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:
@@ -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')}
|
||||
|
||||
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 { 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.
|
||||
|
||||
Reference in New Issue
Block a user