The old SplitGrid + InlinePreview pane is replaced by a full-screen PreviewModal mounted once at the layout root. Open via Space on the focused tile or double-click; close on Esc (or X / Space again). Inside, PreviewPane renders the focused photo, RightSidebar carries the metadata, BulkActionBar reuses the existing per-photo actions, and PreviewCarousel windows ±50 thumbs around the focused index. Selection contract matches the grid: plain click reduces, shift extends the range, ⌘/Ctrl toggles, plain arrow drops the multi- selection, shift-arrow extends. New clearBulkToFirst() helper makes Esc / Clear collapse a bulk back to single-focus on its first member before the next press fully dismisses (modal closes, grid clears focus). Tags route reorganised into /tags/[category]/[[value]] with its own +layout and TagsBrowserSidebar; the old monolithic /tags/+page is trimmed to a legacy redirect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
3.8 KiB
Svelte
125 lines
3.8 KiB
Svelte
<!--
|
|
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 { onDestroy, 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);
|
|
// Whichever branch is currently mounted (fallback <video> or vidstack
|
|
// <media-player>) binds here. On destroy we reach through this ref to
|
|
// abort the in-flight range request — see onDestroy below.
|
|
let host = $state<HTMLElement | undefined>();
|
|
|
|
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;
|
|
});
|
|
|
|
// Hard-abort the underlying <video> on unmount. Detaching the element
|
|
// alone doesn't reliably release the HTTP range request — the connection
|
|
// can linger as "Pending" long enough to starve the grid's thumbnail
|
|
// queue. Explicitly pausing, clearing src, and calling load() tells the
|
|
// network layer to drop the stream now.
|
|
onDestroy(() => {
|
|
if (!host) return;
|
|
const v =
|
|
host.tagName.toLowerCase() === 'video'
|
|
? (host as HTMLVideoElement)
|
|
: host.querySelector('video');
|
|
if (!v) return;
|
|
try {
|
|
v.pause();
|
|
v.removeAttribute('src');
|
|
v.load();
|
|
} catch {
|
|
// Element may already be detached / GC'd; ignore.
|
|
}
|
|
});
|
|
</script>
|
|
|
|
{#if ready}
|
|
<!--
|
|
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
|
|
bind:this={host}
|
|
class="vds-player max-h-full max-w-full rounded-md shadow-2xl"
|
|
title={title ?? ''}
|
|
{poster}
|
|
autoplay
|
|
muted
|
|
playsinline
|
|
load="eager"
|
|
posterLoad="eager"
|
|
streamType="on-demand"
|
|
viewType="video"
|
|
>
|
|
<media-provider>
|
|
<source {src} type="video/mp4" />
|
|
</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
|
|
bind:this={host}
|
|
{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) {
|
|
/* Let the player size to the video's intrinsic aspect ratio,
|
|
capped by the host pane. Don't set width/height to 100% — that
|
|
was stretching widescreen video into the square preview pane.
|
|
The default vidstack layout reads the loaded media's aspect
|
|
and sizes the box accordingly; we just clamp the upper bound
|
|
so it can't escape its host pane. */
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
--media-brand: #3b82f6;
|
|
--media-focus-ring-color: #3b82f6;
|
|
}
|
|
:global(.vds-player video),
|
|
:global(.vds-player media-provider) {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
</style>
|