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
This commit is contained in:
midzelis
2026-03-26 17:21:58 +00:00
parent 2015f95ff5
commit d93ef7e0fc
7 changed files with 61 additions and 31 deletions
@@ -33,6 +33,7 @@
thumbnailSize?: number; thumbnailSize?: number;
thumbnailWidth?: number; thumbnailWidth?: number;
thumbnailHeight?: number; thumbnailHeight?: number;
isInViewport?: boolean;
selected?: boolean; selected?: boolean;
selectionCandidate?: boolean; selectionCandidate?: boolean;
disabled?: boolean; disabled?: boolean;
@@ -56,6 +57,7 @@
thumbnailSize = undefined, thumbnailSize = undefined,
thumbnailWidth = undefined, thumbnailWidth = undefined,
thumbnailHeight = undefined, thumbnailHeight = undefined,
isInViewport = true,
selected = false, selected = false,
selectionCandidate = false, selectionCandidate = false,
disabled = false, disabled = false,
@@ -78,6 +80,7 @@
let mouseOver = $state(false); let mouseOver = $state(false);
let loaded = $state(false); let loaded = $state(false);
let thumbError = $state(false); let thumbError = $state(false);
let skipFade = $state(false);
let width = $derived(thumbnailSize || thumbnailWidth || 235); let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235);
@@ -264,7 +267,11 @@
widthStyle="{width}px" widthStyle="{width}px"
heightStyle="{height}px" heightStyle="{height}px"
curve={selected} curve={selected}
onComplete={(errored) => ((loaded = true), (thumbError = errored))} onComplete={(errored) => {
skipFade = !isInViewport;
loaded = true;
thumbError = errored;
}}
/> />
{#if asset.isVideo} {#if asset.isVideo}
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg"> <div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
@@ -309,7 +316,10 @@
<Thumbhash <Thumbhash
base64ThumbHash={asset.thumbhash} base64ThumbHash={asset.thumbhash}
data-testid="thumbhash" data-testid="thumbhash"
class={['absolute top-0 object-cover group-focus-visible:rounded-lg', { 'rounded-xl': selected }]} class={[
'absolute top-0 object-cover group-focus-visible:rounded-lg',
{ 'rounded-xl': selected, hidden: skipFade },
]}
style="width: {width}px; height: {height}px" style="width: {width}px; height: {height}px"
draggable="false" draggable="false"
fadeOut fadeOut
@@ -22,11 +22,16 @@
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util'; import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk'; import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
type Props = { type Props = {
assets: AssetResponseDto[]; assets: AssetResponseDto[];
viewerAssets?: AssetResponseDto[]; viewerAssets?: AssetResponseDto[];
@@ -34,7 +39,7 @@
disableAssetSelect?: boolean; disableAssetSelect?: boolean;
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
viewport: Viewport; viewport: Viewport;
onIntersected?: (() => void) | undefined; onEndReached?: (() => void) | undefined;
showAssetName?: boolean; showAssetName?: boolean;
onReload?: (() => void) | undefined; onReload?: (() => void) | undefined;
pageHeaderOffset?: number; pageHeaderOffset?: number;
@@ -50,7 +55,7 @@
disableAssetSelect = false, disableAssetSelect = false,
showArchiveIcon = false, showArchiveIcon = false,
viewport, viewport,
onIntersected = undefined, onEndReached = undefined,
showAssetName = false, showAssetName = false,
onReload = undefined, onReload = undefined,
slidingWindowOffset = 0, slidingWindowOffset = 0,
@@ -70,24 +75,31 @@
}), }),
); );
const getStyle = (i: number) => { const getStyle = (index: number) => {
const geo = geometry; return `top: ${geometry.getTop(index)}px; left: ${geometry.getLeft(index)}px; width: ${geometry.getWidth(index)}px; height: ${geometry.getHeight(index)}px;`;
return `top: ${geo.getTop(i)}px; left: ${geo.getLeft(i)}px; width: ${geo.getWidth(i)}px; height: ${geo.getHeight(i)}px;`;
}; };
const isIntersecting = (i: number) => { const isInOrNearViewport = (index: number) => {
const geo = geometry;
const window = slidingWindow; const window = slidingWindow;
const top = geo.getTop(i); const top = geometry.getTop(index);
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top; 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 shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null); let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let scrollTop = $state(0); let scrollTop = $state(0);
let slidingWindow = $derived.by(() => { let slidingWindow = $derived.by(() => {
const top = (scrollTop || 0) - slidingWindowOffset; const top = (scrollTop || 0) - slidingWindowOffset - INTERSECTION_EXPAND_TOP;
const bottom = top + viewport.height + slidingWindowOffset; const bottom = top + viewport.height + slidingWindowOffset + INTERSECTION_EXPAND_BOTTOM;
return { return {
top, top,
bottom, bottom,
@@ -101,17 +113,15 @@
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0); 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(() => { $effect(() => {
// Intersect if there's only one viewport worth of assets left to scroll.
if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) { if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) {
// Notify we got to (near) the end of scroll. const contentHeight = geometry.containerHeight;
const intersectedHeight = geometry.containerHeight; if (lastEndReachedHeight !== contentHeight) {
if (lastIntersectedHeight !== intersectedHeight) { debouncedOnEndReached();
debouncedOnIntersected(); lastEndReachedHeight = contentHeight;
lastIntersectedHeight = intersectedHeight;
} }
} }
}); });
@@ -362,10 +372,10 @@
style:height={geometry.containerHeight + 'px'} style:height={geometry.containerHeight + 'px'}
style:width={geometry.containerWidth + 'px'} style:width={geometry.containerWidth + 'px'}
> >
{#each assets as asset, i (asset.id + '-' + i)} {#each assets as asset, index (asset.id + '-' + index)}
{#if isIntersecting(i)} {#if isInOrNearViewport(index)}
{@const currentAsset = toTimelineAsset(asset)} {@const currentAsset = toTimelineAsset(asset)}
<div class="absolute" style:overflow="clip" style={getStyle(i)}> <div class="absolute" style:overflow="clip" style={getStyle(index)}>
<Thumbnail <Thumbnail
readonly={disableAssetSelect} readonly={disableAssetSelect}
onClick={() => { onClick={() => {
@@ -382,8 +392,9 @@
asset={currentAsset} asset={currentAsset}
selected={assetInteraction.hasSelectedAsset(currentAsset.id)} selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)} selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
thumbnailWidth={geometry.getWidth(i)} isInViewport={isInViewport(index)}
thumbnailHeight={geometry.getHeight(i)} thumbnailWidth={geometry.getWidth(index)}
thumbnailHeight={geometry.getHeight(index)}
/> />
{#if showAssetName && !isTimelineAsset(asset)} {#if showAssetName && !isTimelineAsset(asset)}
<div <div
@@ -21,6 +21,7 @@
{ {
asset: TimelineAsset; asset: TimelineAsset;
position: CommonPosition; position: CommonPosition;
isInViewport: boolean;
}, },
] ]
>; >;
@@ -38,6 +39,7 @@
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)} {#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!} {@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!} {@const asset = viewerAsset.asset!}
{@const isInViewport = viewerAsset.isInViewport!}
<!-- note: don't remove data-asset-id - its used by web e2e tests --> <!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div <div
@@ -50,7 +52,7 @@
out:scale|global={{ start: 0.1, duration: scaleDuration }} out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }} animate:flip={{ duration: transitionDuration }}
> >
{@render thumbnail({ asset, position })} {@render thumbnail({ asset, position, isInViewport })}
{@render customThumbnailLayout?.(asset)} {@render customThumbnailLayout?.(asset)}
</div> </div>
{/each} {/each}
+3 -2
View File
@@ -21,6 +21,7 @@
position: CommonPosition; position: CommonPosition;
timelineDay: TimelineDay; timelineDay: TimelineDay;
groupIndex: number; groupIndex: number;
isInViewport: boolean;
}, },
] ]
>; >;
@@ -105,8 +106,8 @@
width={timelineDay.width} width={timelineDay.width}
{customThumbnailLayout} {customThumbnailLayout}
> >
{#snippet thumbnail({ asset, position })} {#snippet thumbnail({ asset, position, isInViewport })}
{@render thumbnailWithGroup({ asset, position, timelineDay, groupIndex })} {@render thumbnailWithGroup({ asset, position, timelineDay, groupIndex, isInViewport })}
{/snippet} {/snippet}
</AssetLayout> </AssetLayout>
</section> </section>
@@ -673,7 +673,7 @@
manager={timelineManager} manager={timelineManager}
onTimelineDaySelect={handleGroupSelect} onTimelineDaySelect={handleGroupSelect}
> >
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })} {#snippet thumbnail({ asset, position, timelineDay, groupIndex, isInViewport })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)} {@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected = {@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)} assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
@@ -684,6 +684,7 @@
{asset} {asset}
{albumUsers} {albumUsers}
{groupIndex} {groupIndex}
{isInViewport}
onClick={(asset) => { onClick={(asset) => {
if (typeof onThumbnailClick === 'function') { if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, _onClick); onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
@@ -3,6 +3,7 @@ import {
ViewportProximity, ViewportProximity,
calculateViewerAssetViewportProximity, calculateViewerAssetViewportProximity,
isInOrNearViewport, isInOrNearViewport,
isInViewport,
} from './internal/intersection-support.svelte'; } from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte'; import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
@@ -25,6 +26,10 @@ export class ViewerAsset {
return isInOrNearViewport(this.#viewportProximity); return isInOrNearViewport(this.#viewportProximity);
} }
get isInViewport() {
return isInViewport(this.#viewportProximity);
}
position: CommonPosition | undefined = $state.raw(); position: CommonPosition | undefined = $state.raw();
asset: TimelineAsset = $state() as TimelineAsset; asset: TimelineAsset = $state() as TimelineAsset;
id: string = $derived(this.asset.id); id: string = $derived(this.asset.id);
@@ -292,7 +292,7 @@
<GalleryViewer <GalleryViewer
assets={searchResultAssets} assets={searchResultAssets}
assetInteraction={assetMultiSelectManager} assetInteraction={assetMultiSelectManager}
onIntersected={loadNextPage} onEndReached={loadNextPage}
showArchiveIcon={true} showArchiveIcon={true}
{viewport} {viewport}
onReload={onSearchQueryUpdate} onReload={onSearchQueryUpdate}