diff --git a/components/album/histogram-chart.tsx b/components/album/histogram-chart.tsx index 8f0bb80a..da048a40 100644 --- a/components/album/histogram-chart.tsx +++ b/components/album/histogram-chart.tsx @@ -11,6 +11,14 @@ interface CompressedHistogramData { luminance: number[] } +// Memoize computed histograms per image url for the session. The detail view now +// switches photos in place, so without this every revisit re-fetches + re-decodes +// + re-scans pixels. Keyed by the exact url (so a different variant tier / photo +// invalidates correctly). Bounded LRU so a long browsing session can't grow it +// without limit (Map insertion order = LRU; re-set on hit keeps hot entries). +const HISTOGRAM_CACHE_LIMIT = 256 +const histogramCache = new Map() + interface HistogramData { red: number[] green: number[] @@ -199,10 +207,21 @@ export default function HistogramChart({ imageUrl, className = '' }: Readonly { try { @@ -218,9 +237,10 @@ export default function HistogramChart({ imageUrl, className = '' }: Readonly, so this still gets a + // CORS-readable response — without re-fetching the preview on every switch). + img.src = imageUrl img.onload = () => { const canvas = document.createElement('canvas') @@ -244,6 +264,10 @@ export default function HistogramChart({ imageUrl, className = '' }: Readonly HISTOGRAM_CACHE_LIMIT) { + histogramCache.delete(histogramCache.keys().next().value as string) + } setHistogram(calculatedHistogram) } catch (e) { console.error('Error calculating histogram:', e) diff --git a/components/album/preview-image.tsx b/components/album/preview-image.tsx index ab6d52e7..624521cd 100644 --- a/components/album/preview-image.tsx +++ b/components/album/preview-image.tsx @@ -103,8 +103,14 @@ function ThumbBlur({ blurhash }: { blurhash: string }) { function ThumbnailStrip({ photos, activeIndex, onSelect }: { photos: ImageType[]; activeIndex: number; onSelect: (i: number) => void }) { const ref = useRef(null) useEffect(() => { - const el = ref.current?.querySelector('[data-active="true"]') - el?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' }) + // Defer the scroll past the commit so reading layout doesn't force a + // synchronous reflow on every switch; 'auto' (instant) keeps the active + // thumbnail centered without a per-switch smooth-scroll animation. + const raf = requestAnimationFrame(() => { + ref.current?.querySelector('[data-active="true"]') + ?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'auto' }) + }) + return () => cancelAnimationFrame(raf) }, [activeIndex]) return (
@@ -122,6 +128,7 @@ function ThumbnailStrip({ photos, activeIndex, onSelect }: { photos: ImageType[] aria-current={active} aria-label={`View photo ${i + 1}`} onClick={() => onSelect(i)} + style={{ contentVisibility: 'auto', containIntrinsicSize: '48px 48px' } as React.CSSProperties} className={cn( 'relative size-12 shrink-0 overflow-hidden rounded-md transition-all', active ? 'opacity-100 ring-2 ring-primary' : 'opacity-50 hover:opacity-90', @@ -286,6 +293,18 @@ export default function PreviewImage(props: Readonly) { // Image URL for tone analysis and histogram const imageUrl = current?.preview_url || current?.url || '' + // Debounce the histogram/tone source: those run an image-load + getImageData + + // full-pixel scan, wasteful to fire for every photo flashed past during fast + // switching. Initialised to the opened photo (so it shows immediately on open, + // no pop-in), then only updated once switching settles for a short idle — so a + // fast swipe through 10 photos analyses just the one you stop on. + const [deferredImageUrl, setDeferredImageUrl] = useState(imageUrl) + useEffect(() => { + if (typeof window === 'undefined') return + const id = window.setTimeout(() => setDeferredImageUrl(imageUrl), 220) + return () => window.clearTimeout(id) + }, [imageUrl]) + const navigateAway = () => { if (window != undefined) { if (window.history.length > 1) { @@ -425,7 +444,14 @@ export default function PreviewImage(props: Readonly) { /> ) ) : ( - + // Only decode the real blurhash for slides near the current one; + // farther off-screen slides pass '' (→ one shared cached default + // decode) so opening a large album doesn't decode ~20 thumbhashes. + )}
) @@ -631,18 +657,18 @@ export default function PreviewImage(props: Readonly) { )} {/* Tone Analysis */} - {imageUrl && ( + {deferredImageUrl && (
{t('Exif.toneAnalysis')} - +
)} {/* Histogram */} - {imageUrl && ( + {deferredImageUrl && (
{t('Exif.histogram')} - +
)} diff --git a/components/album/tone-analysis.tsx b/components/album/tone-analysis.tsx index b3f5d7b8..b5d73eb4 100644 --- a/components/album/tone-analysis.tsx +++ b/components/album/tone-analysis.tsx @@ -95,6 +95,12 @@ interface ToneAnalysisProps { className?: string } +// Memoize tone analysis per image url for the session — the in-place detail +// switch would otherwise re-fetch + re-decode + re-scan on every revisit. +// Bounded LRU (Map insertion order = LRU; re-set on hit keeps hot entries). +const TONE_CACHE_LIMIT = 256 +const toneCache = new Map() + export default function ToneAnalysis({ imageUrl, className = '' }: Readonly) { const [toneData, setToneData] = useState(null) const [loading, setLoading] = useState(true) @@ -109,10 +115,20 @@ export default function ToneAnalysis({ imageUrl, className = '' }: Readonly { try { @@ -128,9 +144,8 @@ export default function ToneAnalysis({ imageUrl, className = '' }: Readonly { const canvas = document.createElement('canvas') @@ -154,6 +169,10 @@ export default function ToneAnalysis({ imageUrl, className = '' }: Readonly TONE_CACHE_LIMIT) { + toneCache.delete(toneCache.keys().next().value as string) + } setToneData(analysis) } catch (e) { console.error('Error analyzing tone:', e)