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:
@@ -155,6 +155,7 @@
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -133,7 +133,11 @@
|
||||
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
|
||||
</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="relative flex min-w-0 flex-1 flex-col">
|
||||
<button
|
||||
@@ -148,6 +152,8 @@
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<PreviewPane uid={focusedUid} order={selection.order} />
|
||||
</div>
|
||||
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
{#if focusedPhotoQuery.data}
|
||||
<aside
|
||||
@@ -158,9 +164,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
||||
<BulkActionBar />
|
||||
|
||||
<!-- Bottom filmstrip across selection.order. -->
|
||||
<PreviewCarousel />
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
throw away. Until the timer fires, the poster image stands in.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
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 { view } from '$lib/stores/view.svelte';
|
||||
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
||||
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
@@ -27,6 +28,8 @@
|
||||
}
|
||||
let { uid, order, showChevrons = true }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', uid ?? ''],
|
||||
queryFn: () => getPhoto(uid as string),
|
||||
@@ -35,6 +38,48 @@
|
||||
|
||||
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;
|
||||
let armedUid = $state<string | null>(null);
|
||||
|
||||
@@ -86,28 +131,43 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isVideo(photoQuery.data)}
|
||||
{#if isVideo(photoQuery.data) && armedUid === uid}
|
||||
{@const vf = videoFile(photoQuery.data)}
|
||||
{#if armedUid === uid}
|
||||
{#key vf.Hash}
|
||||
<VideoPlayer
|
||||
src={videoUrl(vf.Hash)}
|
||||
poster={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
{#key vf.Hash}
|
||||
<VideoPlayer
|
||||
src={videoUrl(vf.Hash)}
|
||||
poster={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||
/>
|
||||
{/key}
|
||||
{: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
|
||||
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"
|
||||
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||||
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||||
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}
|
||||
{:else}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
|
||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
alt={altText}
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="grid gap-2"
|
||||
class="mt-2 grid gap-2"
|
||||
style="grid-template-columns: {tracks};"
|
||||
>
|
||||
{#each Array.from({ length: count }) as _, i (i)}
|
||||
|
||||
@@ -226,6 +226,7 @@
|
||||
<PhotoGrid photos={drillPhotos} />
|
||||
{/if}
|
||||
</main>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed}
|
||||
@@ -270,5 +271,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
Reference in New Issue
Block a user