feat(timeline): muted hover-to-play preview on video tiles

PhotoPrism plays a silent preview of the actual video when you hover
its grid tile; this mirrors that. After a 250ms debounce the tile
mounts a muted, looping <video> over the thumbnail and cross-fades it
in on first decoded frame, so cursor-skimming doesn't fire N requests
and the tile never blanks mid-fetch. The byte-prefetch helper added
in af96922 is now redundant — the hover <video> warms the same caches
on its own.

Also tells Vidstack the playback URL is video/mp4 via a nested
<source>: our /api/v1/videos/.../avc URL has no extension, so
Vidstack's suffix sniff was failing, falling back to a HEAD probe,
and picking the wrong loader (which surfaced as
NS_ERROR_DOM_MEDIA_METADATA_ERR in Firefox).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 08:20:16 +02:00
parent af96922e89
commit 9d955d6b94
3 changed files with 56 additions and 72 deletions

View File

@@ -1,60 +0,0 @@
/**
* Hover-warm cache for video playback URLs.
*
* When a user hovers a video tile, we ask the browser to fetch the first
* chunk of the playback URL. That single Range request:
* 1. Forces the backend's pre-transcoded MP4 cache file open (paging it
* into the OS page cache so the real player request hits warm bytes).
* 2. Lands in the browser's HTTP cache, so when the player mounts and
* issues its own Range: bytes=0- request the response is satisfied
* from disk and playback starts within a frame or two.
*
* We delay the fetch slightly so users who are scrubbing their cursor
* across the grid don't pay the cost; cancellation via AbortController
* keeps wasted bytes bounded.
*
* Each hash is warmed at most once per session — the prefetch is purely
* cache-warming, so a second hover would be a no-op anyway.
*/
import { videoUrl } from '$lib/stores/session.svelte';
const HOVER_DELAY_MS = 120;
const PREFETCH_BYTES = 524288; // 512 KB — enough to start playback in most cases.
const warmed = new Set<string>();
const pending = new Map<string, { timer: number; controller: AbortController }>();
export function startVideoPrefetch(hash: string): void {
if (!hash || warmed.has(hash) || pending.has(hash)) return;
const url = videoUrl(hash);
if (!url) return;
const controller = new AbortController();
const timer = window.setTimeout(async () => {
try {
await fetch(url, {
method: 'GET',
headers: { Range: `bytes=0-${PREFETCH_BYTES - 1}` },
signal: controller.signal,
credentials: 'same-origin',
cache: 'default'
});
warmed.add(hash);
} catch {
// Aborted hovers or network blips are expected — silently drop.
} finally {
pending.delete(hash);
}
}, HOVER_DELAY_MS);
pending.set(hash, { timer, controller });
}
export function cancelVideoPrefetch(hash: string): void {
const entry = pending.get(hash);
if (!entry) return;
clearTimeout(entry.timer);
entry.controller.abort();
pending.delete(hash);
}