diff --git a/web/package-lock.json b/web/package-lock.json index 5726b89..fc3308e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,7 +18,8 @@ "mode-watcher": "^1.1.0", "svelte-sonner": "^1.1.1", "tailwind-merge": "^3.6.0", - "tailwind-variants": "^3.2.2" + "tailwind-variants": "^3.2.2", + "vidstack": "^1.12.13" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", @@ -1835,6 +1836,15 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -1912,6 +1922,15 @@ "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": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2513,6 +2532,34 @@ "dev": true, "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": { "version": "8.0.13", "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": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/web/package.json b/web/package.json index dfc4d5b..90c09a0 100644 --- a/web/package.json +++ b/web/package.json @@ -34,6 +34,7 @@ "mode-watcher": "^1.1.0", "svelte-sonner": "^1.1.1", "tailwind-merge": "^3.6.0", - "tailwind-variants": "^3.2.2" + "tailwind-variants": "^3.2.2", + "vidstack": "^1.12.13" } } diff --git a/web/src/lib/components/preview/PreviewOverlay.svelte b/web/src/lib/components/preview/PreviewOverlay.svelte index b19dafd..d6f3ad2 100644 --- a/web/src/lib/components/preview/PreviewOverlay.svelte +++ b/web/src/lib/components/preview/PreviewOverlay.svelte @@ -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(() => ({ @@ -131,15 +132,11 @@ {@const pf = primaryFile(photoQuery.data)} {#if isVideo(photoQuery.data)} {@const vf = videoFile(photoQuery.data)} - - + title={photoQuery.data.OriginalName ?? pf.Name ?? ''} + /> {:else} ` 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 / + - `vidstack/player/layouts/default` registers + - the two CSS imports ship the dark theme and video-layout styles +--> + + +{#if ready} + + + + + +{:else} + + + +{/if} + + diff --git a/web/src/lib/components/timeline/PhotoTile.svelte b/web/src/lib/components/timeline/PhotoTile.svelte index b518a96..8812067 100644 --- a/web/src/lib/components/timeline/PhotoTile.svelte +++ b/web/src/lib/components/timeline/PhotoTile.svelte @@ -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(, 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