web: single-pointer selection + post-action focus, video debounce, tags metadata sidebar
Selection: plain arrow nav now clears prior multi-selection so exactly one tile is ringed at a time; shift-extend still grows from the anchor. onApprove / onRestore / onDelete advance focus via focusAfter(ids) before clearing selection, matching onArchive. Preview: defer mounting <VideoPlayer> by 250ms so arrow-skim across video tiles doesn't open and immediately cancel range requests; hard- abort the underlying <video> on unmount so the connection releases. Tags drill view: right-sidebar metadata wired in (single-photo RightSidebar, BulkMetadataSidebar for >=2 selected), resizable edge mirrors the timeline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,31 @@
|
||||
|
||||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
||||
|
||||
// Debounce window before a focused video actually mounts <VideoPlayer>
|
||||
// and opens an HTTP range request. Short enough that a deliberate
|
||||
// click feels instant; long enough that arrow-skim across video tiles
|
||||
// never opens (and immediately cancels) a stream we'd have thrown
|
||||
// away anyway. The grid's thumbnail traffic is the thing this
|
||||
// protects.
|
||||
const VIDEO_LOAD_DELAY_MS = 250;
|
||||
let armedUid = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
// Re-arm on every uid change. Until the timer fires, the template
|
||||
// renders the poster image instead of <VideoPlayer>, so no video
|
||||
// fetch is issued. If uid changes again before the 250 ms is up,
|
||||
// the cleanup clears the pending timer and the new one takes over.
|
||||
const target = uid;
|
||||
if (!target) {
|
||||
armedUid = null;
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
armedUid = target;
|
||||
}, VIDEO_LOAD_DELAY_MS);
|
||||
return () => clearTimeout(t);
|
||||
});
|
||||
|
||||
function focusAt(i: number) {
|
||||
const next = order[i];
|
||||
if (!next) return;
|
||||
@@ -78,17 +103,28 @@
|
||||
|
||||
{#if isVideo(photoQuery.data)}
|
||||
{@const vf = videoFile(photoQuery.data)}
|
||||
<!-- Key on the video hash so navigating to a new video remounts the
|
||||
player. Without this the <media-player> element keeps the
|
||||
previous src bound and `autoplay` doesn't re-fire — clicking
|
||||
a video tile would leave the pane idle on its poster. -->
|
||||
{#key vf.Hash}
|
||||
<VideoPlayer
|
||||
src={videoUrl(vf.Hash)}
|
||||
poster={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||
{#if armedUid === uid}
|
||||
<!-- Key on the video hash so navigating to a new video remounts the
|
||||
player. Without this the <media-player> element keeps the
|
||||
previous src bound and `autoplay` doesn't re-fire — clicking
|
||||
a video tile would leave the pane idle on its poster. -->
|
||||
{#key vf.Hash}
|
||||
<VideoPlayer
|
||||
src={videoUrl(vf.Hash)}
|
||||
poster={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<!-- Debounce window: render the poster only. Matches the
|
||||
still-photo branch's styling so the pane reads identically
|
||||
until <VideoPlayer> arms in. -->
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'}
|
||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{:else}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- the two CSS imports ship the dark theme and video-layout styles
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import 'vidstack/player/styles/default/theme.css';
|
||||
import 'vidstack/player/styles/default/layouts/video.css';
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
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
|
||||
@@ -31,6 +35,27 @@
|
||||
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}
|
||||
@@ -43,6 +68,7 @@
|
||||
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}
|
||||
@@ -64,6 +90,7 @@
|
||||
visible so the transition into the full chrome isn't jarring. -->
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={host}
|
||||
{src}
|
||||
{poster}
|
||||
controls
|
||||
|
||||
Reference in New Issue
Block a user