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:
55
web/package-lock.json
generated
55
web/package-lock.json
generated
@@ -18,7 +18,8 @@
|
|||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"svelte-sonner": "^1.1.1",
|
"svelte-sonner": "^1.1.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwind-variants": "^3.2.2"
|
"tailwind-variants": "^3.2.2",
|
||||||
|
"vidstack": "^1.12.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
@@ -1835,6 +1836,15 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lit-html": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-character": {
|
"node_modules/locate-character": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
@@ -1912,6 +1922,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/media-captions": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-captions/-/media-captions-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
@@ -2513,6 +2532,34 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unplugin": {
|
||||||
|
"version": "1.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz",
|
||||||
|
"integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"acorn": "^8.14.0",
|
||||||
|
"webpack-virtual-modules": "^0.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vidstack": {
|
||||||
|
"version": "1.12.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/vidstack/-/vidstack-1.12.13.tgz",
|
||||||
|
"integrity": "sha512-vuNeyRmWoH/7EoFVDYjp9nkgcqtCMmal518LDeb78dYKgWb+p6+vtY0AzDhrkBv5q1UiCn+xwmjMmwvSlPLuhQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.6.10",
|
||||||
|
"lit-html": "^2.8.0",
|
||||||
|
"media-captions": "^1.0.4",
|
||||||
|
"unplugin": "^1.12.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.13",
|
"version": "8.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||||
@@ -2611,6 +2658,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webpack-virtual-modules": {
|
||||||
|
"version": "0.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
|
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/zimmerframe": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"svelte-sonner": "^1.1.1",
|
"svelte-sonner": "^1.1.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwind-variants": "^3.2.2"
|
"tailwind-variants": "^3.2.2",
|
||||||
|
"vidstack": "^1.12.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||||||
import { setFocused } from '$lib/stores/selection.svelte';
|
import { setFocused } from '$lib/stores/selection.svelte';
|
||||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.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';
|
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||||
@@ -131,15 +132,11 @@
|
|||||||
{@const pf = primaryFile(photoQuery.data)}
|
{@const pf = primaryFile(photoQuery.data)}
|
||||||
{#if isVideo(photoQuery.data)}
|
{#if isVideo(photoQuery.data)}
|
||||||
{@const vf = videoFile(photoQuery.data)}
|
{@const vf = videoFile(photoQuery.data)}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<VideoPlayer
|
||||||
<video
|
|
||||||
src={videoUrl(vf.Hash)}
|
src={videoUrl(vf.Hash)}
|
||||||
poster={thumbUrl(pf.Hash, 'fit_1920')}
|
poster={thumbUrl(pf.Hash, 'fit_1920')}
|
||||||
controls
|
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||||
autoplay
|
/>
|
||||||
muted
|
|
||||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
|
||||||
></video>
|
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(pf.Hash, 'fit_1920')}
|
src={thumbUrl(pf.Hash, 'fit_1920')}
|
||||||
|
|||||||
81
web/src/lib/components/preview/VideoPlayer.svelte
Normal file
81
web/src/lib/components/preview/VideoPlayer.svelte
Normal 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>
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
import { Maximize2 } from 'lucide-svelte';
|
import { Maximize2 } from 'lucide-svelte';
|
||||||
import { thumbSrc, thumbSrcSet } from '$lib/stores/session.svelte';
|
import { thumbSrc, thumbSrcSet } from '$lib/stores/session.svelte';
|
||||||
import { view } from '$lib/stores/view.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';
|
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,6 +31,17 @@
|
|||||||
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
||||||
|
|
||||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
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`
|
// 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
|
// 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
|
// 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>
|
sits as a sibling of the main tile button (not nested) — nesting <button>
|
||||||
in <button> is invalid HTML.
|
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,
|
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.
|
driven by a springy `cubic-bezier(0.34, 1.3, 0.64, 1)` over 300ms.
|
||||||
|
|||||||
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