From 38bfe7392496e95ed9ea889839b2647a00f26161 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Tue, 3 Mar 2026 01:12:41 +1300 Subject: [PATCH] Improved scrubbing using keybinds --- .../src/lib/components/player/Player.svelte | 16 +-- .../src/lib/components/player/Timeline.svelte | 127 +++++++++++++++--- materialious/src/lib/css/global.css | 2 +- materialious/src/lib/player/thumbnails.ts | 10 +- 4 files changed, 117 insertions(+), 38 deletions(-) diff --git a/materialious/src/lib/components/player/Player.svelte b/materialious/src/lib/components/player/Player.svelte index 7462f9f8..5bed65a8 100644 --- a/materialious/src/lib/components/player/Player.svelte +++ b/materialious/src/lib/components/player/Player.svelte @@ -601,20 +601,6 @@ }); if (!$isAndroidTvStore) { - Mousetrap.bind('right', () => { - if (!playerElement) return; - playerElement.currentTime = playerElement.currentTime + playerDoubleTapSeek; - showPlayerUI(); - return false; - }); - - Mousetrap.bind('left', () => { - if (!playerElement) return; - playerElement.currentTime = playerElement.currentTime - playerDoubleTapSeek; - showPlayerUI(); - return false; - }); - Mousetrap.bind('enter', () => { if (segmentManualSkip) { skipSegment(segmentManualSkip); @@ -804,7 +790,7 @@ window.removeEventListener('resize', updateVideoPlayerHeight); - Mousetrap.unbind(['left', 'right', 'space', 'c', 'f', 'shift+left', 'shift+right', 'enter']); + Mousetrap.unbind(['space', 'c', 'f', 'shift+left', 'shift+right', 'enter']); if (watchProgressInterval) clearInterval(watchProgressInterval); if (sabrAdapter) sabrAdapter.dispose(); diff --git a/materialious/src/lib/components/player/Timeline.svelte b/materialious/src/lib/components/player/Timeline.svelte index 5ebbcfa4..f7476c06 100644 --- a/materialious/src/lib/components/player/Timeline.svelte +++ b/materialious/src/lib/components/player/Timeline.svelte @@ -15,6 +15,8 @@ import { onDestroy, onMount } from 'svelte'; import { videoLength } from '$lib/numbers'; import { truncate } from '$lib/misc'; + import { mergeAttrs } from 'melt'; + import Mousetrap from 'mousetrap'; let { playerElement, @@ -37,7 +39,7 @@ } = $props(); let playerSliderInteracted = $state(false); - let playerShowTimelineThumbnail = $state(true); + let playerShowTimelineThumbnail = $state(false); let playerCloestTimestamp: Timestamp | undefined = $state(); let playerCloestSponsor: Segment | undefined = $state(); let playerSliderElement: HTMLElement | undefined = $state(); @@ -45,6 +47,7 @@ let playerTimelineTooltip: HTMLDivElement | undefined = $state(); let playerTimelineThumbnails: TimelineThumbnail[] = $state([]); let playerTimelineThumbnailsCache = new ImageCache(); + let playerTimelineThumbnailLoaded = $state(false); let playerTimelineThumbnailCanvas: { timeline?: HTMLCanvasElement; thumb?: HTMLCanvasElement; @@ -52,6 +55,14 @@ let playerTimelineTimeHover = $state(0); let playerBufferBar: HTMLElement | undefined = $state(); let playerBufferedTo: number = $state(0); + let playerScrubbingHoldTime = 0; + let playerScrubbingDirection: 1 | -1 = 1; + let playerScrubbingIsActive = false; + let playerScrubbingLastTimestamp: number | null = null; + let playerScrubbingPlaybackState: 'paused' | 'playing' | undefined; + + const playerScrubbingBaseSpeed = 0.05; + const playerScrubbingAcceleration = 0.002; const sponsorSegments = { sponsor: $_('layout.sponsors.sponsor'), @@ -71,6 +82,7 @@ onValueChange: async (timeToSet) => { playerSliderInteracted = true; userManualSeeking = true; + playerShowTimelineThumbnail = true; currentTime = timeToSet; showPlayerUI(); @@ -95,6 +107,11 @@ }); onMount(async () => { + Mousetrap.bind('right', () => playerScrubbingStart(1)); + Mousetrap.bind('left', () => playerScrubbingStart(-1)); + Mousetrap.bind('right', playerScrubbingStop, 'keyup'); + Mousetrap.bind('left', playerScrubbingStop, 'keyup'); + playerElement?.addEventListener('timeupdate', () => { const buffered = playerElement.buffered; @@ -150,8 +167,68 @@ onDestroy(() => { playerTimelineThumbnailsCache.clear(); + + Mousetrap.unbind(['left', 'right']); }); + async function playerScrubbingFrame(timestamp: number) { + if (!playerScrubbingIsActive || !playerElement || !playerSliderElement) return; + + if (!playerScrubbingPlaybackState) + playerScrubbingPlaybackState = playerElement.paused ? 'paused' : 'playing'; + + playerElement.pause(); + showPlayerUI(); + + if (playerScrubbingLastTimestamp === null) playerScrubbingLastTimestamp = timestamp; + const delta = timestamp - playerScrubbingLastTimestamp; + + playerScrubbingLastTimestamp = timestamp; + playerScrubbingHoldTime += delta; + + // Progressive acceleration + const speed = playerScrubbingBaseSpeed + playerScrubbingHoldTime * playerScrubbingAcceleration; + currentTime += speed * playerScrubbingDirection; + + // Clamp to video duration + if (currentTime < 0) currentTime = 0; + if (currentTime > playerMaxKnownTime) currentTime = playerMaxKnownTime; + + playerShowTimelineThumbnail = true; + userManualSeeking = false; + playerTimelineTimeHover = currentTime; + + await renderTimelineTooltip(currentTime / playerMaxKnownTime); + + requestAnimationFrame(playerScrubbingFrame); + } + + function playerScrubbingStart(dir: 1 | -1) { + if (playerScrubbingIsActive) return; + + playerScrubbingDirection = dir; + playerScrubbingHoldTime = 0; + playerScrubbingLastTimestamp = null; + playerScrubbingIsActive = true; + + requestAnimationFrame(playerScrubbingFrame); + } + + function playerScrubbingStop() { + if (!playerScrubbingIsActive || !playerElement) return; + + playerScrubbingIsActive = false; + playerScrubbingHoldTime = 0; + playerScrubbingLastTimestamp = null; + playerShowTimelineThumbnail = false; + + playerElement.currentTime = currentTime; + + if (playerScrubbingPlaybackState === 'playing') playerElement?.play(); + + playerScrubbingPlaybackState = undefined; + } + const markerGapSize = 0.1; const minVisiblePercent = 0.05; function timelineMarkerWidth(startTime: number, endTime: number): string { @@ -189,10 +266,11 @@ } async function setPlayerTimelineThumbnails(time: number, canvas: HTMLCanvasElement) { + playerTimelineThumbnailLoaded = false; const canvasContext = canvas.getContext('2d'); if (canvasContext) { - await drawTimelineThumbnail( + playerTimelineThumbnailLoaded = await drawTimelineThumbnail( canvasContext, playerTimelineThumbnailsCache, playerTimelineThumbnails, @@ -202,25 +280,24 @@ } let requestAnimationTooltip: number | undefined; - let latestMouseX: number | undefined; + let timelineMouseXPosition: number | undefined; function timelineMouseMove(event: MouseEvent) { - latestMouseX = event.clientX; + if (!playerSliderElement) return; + timelineMouseXPosition = event.clientX; if (!requestAnimationTooltip) { - requestAnimationTooltip = requestAnimationFrame(updateTooltip); + const rect = playerSliderElement.getBoundingClientRect(); + const percent = Math.min(Math.max((timelineMouseXPosition - rect.left) / rect.width, 0), 1); + + playerTimelineTimeHover = percent * (playerMaxKnownTime ?? 0); + requestAnimationTooltip = requestAnimationFrame(() => renderTimelineTooltip(percent)); } } - async function updateTooltip() { - if (!playerSliderElement || latestMouseX === undefined) { - requestAnimationTooltip = undefined; - return; - } - const rect = playerSliderElement.getBoundingClientRect(); - const percent = Math.min(Math.max((latestMouseX - rect.left) / rect.width, 0), 1); + async function renderTimelineTooltip(percent: number) { + if (!playerSliderElement) return; - playerTimelineTimeHover = percent * (video.lengthSeconds ?? 0); setPlayerTimelineChapters(playerTimelineTimeHover); if (playerTimelineThumbnailCanvas.timeline) { @@ -250,7 +327,10 @@
(playerShowTimelineThumbnail = false)} onmousemove={timelineMouseMove} bind:this={playerSliderElement} > @@ -275,14 +355,18 @@ {/snippet}
{#if !userManualSeeking && playerShowTimelineThumbnail} -
+
{@render timelineTooltip('timeline', playerTimelineTimeHover)}
{/if}
- {#if playerSliderInteracted} -
+ {#if playerSliderInteracted && playerShowTimelineThumbnail} +
{@render timelineTooltip('thumb', currentTime)}
{/if} @@ -327,12 +411,19 @@ position: absolute; left: 0; transform: translateX(0%); - transition: none; pointer-events: none; will-change: transform; padding: 0; display: block; border-radius: var(--border-radius); + visibility: visible; + opacity: 1; + transition: opacity 0.5s ease; + } + + .timeline.tooltip.tooltip-hide { + opacity: 0; + pointer-events: none; } .tooltip.thumb { diff --git a/materialious/src/lib/css/global.css b/materialious/src/lib/css/global.css index 8f23c694..0a383162 100644 --- a/materialious/src/lib/css/global.css +++ b/materialious/src/lib/css/global.css @@ -28,7 +28,7 @@ body { } .hide { - display: none; + display: none !important; } article { diff --git a/materialious/src/lib/player/thumbnails.ts b/materialious/src/lib/player/thumbnails.ts index fd693304..6bca216c 100644 --- a/materialious/src/lib/player/thumbnails.ts +++ b/materialious/src/lib/player/thumbnails.ts @@ -115,8 +115,8 @@ export async function drawTimelineThumbnail( imageCache: ImageCache, thumbnails: TimelineThumbnail[], currentTime: number -): Promise { - if (!thumbnails.length) return; +): Promise { + if (!thumbnails.length) return false; const thumbnail = findElementForTime( thumbnails, @@ -125,11 +125,11 @@ export async function drawTimelineThumbnail( (thumbnail: TimelineThumbnail) => thumbnail.endTime ); - if (!thumbnail) return; + if (!thumbnail) return false; const img = await imageCache.load(thumbnail.url); if (!img) { - return; + return false; } const thumbWidth = thumbnail.width; @@ -147,4 +147,6 @@ export async function drawTimelineThumbnail( thumbWidth, thumbHeight ); + + return true; }