From d93ef7e0fc4c60b949452ab78eb1a4ed50ed302d Mon Sep 17 00:00:00 2001 From: midzelis Date: Thu, 26 Mar 2026 17:21:58 +0000 Subject: [PATCH] fix(web): align gallery-viewer viewport zones and skip thumbhash fade for offscreen thumbnails When thumbnails load outside the viewport (e.g. during fast scrolling), skip the thumbhash-to-image fade transition to avoid visual flicker for already-loaded images. Expands the gallery-viewer sliding window to use the same INTERSECTION_EXPAND_TOP/BOTTOM tunables as the timeline, enabling consistent near-viewport preloading of assets across both views. Adds isInViewport prop to Thumbnail, threaded through AssetLayout, Month, and Timeline. ViewerAsset gains an isInViewport getter backed by ViewportProximity. Also aligns gallery-viewer naming with ViewportProximity terminology: - isRenderable -> isInOrNearViewport - isIntersecting -> isInViewport - onIntersected -> onReachedEnd Change-Id: Iad30716dc2a45f4883701bbbd1412e106a6a6964 --- .../assets/thumbnail/Thumbnail.svelte | 14 ++++- .../gallery-viewer/GalleryViewer.svelte | 59 +++++++++++-------- .../components/timeline/AssetLayout.svelte | 4 +- web/src/lib/components/timeline/Month.svelte | 5 +- .../lib/components/timeline/Timeline.svelte | 3 +- .../timeline-manager/viewer-asset.svelte.ts | 5 ++ .../[[assetId=id]]/+page.svelte | 2 +- 7 files changed, 61 insertions(+), 31 deletions(-) diff --git a/web/src/lib/components/assets/thumbnail/Thumbnail.svelte b/web/src/lib/components/assets/thumbnail/Thumbnail.svelte index a087aaf809..9559f25fc1 100644 --- a/web/src/lib/components/assets/thumbnail/Thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/Thumbnail.svelte @@ -33,6 +33,7 @@ thumbnailSize?: number; thumbnailWidth?: number; thumbnailHeight?: number; + isInViewport?: boolean; selected?: boolean; selectionCandidate?: boolean; disabled?: boolean; @@ -56,6 +57,7 @@ thumbnailSize = undefined, thumbnailWidth = undefined, thumbnailHeight = undefined, + isInViewport = true, selected = false, selectionCandidate = false, disabled = false, @@ -78,6 +80,7 @@ let mouseOver = $state(false); let loaded = $state(false); let thumbError = $state(false); + let skipFade = $state(false); let width = $derived(thumbnailSize || thumbnailWidth || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235); @@ -264,7 +267,11 @@ widthStyle="{width}px" heightStyle="{height}px" curve={selected} - onComplete={(errored) => ((loaded = true), (thumbError = errored))} + onComplete={(errored) => { + skipFade = !isInViewport; + loaded = true; + thumbError = errored; + }} /> {#if asset.isVideo}
@@ -309,7 +316,10 @@ void) | undefined; + onEndReached?: (() => void) | undefined; showAssetName?: boolean; onReload?: (() => void) | undefined; pageHeaderOffset?: number; @@ -50,7 +55,7 @@ disableAssetSelect = false, showArchiveIcon = false, viewport, - onIntersected = undefined, + onEndReached = undefined, showAssetName = false, onReload = undefined, slidingWindowOffset = 0, @@ -70,24 +75,31 @@ }), ); - const getStyle = (i: number) => { - const geo = geometry; - return `top: ${geo.getTop(i)}px; left: ${geo.getLeft(i)}px; width: ${geo.getWidth(i)}px; height: ${geo.getHeight(i)}px;`; + const getStyle = (index: number) => { + return `top: ${geometry.getTop(index)}px; left: ${geometry.getLeft(index)}px; width: ${geometry.getWidth(index)}px; height: ${geometry.getHeight(index)}px;`; }; - const isIntersecting = (i: number) => { - const geo = geometry; + const isInOrNearViewport = (index: number) => { const window = slidingWindow; - const top = geo.getTop(i); - return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top; + const top = geometry.getTop(index); + return top + pageHeaderOffset < window.bottom && top + geometry.getHeight(index) > window.top; + }; + + const isInViewport = (index: number) => { + const top = geometry.getTop(index) + pageHeaderOffset; + const bottom = top + geometry.getHeight(index); + const viewportTop = (scrollTop || 0) - slidingWindowOffset; + const viewportBottom = viewportTop + viewport.height + slidingWindowOffset; + return top < viewportBottom && bottom > viewportTop; }; let shiftKeyIsDown = $state(false); let lastAssetMouseEvent: TimelineAsset | null = $state(null); let scrollTop = $state(0); + let slidingWindow = $derived.by(() => { - const top = (scrollTop || 0) - slidingWindowOffset; - const bottom = top + viewport.height + slidingWindowOffset; + const top = (scrollTop || 0) - slidingWindowOffset - INTERSECTION_EXPAND_TOP; + const bottom = top + viewport.height + slidingWindowOffset + INTERSECTION_EXPAND_BOTTOM; return { top, bottom, @@ -101,17 +113,15 @@ const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0); - const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true }); + const debouncedOnEndReached = debounce(() => onEndReached?.(), 750, { maxWait: 100, leading: true }); - let lastIntersectedHeight = 0; + let lastEndReachedHeight = 0; $effect(() => { - // Intersect if there's only one viewport worth of assets left to scroll. if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) { - // Notify we got to (near) the end of scroll. - const intersectedHeight = geometry.containerHeight; - if (lastIntersectedHeight !== intersectedHeight) { - debouncedOnIntersected(); - lastIntersectedHeight = intersectedHeight; + const contentHeight = geometry.containerHeight; + if (lastEndReachedHeight !== contentHeight) { + debouncedOnEndReached(); + lastEndReachedHeight = contentHeight; } } }); @@ -362,10 +372,10 @@ style:height={geometry.containerHeight + 'px'} style:width={geometry.containerWidth + 'px'} > - {#each assets as asset, i (asset.id + '-' + i)} - {#if isIntersecting(i)} + {#each assets as asset, index (asset.id + '-' + index)} + {#if isInOrNearViewport(index)} {@const currentAsset = toTimelineAsset(asset)} -
+
{ @@ -382,8 +392,9 @@ asset={currentAsset} selected={assetInteraction.hasSelectedAsset(currentAsset.id)} selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)} - thumbnailWidth={geometry.getWidth(i)} - thumbnailHeight={geometry.getHeight(i)} + isInViewport={isInViewport(index)} + thumbnailWidth={geometry.getWidth(index)} + thumbnailHeight={geometry.getHeight(index)} /> {#if showAssetName && !isTimelineAsset(asset)}
; @@ -38,6 +39,7 @@ {#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)} {@const position = viewerAsset.position!} {@const asset = viewerAsset.asset!} + {@const isInViewport = viewerAsset.isInViewport!}
- {@render thumbnail({ asset, position })} + {@render thumbnail({ asset, position, isInViewport })} {@render customThumbnailLayout?.(asset)}
{/each} diff --git a/web/src/lib/components/timeline/Month.svelte b/web/src/lib/components/timeline/Month.svelte index 3f4b9b0661..f6ca89ebfd 100644 --- a/web/src/lib/components/timeline/Month.svelte +++ b/web/src/lib/components/timeline/Month.svelte @@ -21,6 +21,7 @@ position: CommonPosition; timelineDay: TimelineDay; groupIndex: number; + isInViewport: boolean; }, ] >; @@ -105,8 +106,8 @@ width={timelineDay.width} {customThumbnailLayout} > - {#snippet thumbnail({ asset, position })} - {@render thumbnailWithGroup({ asset, position, timelineDay, groupIndex })} + {#snippet thumbnail({ asset, position, isInViewport })} + {@render thumbnailWithGroup({ asset, position, timelineDay, groupIndex, isInViewport })} {/snippet} diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 646feee7b9..d4a1a647bb 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -673,7 +673,7 @@ manager={timelineManager} onTimelineDaySelect={handleGroupSelect} > - {#snippet thumbnail({ asset, position, timelineDay, groupIndex })} + {#snippet thumbnail({ asset, position, timelineDay, groupIndex, isInViewport })} {@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)} {@const isAssetSelected = assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)} @@ -684,6 +684,7 @@ {asset} {albumUsers} {groupIndex} + {isInViewport} onClick={(asset) => { if (typeof onThumbnailClick === 'function') { onThumbnailClick(asset, timelineManager, timelineDay, _onClick); diff --git a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts index 0179dd9e74..a2bab899e0 100644 --- a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts +++ b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts @@ -3,6 +3,7 @@ import { ViewportProximity, calculateViewerAssetViewportProximity, isInOrNearViewport, + isInViewport, } from './internal/intersection-support.svelte'; import type { TimelineDay } from './timeline-day.svelte'; import type { TimelineAsset } from './types'; @@ -25,6 +26,10 @@ export class ViewerAsset { return isInOrNearViewport(this.#viewportProximity); } + get isInViewport() { + return isInViewport(this.#viewportProximity); + } + position: CommonPosition | undefined = $state.raw(); asset: TimelineAsset = $state() as TimelineAsset; id: string = $derived(this.asset.id); diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 488d699368..f6d0b60bda 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -292,7 +292,7 @@