Files
immich/web/src/lib/components/Image.svelte
Min Idzelis 8764a1894b feat: adaptive progressive image loading for photo viewer (#26636)
* feat(web): adaptive progressive image loading for photo viewer

Replace ImageManager with a new AdaptiveImageLoader that progressively
loads images through quality tiers (thumbnail → preview → original).

New components and utilities:
- AdaptiveImage: layered image renderer with thumbhash, thumbnail,
  preview, and original layers with visibility managed by load state
- AdaptiveImageLoader: state machine driving the quality progression
  with per-quality callbacks and error handling
- ImageLayer/Image: low-level image elements with load/error lifecycle
- PreloadManager: preloads adjacent assets for instant navigation
- AlphaBackground/DelayedLoadingSpinner: loading state UI

Zoom is handled via a derived CSS transform applied to the content
wrapper in AdaptiveImage, with the zoom library (zoomTarget: null)
only tracking state without manipulating the DOM directly.

Also adds scaleToCover to container-utils and getAssetUrls to utils.

* fix: don't partially render images in firefox

* add passive loading indicator to asset-viewer

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-11 09:48:46 -05:00

78 lines
1.7 KiB
Svelte

<script lang="ts">
import { isFirefox } from '$lib/utils/asset-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { onDestroy, untrack } from 'svelte';
import type { HTMLImgAttributes } from 'svelte/elements';
type Props = Omit<HTMLImgAttributes, 'onload' | 'onerror'> & {
src: string | undefined;
onStart?: () => void;
onLoad?: () => void;
onError?: (error: Error) => void;
ref?: HTMLImageElement;
};
let { src, onStart, onLoad, onError, ref = $bindable(), ...rest }: Props = $props();
let capturedSource: string | undefined = $state();
let loaded = $state(false);
let destroyed = false;
$effect(() => {
if (src !== undefined && capturedSource === undefined) {
capturedSource = src;
untrack(() => {
onStart?.();
});
}
});
onDestroy(() => {
destroyed = true;
if (capturedSource !== undefined) {
cancelImageUrl(capturedSource);
}
});
const completeLoad = () => {
if (destroyed) {
return;
}
loaded = true;
onLoad?.();
};
const handleLoad = () => {
if (destroyed || !src) {
return;
}
if (isFirefox && ref) {
ref.decode().then(completeLoad, completeLoad);
return;
}
completeLoad();
};
const handleError = () => {
if (destroyed || !src) {
return;
}
onError?.(new Error(`Failed to load image: ${src}`));
};
</script>
{#if capturedSource}
{#key capturedSource}
<img
bind:this={ref}
src={capturedSource}
{...rest}
style:visibility={isFirefox && !loaded ? 'hidden' : undefined}
onload={handleLoad}
onerror={handleError}
/>
{/key}
{/if}