|
|
|
|
@@ -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}
|
|
|
|
|
|