preview: LQIP + ±2 prefetch + bound action bar to its column

Preview pane paints instantly: a blurred copy of the same thumbnail the
grid loaded (cache hit) rides beneath the sharp fit_1280, which now
carries fetchpriority=high and decoding=async. A $effect prefetches
fit_1280 for the ±2 neighbours so arrow-skim hits the HTTP cache.
Carousel thumbs drop to fetchpriority=low so they yield to the main
image. Skeleton grid gains an mt-2 to breathe against the toolbar.
BulkActionBar moves inside the main column in both PreviewModal and the
/tags drill-in so it no longer stretches under the right sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 10:24:31 +02:00
parent ea1803ec2f
commit 0f4e2e0b8f
5 changed files with 88 additions and 25 deletions

View File

@@ -155,6 +155,7 @@
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
fetchpriority="low"
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{/if} {/if}

View File

@@ -133,7 +133,11 @@
Full-screen preview of the focused photo with metadata and a thumbnail carousel. Full-screen preview of the focused photo with metadata and a thumbnail carousel.
</Dialog.Description> </Dialog.Description>
<!-- Top row: preview pane (fills) + sidebar (fixed width). --> <!-- Top row: preview pane (fills) + sidebar (fixed width).
BulkActionBar lives inside the main column — same shape as the
timeline (+page.svelte) so the bar stays bounded by the
column's width and doesn't stretch under the metadata
sidebar. -->
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<div class="relative flex min-w-0 flex-1 flex-col"> <div class="relative flex min-w-0 flex-1 flex-col">
<button <button
@@ -148,6 +152,8 @@
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<PreviewPane uid={focusedUid} order={selection.order} /> <PreviewPane uid={focusedUid} order={selection.order} />
</div> </div>
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
<BulkActionBar />
</div> </div>
{#if focusedPhotoQuery.data} {#if focusedPhotoQuery.data}
<aside <aside
@@ -158,9 +164,6 @@
{/if} {/if}
</div> </div>
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
<BulkActionBar />
<!-- Bottom filmstrip across selection.order. --> <!-- Bottom filmstrip across selection.order. -->
<PreviewCarousel /> <PreviewCarousel />
</Dialog.Content> </Dialog.Content>

View File

@@ -9,10 +9,11 @@
throw away. Until the timer fires, the poster image stands in. throw away. Until the timer fires, the poster image stands in.
--> -->
<script lang="ts"> <script lang="ts">
import { createQuery } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism'; import { getPhoto } from '$lib/services/photoprism';
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte'; import { thumbSrc, thumbSrcSet, thumbUrl, videoUrl } from '$lib/stores/session.svelte';
import { setAnchor, setFocused } from '$lib/stores/selection.svelte'; import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
import { view } from '$lib/stores/view.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte'; import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism'; import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
@@ -27,6 +28,8 @@
} }
let { uid, order, showChevrons = true }: Props = $props(); let { uid, order, showChevrons = true }: Props = $props();
const qc = useQueryClient();
const photoQuery = createQuery<PpPhoto>(() => ({ const photoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', uid ?? ''], queryKey: ['photo', uid ?? ''],
queryFn: () => getPhoto(uid as string), queryFn: () => getPhoto(uid as string),
@@ -35,6 +38,48 @@
const currentIndex = $derived(uid ? order.indexOf(uid) : -1); const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
/** Mirrors PreviewCarousel.lookup — pulls a PpPhoto out of TanStack's
* cache without firing a fetch, so we can resolve adjacent hashes for
* prefetching without making the prefetch itself trigger more work. */
function lookupCached(target: string): PpPhoto | null {
const direct = qc.getQueryData<PpPhoto>(['photo', target]);
if (direct) return direct;
const lists = qc.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) {
if (!data) continue;
if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === target);
if (hit) return hit;
continue;
}
const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue;
for (const page of pages) {
const hit = page?.find?.((p) => p.UID === target);
if (hit) return hit;
}
}
return null;
}
// Prefetch fit_1280 for the ±2 neighbours of the focused photo so
// arrow-skim feels instant. We `new Image()` rather than `<link
// rel=preload>` because the URLs are runtime-derived and a throwaway
// Image() reuses the browser's HTTP cache the same way.
$effect(() => {
if (!uid || currentIndex < 0) return;
for (const offset of [-1, 1, -2, 2]) {
const idx = currentIndex + offset;
if (idx < 0 || idx >= order.length) continue;
const photo = lookupCached(order[idx]);
if (!photo) continue;
const hash = primaryFile(photo).Hash;
if (!hash) continue;
const img = new Image();
img.src = thumbUrl(hash, 'fit_1280');
}
});
const VIDEO_LOAD_DELAY_MS = 250; const VIDEO_LOAD_DELAY_MS = 250;
let armedUid = $state<string | null>(null); let armedUid = $state<string | null>(null);
@@ -86,9 +131,8 @@
</button> </button>
{/if} {/if}
{#if isVideo(photoQuery.data)} {#if isVideo(photoQuery.data) && armedUid === uid}
{@const vf = videoFile(photoQuery.data)} {@const vf = videoFile(photoQuery.data)}
{#if armedUid === uid}
{#key vf.Hash} {#key vf.Hash}
<VideoPlayer <VideoPlayer
src={videoUrl(vf.Hash)} src={videoUrl(vf.Hash)}
@@ -97,17 +141,33 @@
/> />
{/key} {/key}
{:else} {:else}
{@const altText =
photoQuery.data.OriginalName ??
pf.Name ??
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
the tile_*'s square center-crop against the sharp image's
true aspect. Sized via aspect-ratio + max-* + m-auto so it
lands in the exact same bounding box as the sharp <img>
beside it (object-contain semantics, but expressible on a
positioned element). Paints from the HTTP cache the moment
the modal opens. -->
<img <img
src={thumbUrl(pf.Hash, 'fit_1280')} src={thumbSrc(pf.Hash, view.thumbnailSize)}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'} srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
class="max-h-full max-w-full rounded-md object-contain shadow-2xl" alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
style="aspect-ratio: {pf.Width} / {pf.Height};"
/> />
{/if} {/if}
{:else}
<img <img
src={thumbUrl(pf.Hash, 'fit_1280')} src={thumbUrl(pf.Hash, 'fit_1280')}
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'} alt={altText}
class="max-h-full max-w-full rounded-md object-contain shadow-2xl" fetchpriority="high"
decoding="async"
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
/> />
{/if} {/if}
{/if} {/if}

View File

@@ -22,7 +22,7 @@
<div <div
aria-hidden="true" aria-hidden="true"
class="grid gap-2" class="mt-2 grid gap-2"
style="grid-template-columns: {tracks};" style="grid-template-columns: {tracks};"
> >
{#each Array.from({ length: count }) as _, i (i)} {#each Array.from({ length: count }) as _, i (i)}

View File

@@ -226,6 +226,7 @@
<PhotoGrid photos={drillPhotos} /> <PhotoGrid photos={drillPhotos} />
{/if} {/if}
</main> </main>
<BulkActionBar />
</div> </div>
{#if !view.rightSidebarCollapsed} {#if !view.rightSidebarCollapsed}
@@ -270,5 +271,3 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<BulkActionBar />