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:
2026-05-18 08:13:59 +02:00
parent ccbc1050de
commit af96922e89
6 changed files with 219 additions and 10 deletions

View File

@@ -10,6 +10,7 @@
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
const photoQuery = createQuery<PpPhoto>(() => ({
@@ -131,15 +132,11 @@
{@const pf = primaryFile(photoQuery.data)}
{#if isVideo(photoQuery.data)}
{@const vf = videoFile(photoQuery.data)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
<VideoPlayer
src={videoUrl(vf.Hash)}
poster={thumbUrl(pf.Hash, 'fit_1920')}
controls
autoplay
muted
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
></video>
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
/>
{:else}
<img
src={thumbUrl(pf.Hash, 'fit_1920')}

View File

@@ -0,0 +1,81 @@
<!--
Vidstack-based video player. Wraps the native `<video>` element with a
polished, PhotoPrism-style chrome (gradient bottom bar, large play
overlay, hover-revealed scrubber). Element registration is dynamically
imported so it never runs during SvelteKit's static prerender.
Imports are side-effecting:
- `vidstack/player` registers <media-player> / <media-provider>
- `vidstack/player/layouts/default` registers <media-video-layout>
- the two CSS imports ship the dark theme and video-layout styles
-->
<script lang="ts">
import { onMount } from 'svelte';
import 'vidstack/player/styles/default/theme.css';
import 'vidstack/player/styles/default/layouts/video.css';
interface Props {
src: string;
poster?: string;
title?: string;
}
let { src, poster, title }: Props = $props();
let ready = $state(false);
onMount(async () => {
// Bundle these into the client chunk only — the modules side-effect
// `customElements.define()` calls, which would crash under SSR.
await import('vidstack/player');
await import('vidstack/player/ui');
await import('vidstack/player/layouts/default');
ready = true;
});
</script>
{#if ready}
<!-- svelte-ignore element_invalid_self_closing_tag -->
<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-video-layout></media-video-layout>
</media-player>
{:else}
<!-- Fallback while the Vidstack module is loading. Keeps the poster
visible so the transition into the full chrome isn't jarring. -->
<!-- svelte-ignore a11y_media_has_caption -->
<video
{src}
{poster}
controls
autoplay
muted
playsinline
preload="auto"
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
></video>
{/if}
<style>
:global(.vds-player) {
width: 100%;
height: 100%;
max-height: 100%;
aspect-ratio: auto;
--media-brand: #3b82f6;
--media-focus-ring-color: #3b82f6;
}
</style>

View File

@@ -18,6 +18,7 @@
import { Maximize2 } from 'lucide-svelte';
import { thumbSrc, thumbSrcSet } 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 {
@@ -30,6 +31,17 @@
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
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.
function onMouseEnter() {
if (video) startVideoPrefetch(hash);
}
function onMouseLeave() {
if (video) cancelVideoPrefetch(hash);
}
// 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
// wider when the grid stretches to fill the column, but `tile_*` is
@@ -46,7 +58,12 @@
sits as a sibling of the main tile button (not nested) — nesting <button>
in <button> is invalid HTML.
-->
<div class="group relative h-full w-full">
<div
class="group relative h-full w-full"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
role="presentation"
>
<!--
Selection animation: scale to 90% + blue ring + blue tint overlay,
driven by a springy `cubic-bezier(0.34, 1.3, 0.64, 1)` over 300ms.

View 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);
}