feat(preview): inline video player via vidstack + hover-warm prefetch
Replace PreviewOverlay's bare <video controls> with a vidstack-driven player wrapping the same source URL. Vidstack's default video layout provides a polished chrome (gradient bottom bar, large play overlay, hover-revealed scrubber) and registers <media-player>/<media-provider> custom elements that vendor the browser quirks. To keep first-frame latency low, PhotoTile starts a hover-warm fetch of the playback URL after a short (120 ms) delay — a single Range request of the first 512 KB pages the backend's pre-transcoded MP4 cache file into the OS page cache and lands in the browser's HTTP cache, so when the player mounts and issues its own bytes=0- request the response is satisfied from disk. Each hash is warmed at most once per session; AbortController cancels hovers that don't commit. The vidstack modules are dynamically imported on mount so they never run during SvelteKit's static prerender — they side-effect customElements.define() calls which would crash under SSR.
This commit is contained in:
60
web/src/lib/stores/videoPrefetch.svelte.ts
Normal file
60
web/src/lib/stores/videoPrefetch.svelte.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user