From 11c63da98ec19e4e6e184217a78d629fea0ac1e6 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Wed, 21 Jan 2026 21:34:53 +1300 Subject: [PATCH] Improved TV controls --- materialious/src/lib/components/Player.svelte | 42 +++++---- .../routes/(no-layout)/tv/[slug]/+page.svelte | 87 +++++++++++-------- 2 files changed, 74 insertions(+), 55 deletions(-) diff --git a/materialious/src/lib/components/Player.svelte b/materialious/src/lib/components/Player.svelte index 61ff7720..8a7ae06e 100644 --- a/materialious/src/lib/components/Player.svelte +++ b/materialious/src/lib/components/Player.svelte @@ -61,6 +61,8 @@ interface Props { data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null }; + currentTime?: number; + userManualSeeking?: boolean; isEmbed?: boolean; playerElement?: HTMLMediaElement | undefined; showControls?: boolean; @@ -70,6 +72,8 @@ data, isEmbed = false, playerElement = $bindable(undefined), + currentTime = $bindable(0), + userManualSeeking = $bindable(false), showControls = false }: Props = $props(); @@ -98,7 +102,6 @@ let playerContainer: HTMLElement; let playerBufferBar: HTMLElement | undefined = $state(); let playerCurrentPlaybackState = $state(false); - let playerCurrentTime = $state(0); let playerMaxKnownTime = $state(data.video.lengthSeconds); let playerIsBuffering = $state(true); let playerVolume = $state(0); @@ -115,23 +118,22 @@ let playerSliderElement: HTMLElement | undefined = $state(); let playerSliderDebounce: ReturnType; let playerVolumeElement: HTMLElement | undefined = $state(); - let playerUserManualSeeking: boolean = $state(false); let playerIsFullscreen: boolean = $state(false); const playerTimelineSlider = new Slider({ min: 0, step: 0.1, - value: () => playerCurrentTime, + value: () => currentTime, onValueChange: (timeToSet) => { - playerUserManualSeeking = true; - playerCurrentTime = timeToSet; + userManualSeeking = true; + currentTime = timeToSet; if (playerSliderDebounce) clearTimeout(playerSliderDebounce); playerSliderDebounce = setTimeout(() => { if (playerElement) { playerElement.currentTime = timeToSet; - playerUserManualSeeking = false; + userManualSeeking = false; } }, 300); }, @@ -912,11 +914,11 @@ playerElement?.addEventListener('timeupdate', () => { if (!playerElement) return; - if (!playerUserManualSeeking) { - playerCurrentTime = playerElement.currentTime ?? 0; + if (!userManualSeeking) { + currentTime = playerElement.currentTime ?? 0; } - if (playerMaxKnownTime === 0 || playerCurrentTime > playerMaxKnownTime) { + if (playerMaxKnownTime === 0 || currentTime > playerMaxKnownTime) { playerMaxKnownTime = Number(playerElement.currentTime); } @@ -935,7 +937,7 @@ playerBufferedTo = buffered.end(0); const bufferedPercent = (playerBufferedTo / playerMaxKnownTime) * 100; - const progressPercent = (playerCurrentTime / playerMaxKnownTime) * 100; + const progressPercent = (currentTime / playerMaxKnownTime) * 100; const bufferAhead = Math.max(0, bufferedPercent - progressPercent); @@ -1203,7 +1205,7 @@ {#if data.video.liveNow} {$_('thumbnail.live')} {:else} - {videoLength(playerCurrentTime)} / {videoLength(playerMaxKnownTime)} + {videoLength(currentTime)} / {videoLength(playerMaxKnownTime)} {/if}

@@ -1248,12 +1250,13 @@

- {#if !playerUserManualSeeking} + {#if !userManualSeeking}
{#if playerCloestSponsor} {sponsorSegments[playerCloestSponsor.category]} @@ -1269,15 +1272,11 @@
- {videoLength(playerCurrentTime)} + {videoLength(currentTime)}
-
+
{#each data.content.timestamps as chapter, index (chapter)}
{#if !$isAndroidTvStore} @@ -1685,6 +1684,11 @@ background-color: var(--tertiary); } + .disable-tv { + pointer-events: none; + cursor: not-allowed; + } + menu.mobile { transform: scale(1) translateY(-40%) translateX(0); width: 300px; diff --git a/materialious/src/routes/(no-layout)/tv/[slug]/+page.svelte b/materialious/src/routes/(no-layout)/tv/[slug]/+page.svelte index 86fc2e11..5fac02d1 100644 --- a/materialious/src/routes/(no-layout)/tv/[slug]/+page.svelte +++ b/materialious/src/routes/(no-layout)/tv/[slug]/+page.svelte @@ -18,45 +18,51 @@ let showInfo = $state(false); let playerCurrentTime: number = $state(0); let showControls = $state(false); + let currentTime = $state(0); + let userManualSeeking = $state(false); - let seekInterval: ReturnType | undefined; - let seekStartTime: number = 0; + let seekRaf: number | null = null; + let seekStartTime = 0; let isSeeking = false; + let lastUpdate = 0; function startSeeking(direction: 'left' | 'right') { + showControls = true; + if (!playerElement || showInfo || isSeeking) return true; + userManualSeeking = true; isSeeking = true; - showControls = true; - seekStartTime = Date.now(); + lastUpdate = performance.now(); - const videoDuration = data.video.lengthSeconds; - const baseSeekAmount = videoDuration * 0.005; // 0.5% of video duration as base + const duration = data.video.lengthSeconds; + const base = duration * 0.005; // ~0.5% - // Initial seek - if (direction === 'right') { - playerElement.currentTime = Math.min( - playerElement.currentTime + baseSeekAmount, - videoDuration - ); - } else { - playerElement.currentTime = Math.max(playerElement.currentTime - baseSeekAmount, 0); + function step(now: number) { + if (!isSeeking) return; + + const elapsedTotal = (now - seekStartTime) / 1000; // total hold time + const throttleDelta = now - lastUpdate; + + // only update every ~100ms so we don’t overshoot + if (throttleDelta > 100) { + const factor = 1 + Math.min(elapsedTotal ** 1.2, 3); // gentler acceleration + const amount = base * factor; + + if (direction === 'right') { + currentTime = Math.min(currentTime + amount, duration); + } else { + currentTime = Math.max(currentTime - amount, 0); + } + + lastUpdate = now; + } + + seekRaf = requestAnimationFrame(step); } - // Progressive seeking - seekInterval = setInterval(() => { - if (!playerElement || !isSeeking) return; - - const holdDuration = (Date.now() - seekStartTime) / 1000; // in seconds - const acceleration = Math.min(holdDuration * 0.5, 5); // Cap at 5x acceleration - const seekAmount = baseSeekAmount * (1 + acceleration); - - if (direction === 'right') { - playerElement.currentTime = Math.min(playerElement.currentTime + seekAmount, videoDuration); - } else { - playerElement.currentTime = Math.max(playerElement.currentTime - seekAmount, 0); - } - }, 100); + seekStartTime = performance.now(); + seekRaf = requestAnimationFrame(step); return false; } @@ -64,11 +70,16 @@ function stopSeeking() { isSeeking = false; showControls = false; - if (seekInterval) { - clearInterval(seekInterval); - seekInterval = undefined; + userManualSeeking = false; + + if (seekRaf !== null) { + cancelAnimationFrame(seekRaf); + seekRaf = null; + } + + if (playerElement) { + playerElement.currentTime = currentTime; } - return false; } onMount(() => { @@ -149,14 +160,18 @@ onDestroy(() => { Mousetrap.unbind(['up', 'down', 'left', 'right', 'enter']); - if (seekInterval) { - clearInterval(seekInterval); - } }); {#key data.video.videoId} - + {/key} {#if showInfo}