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:
@@ -34,23 +34,29 @@
|
||||
</script>
|
||||
|
||||
{#if ready}
|
||||
<!-- svelte-ignore element_invalid_self_closing_tag -->
|
||||
<!--
|
||||
Note: `src` is *not* set as an attribute on <media-player>. Our backend
|
||||
URL ends in `/avc` (not `.mp4`), so Vidstack's URL-suffix sniff fails
|
||||
and it falls back to a HEAD probe — which can pick the wrong loader
|
||||
and produce decode errors. Declaring the source via a nested
|
||||
<source type="video/mp4"> tells Vidstack to use the native MP4 loader
|
||||
directly, no probe needed.
|
||||
-->
|
||||
<media-player
|
||||
class="vds-player max-h-full max-w-full rounded-md shadow-2xl"
|
||||
title={title ?? ''}
|
||||
{src}
|
||||
{poster}
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
load="eager"
|
||||
posterLoad="eager"
|
||||
crossorigin=""
|
||||
preferNativeHLS="false"
|
||||
streamType="on-demand"
|
||||
viewType="video"
|
||||
>
|
||||
<media-provider></media-provider>
|
||||
<media-provider>
|
||||
<source {src} type="video/mp4" />
|
||||
</media-provider>
|
||||
<media-video-layout></media-video-layout>
|
||||
</media-player>
|
||||
{:else}
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Maximize2 } from 'lucide-svelte';
|
||||
import { thumbSrc, thumbSrcSet } from '$lib/stores/session.svelte';
|
||||
import { thumbSrc, thumbSrcSet, videoUrl } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { cancelVideoPrefetch, startVideoPrefetch } from '$lib/stores/videoPrefetch.svelte';
|
||||
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
interface Props {
|
||||
@@ -33,14 +32,30 @@
|
||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
||||
const video = $derived(isVideo(photo));
|
||||
|
||||
// Warm the video transcode + browser HTTP cache on hover so opening the
|
||||
// preview feels instant. The helper de-bounces internally — we just
|
||||
// fire-and-cancel on the hover edges.
|
||||
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
|
||||
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
|
||||
// before mounting the <video> so a cursor skimming across the grid
|
||||
// doesn't kick off N HTTP requests, then we cross-fade in once the
|
||||
// first frame is decoded (videoReady).
|
||||
const HOVER_DELAY = 250;
|
||||
let hoverPlaying = $state(false);
|
||||
let videoReady = $state(false);
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onMouseEnter() {
|
||||
if (video) startVideoPrefetch(hash);
|
||||
if (!video || selected) return;
|
||||
if (hoverTimer) clearTimeout(hoverTimer);
|
||||
hoverTimer = setTimeout(() => {
|
||||
hoverPlaying = true;
|
||||
}, HOVER_DELAY);
|
||||
}
|
||||
function onMouseLeave() {
|
||||
if (video) cancelVideoPrefetch(hash);
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = null;
|
||||
}
|
||||
hoverPlaying = false;
|
||||
videoReady = false;
|
||||
}
|
||||
// Render-size hint for the browser's srcset picker. `view.thumbnailSize`
|
||||
// is the grid's `minmax(<px>, 1fr)` minimum — real tiles may be a hair
|
||||
@@ -103,6 +118,29 @@
|
||||
class:transition={!selected}
|
||||
class:group-hover:scale-105={!selected}
|
||||
/>
|
||||
{#if hoverPlaying}
|
||||
<!--
|
||||
Muted hover-preview video stacked over the thumbnail. We don't
|
||||
unmount the <img> underneath — the video fades in once its
|
||||
first frame is decoded, so the tile never goes blank during
|
||||
the network round-trip. `pointer-events-none` keeps clicks
|
||||
flowing through to the parent <button>.
|
||||
-->
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
src={videoUrl(hash)}
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="auto"
|
||||
tabindex={-1}
|
||||
oncanplay={() => (videoReady = true)}
|
||||
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200"
|
||||
class:opacity-0={!videoReady}
|
||||
class:opacity-100={videoReady}
|
||||
></video>
|
||||
{/if}
|
||||
{#if selected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/if}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user