mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
fix(web): viewport reactivity, off-screen thumbhashes being rendered (#15435)
* viewport optimizations * fade in * async bitmap * fast path for smaller date groups --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
ac36effb45
commit
52f21fb331
8 changed files with 183 additions and 52 deletions
|
|
@ -39,6 +39,7 @@
|
|||
thumbnailSize?: number | undefined;
|
||||
thumbnailWidth?: number | undefined;
|
||||
thumbnailHeight?: number | undefined;
|
||||
eagerThumbhash?: boolean;
|
||||
selected?: boolean;
|
||||
selectionCandidate?: boolean;
|
||||
disabled?: boolean;
|
||||
|
|
@ -71,6 +72,7 @@
|
|||
thumbnailSize = undefined,
|
||||
thumbnailWidth = undefined,
|
||||
thumbnailHeight = undefined,
|
||||
eagerThumbhash = true,
|
||||
selected = false,
|
||||
selectionCandidate = false,
|
||||
disabled = false,
|
||||
|
|
@ -113,7 +115,6 @@
|
|||
|
||||
let width = $derived(thumbnailSize || thumbnailWidth || 235);
|
||||
let height = $derived(thumbnailSize || thumbnailHeight || 235);
|
||||
let display = $derived(intersecting);
|
||||
|
||||
const onIconClickedHandler = (e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
|
|
@ -207,7 +208,11 @@
|
|||
? 'bg-gray-300'
|
||||
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
|
||||
>
|
||||
{#if !loaded && asset.thumbhash}
|
||||
<!-- TODO: Rendering thumbhashes for offscreen assets is a ton of overhead.
|
||||
This is here to ensure thumbhashes appear on the first
|
||||
frame instead of a gray box for smaller date groups,
|
||||
where the overhead (while wasteful) does not cause major issues. -->
|
||||
{#if eagerThumbhash && !loaded && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
class="absolute object-cover z-10"
|
||||
|
|
@ -217,7 +222,17 @@
|
|||
></canvas>
|
||||
{/if}
|
||||
|
||||
{#if display}
|
||||
{#if intersecting}
|
||||
{#if !eagerThumbhash && !loaded && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
class="absolute object-cover z-10"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
{/if}
|
||||
|
||||
<!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates
|
||||
the navigation url on scroll. Replace this with button for now. -->
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
$: dateGroups = bucket.dateGroups;
|
||||
|
||||
const {
|
||||
DATEGROUP: { INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM },
|
||||
DATEGROUP: { INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM, SMALL_GROUP_THRESHOLD },
|
||||
} = TUNABLES;
|
||||
/* TODO figure out a way to calculate this*/
|
||||
const TITLE_HEIGHT = 51;
|
||||
|
|
@ -179,6 +179,7 @@
|
|||
>
|
||||
{#each dateGroup.assets as asset, index (asset.id)}
|
||||
{@const box = dateGroup.geometry.boxes[index]}
|
||||
{@const isSmallGroup = dateGroup.assets.length <= SMALL_GROUP_THRESHOLD}
|
||||
<!-- update ASSET_GRID_PADDING-->
|
||||
<div
|
||||
use:intersectionObserver={{
|
||||
|
|
@ -217,6 +218,7 @@
|
|||
disabled={$assetStore.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={box.width}
|
||||
thumbnailHeight={box.height}
|
||||
eagerThumbhash={isSmallGroup}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
} from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { debounce, throttle } from 'lodash-es';
|
||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
||||
|
|
@ -81,8 +81,9 @@
|
|||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
|
||||
|
||||
const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
|
||||
const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
|
||||
// this does *not* need to be reactive and making it reactive causes expensive repeated updates
|
||||
// svelte-ignore non_reactive_update
|
||||
let safeViewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
|
||||
|
||||
const componentId = generateId();
|
||||
let element: HTMLElement | undefined = $state();
|
||||
|
|
@ -103,7 +104,7 @@
|
|||
let leadout = $state(false);
|
||||
|
||||
const {
|
||||
ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW },
|
||||
ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW, LARGE_BUCKET_THRESHOLD, LARGE_BUCKET_DEBOUNCE_MS },
|
||||
BUCKET: {
|
||||
INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP,
|
||||
INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM,
|
||||
|
|
@ -114,14 +115,6 @@
|
|||
},
|
||||
} = TUNABLES;
|
||||
|
||||
const isViewportOrigin = () => {
|
||||
return viewport.height === 0 && viewport.width === 0;
|
||||
};
|
||||
|
||||
const isEqual = (a: ViewportXY, b: ViewportXY) => {
|
||||
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
|
||||
};
|
||||
|
||||
const completeNav = () => {
|
||||
navigating = false;
|
||||
if (internalScroll) {
|
||||
|
|
@ -235,6 +228,14 @@
|
|||
};
|
||||
|
||||
onMount(() => {
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
safeViewport.height = rect.height;
|
||||
safeViewport.width = rect.width;
|
||||
safeViewport.x = rect.x;
|
||||
safeViewport.y = rect.y;
|
||||
}
|
||||
|
||||
void $assetStore
|
||||
.init({ bucketListener })
|
||||
.then(() => ($assetStore.connect(), $assetStore.updateViewport(safeViewport)));
|
||||
|
|
@ -259,8 +260,6 @@
|
|||
}
|
||||
return offset;
|
||||
}
|
||||
const _updateViewport = () => void $assetStore.updateViewport(safeViewport);
|
||||
const updateViewport = throttle(_updateViewport, 16);
|
||||
|
||||
const getMaxScrollPercent = () =>
|
||||
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
|
||||
|
|
@ -744,23 +743,8 @@
|
|||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (element && isViewportOrigin()) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
viewport.height = rect.height;
|
||||
viewport.width = rect.width;
|
||||
viewport.x = rect.x;
|
||||
viewport.y = rect.y;
|
||||
}
|
||||
if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) {
|
||||
safeViewport.height = viewport.height;
|
||||
safeViewport.width = viewport.width;
|
||||
safeViewport.x = viewport.x;
|
||||
safeViewport.y = viewport.y;
|
||||
updateViewport();
|
||||
}
|
||||
});
|
||||
|
||||
let largeBucketMode = false;
|
||||
let updateViewport = debounce(() => $assetStore.updateViewport(safeViewport), 8);
|
||||
let shortcutList = $derived(
|
||||
(() => {
|
||||
if ($isSearchEnabled || $showAssetViewer) {
|
||||
|
|
@ -843,7 +827,21 @@
|
|||
id="asset-grid"
|
||||
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
|
||||
tabindex="-1"
|
||||
use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
|
||||
use:resizeObserver={({ width, height }) => {
|
||||
if (!largeBucketMode && assetStore.maxBucketAssets >= LARGE_BUCKET_THRESHOLD) {
|
||||
largeBucketMode = true;
|
||||
// Each viewport update causes each asset to re-decode both the thumbhash and the thumbnail.
|
||||
// This is because the thumbnail components are destroyed and re-mounted, possibly because of the intersection observer.
|
||||
// For larger buckets, this can lead to freezing and a poor user experience.
|
||||
// As a mitigation, we aggressively debounce the viewport update to reduce the number of these events.
|
||||
updateViewport = debounce(() => $assetStore.updateViewport(safeViewport), LARGE_BUCKET_DEBOUNCE_MS, {
|
||||
leading: false,
|
||||
trailing: true,
|
||||
});
|
||||
}
|
||||
safeViewport = { width, height, x: safeViewport.x, y: safeViewport.y };
|
||||
void updateViewport();
|
||||
}}
|
||||
bind:this={element}
|
||||
onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue