Improved scrubbing using keybinds

This commit is contained in:
WardPearce
2026-03-03 01:12:41 +13:00
parent 6bce6e1da1
commit 38bfe73924
4 changed files with 117 additions and 38 deletions
@@ -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();
@@ -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 @@
<div
class="player-slider full-width"
class:disable-tv={$isAndroidTvStore}
{...playerTimelineSlider.root}
{...mergeAttrs(playerTimelineSlider.root, {
tabindex: -1
})}
onmouseleave={() => (playerShowTimelineThumbnail = false)}
onmousemove={timelineMouseMove}
bind:this={playerSliderElement}
>
@@ -275,14 +355,18 @@
{/snippet}
<div class="track">
{#if !userManualSeeking && playerShowTimelineThumbnail}
<div bind:this={playerTimelineTooltip} class="timeline tooltip">
<div
bind:this={playerTimelineTooltip}
class="timeline tooltip"
class:tooltip-hide={!playerTimelineThumbnailLoaded}
>
{@render timelineTooltip('timeline', playerTimelineTimeHover)}
</div>
{/if}
<div class="range"></div>
<div {...playerTimelineSlider.thumb}>
{#if playerSliderInteracted}
<div class="tooltip thumb">
{#if playerSliderInteracted && playerShowTimelineThumbnail}
<div class="tooltip thumb" class:tooltip-hide={!playerTimelineThumbnailLoaded}>
{@render timelineTooltip('thumb', currentTime)}
</div>
{/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 {
+1 -1
View File
@@ -28,7 +28,7 @@ body {
}
.hide {
display: none;
display: none !important;
}
article {
+6 -4
View File
@@ -115,8 +115,8 @@ export async function drawTimelineThumbnail(
imageCache: ImageCache,
thumbnails: TimelineThumbnail[],
currentTime: number
): Promise<void> {
if (!thumbnails.length) return;
): Promise<boolean> {
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;
}