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:
@@ -138,6 +138,10 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
: selection.order.length - 1
|
: selection.order.length - 1
|
||||||
: Math.min(Math.max(0, cur + delta), selection.order.length - 1);
|
: Math.min(Math.max(0, cur + delta), selection.order.length - 1);
|
||||||
const nextUid = selection.order[next];
|
const nextUid = selection.order[next];
|
||||||
|
// Plain arrow nav collapses any prior multi-selection down to the
|
||||||
|
// cursor: one ringed tile at a time. Shift-extend keeps `ids`
|
||||||
|
// growing from the anchor (selectRange runs after this).
|
||||||
|
if (!extending) clearSelection();
|
||||||
setFocused(nextUid);
|
setFocused(nextUid);
|
||||||
if (!extending) setAnchor(nextUid);
|
if (!extending) setAnchor(nextUid);
|
||||||
const tile = node.querySelector<HTMLElement>(`[data-uid="${nextUid}"]`);
|
const tile = node.querySelector<HTMLElement>(`[data-uid="${nextUid}"]`);
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Render the right-sidebar toggle. Routes without a right panel
|
/** Render the right-sidebar toggle. Routes without a right panel
|
||||||
* (map, ratings, colors, tags) leave this off. */
|
* (map, ratings, colors) leave this off. /tags toggles it on
|
||||||
|
* only while drilled into a category, where the photo grid is
|
||||||
|
* showing real photos with metadata. */
|
||||||
showRightToggle?: boolean;
|
showRightToggle?: boolean;
|
||||||
children?: import('svelte').Snippet;
|
children?: import('svelte').Snippet;
|
||||||
trailing?: import('svelte').Snippet;
|
trailing?: import('svelte').Snippet;
|
||||||
|
|||||||
@@ -38,6 +38,31 @@
|
|||||||
|
|
||||||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
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) {
|
function focusAt(i: number) {
|
||||||
const next = order[i];
|
const next = order[i];
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
@@ -78,6 +103,7 @@
|
|||||||
|
|
||||||
{#if isVideo(photoQuery.data)}
|
{#if isVideo(photoQuery.data)}
|
||||||
{@const vf = videoFile(photoQuery.data)}
|
{@const vf = videoFile(photoQuery.data)}
|
||||||
|
{#if armedUid === uid}
|
||||||
<!-- Key on the video hash so navigating to a new video remounts the
|
<!-- Key on the video hash so navigating to a new video remounts the
|
||||||
player. Without this the <media-player> element keeps the
|
player. Without this the <media-player> element keeps the
|
||||||
previous src bound and `autoplay` doesn't re-fire — clicking
|
previous src bound and `autoplay` doesn't re-fire — clicking
|
||||||
@@ -89,6 +115,16 @@
|
|||||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/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"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
- the two CSS imports ship the dark theme and video-layout styles
|
- the two CSS imports ship the dark theme and video-layout styles
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import 'vidstack/player/styles/default/theme.css';
|
import 'vidstack/player/styles/default/theme.css';
|
||||||
import 'vidstack/player/styles/default/layouts/video.css';
|
import 'vidstack/player/styles/default/layouts/video.css';
|
||||||
|
|
||||||
@@ -22,6 +22,10 @@
|
|||||||
let { src, poster, title }: Props = $props();
|
let { src, poster, title }: Props = $props();
|
||||||
|
|
||||||
let ready = $state(false);
|
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 () => {
|
onMount(async () => {
|
||||||
// Bundle these into the client chunk only — the modules side-effect
|
// Bundle these into the client chunk only — the modules side-effect
|
||||||
@@ -31,6 +35,27 @@
|
|||||||
await import('vidstack/player/layouts/default');
|
await import('vidstack/player/layouts/default');
|
||||||
ready = true;
|
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>
|
</script>
|
||||||
|
|
||||||
{#if ready}
|
{#if ready}
|
||||||
@@ -43,6 +68,7 @@
|
|||||||
directly, no probe needed.
|
directly, no probe needed.
|
||||||
-->
|
-->
|
||||||
<media-player
|
<media-player
|
||||||
|
bind:this={host}
|
||||||
class="vds-player max-h-full max-w-full rounded-md shadow-2xl"
|
class="vds-player max-h-full max-w-full rounded-md shadow-2xl"
|
||||||
title={title ?? ''}
|
title={title ?? ''}
|
||||||
{poster}
|
{poster}
|
||||||
@@ -64,6 +90,7 @@
|
|||||||
visible so the transition into the full chrome isn't jarring. -->
|
visible so the transition into the full chrome isn't jarring. -->
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
<video
|
<video
|
||||||
|
bind:this={host}
|
||||||
{src}
|
{src}
|
||||||
{poster}
|
{poster}
|
||||||
controls
|
controls
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
} else {
|
} else {
|
||||||
toast.success(`Kept ${ids.length}`);
|
toast.success(`Kept ${ids.length}`);
|
||||||
}
|
}
|
||||||
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,6 +125,7 @@
|
|||||||
await withBusy(async () => {
|
await withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
await batchDelete(ids);
|
await batchDelete(ids);
|
||||||
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
toast.success(`Deleted ${ids.length}`);
|
toast.success(`Deleted ${ids.length}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -142,6 +144,7 @@
|
|||||||
await batchArchive(ids);
|
await batchArchive(ids);
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
});
|
});
|
||||||
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
toast.success(`Restored ${ids.length}`);
|
toast.success(`Restored ${ids.length}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
import { isAuthenticated } from "$lib/stores/session.svelte";
|
import { isAuthenticated } from "$lib/stores/session.svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import {
|
import {
|
||||||
|
clearSelection,
|
||||||
isSelected,
|
isSelected,
|
||||||
selectRange,
|
selectRange,
|
||||||
selection,
|
selection,
|
||||||
@@ -570,6 +571,10 @@
|
|||||||
const destUid = rows[r]?.uids[c];
|
const destUid = rows[r]?.uids[c];
|
||||||
if (!destUid) return;
|
if (!destUid) return;
|
||||||
|
|
||||||
|
// Plain arrow nav collapses any prior multi-selection to the cursor
|
||||||
|
// (mirrors gridKeyNav.moveFocus). Shift-extend keeps `ids` growing
|
||||||
|
// from the anchor below.
|
||||||
|
if (!extending) clearSelection();
|
||||||
setFocused(destUid);
|
setFocused(destUid);
|
||||||
if (!extending) setAnchor(destUid);
|
if (!extending) setAnchor(destUid);
|
||||||
if (extending && selection.focused) selectRange(destUid);
|
if (extending && selection.focused) selectRange(destUid);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
aggregateKeywords,
|
aggregateKeywords,
|
||||||
countPhotos,
|
countPhotos,
|
||||||
getAllMarks,
|
getAllMarks,
|
||||||
|
getPhoto,
|
||||||
listLabels,
|
listLabels,
|
||||||
listPhotos,
|
listPhotos,
|
||||||
type AggregatedKeyword,
|
type AggregatedKeyword,
|
||||||
@@ -13,13 +14,16 @@
|
|||||||
type PpLabel
|
type PpLabel
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { isAuthenticated, session, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
|
import { isAuthenticated, session, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
|
||||||
import { view } from '$lib/stores/view.svelte';
|
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||||
|
import { resizable } from '$lib/actions/resizable';
|
||||||
import { selection } from '$lib/stores/selection.svelte';
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||||
|
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||||
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
|
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
|
||||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||||
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
||||||
import SplitGrid from '$lib/components/preview/SplitGrid.svelte';
|
import SplitGrid from '$lib/components/preview/SplitGrid.svelte';
|
||||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
@@ -295,6 +299,17 @@
|
|||||||
: drillPhotosQuery.data ?? []
|
: drillPhotosQuery.data ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Metadata for the focused tile in the drill-in PhotoGrid. Mirrors the
|
||||||
|
// timeline (+page.svelte) and /review wiring so the right sidebar
|
||||||
|
// reads consistently across views.
|
||||||
|
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||||
|
queryKey: ['photo', selection.focused ?? ''],
|
||||||
|
queryFn: () =>
|
||||||
|
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
|
||||||
|
enabled: isAuthenticated() && Boolean(selection.focused),
|
||||||
|
staleTime: 0
|
||||||
|
}));
|
||||||
|
|
||||||
function starLabel(rating: number): string {
|
function starLabel(rating: number): string {
|
||||||
return '★'.repeat(rating);
|
return '★'.repeat(rating);
|
||||||
}
|
}
|
||||||
@@ -319,7 +334,7 @@
|
|||||||
const drillCount = $derived<number>(drillPhotos.length);
|
const drillCount = $derived<number>(drillPhotos.length);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Toolbar>
|
<Toolbar showRightToggle={Boolean(drillKey)}>
|
||||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
Tags
|
Tags
|
||||||
</span>
|
</span>
|
||||||
@@ -392,6 +407,8 @@
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
{#if drillKey}
|
{#if drillKey}
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<SplitGrid>
|
<SplitGrid>
|
||||||
{#snippet preview()}
|
{#snippet preview()}
|
||||||
<InlinePreview uid={selection.focused} order={selection.order} />
|
<InlinePreview uid={selection.focused} order={selection.order} />
|
||||||
@@ -416,6 +433,48 @@
|
|||||||
</main>
|
</main>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SplitGrid>
|
</SplitGrid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !view.rightSidebarCollapsed}
|
||||||
|
<aside
|
||||||
|
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||||
|
style="width: {view.rightSidebarWidth}px;"
|
||||||
|
>
|
||||||
|
<div class="h-full overflow-y-auto">
|
||||||
|
{#if selection.ids.size >= 2}
|
||||||
|
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||||
|
{:else if focusedPhotoQuery.data}
|
||||||
|
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||||
|
{:else if focusedPhotoQuery.isFetching}
|
||||||
|
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2 p-4 text-center">
|
||||||
|
<div class="text-xl">ⓘ</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click
|
||||||
|
on a thumbnail to view its metadata here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||||||
|
use:resizable={{
|
||||||
|
edge: 'left',
|
||||||
|
getWidth: () => view.rightSidebarWidth,
|
||||||
|
setWidth: setRightSidebarWidth
|
||||||
|
}}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize info panel"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<main
|
<main
|
||||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||||
|
|||||||
Reference in New Issue
Block a user