mirror of
https://github.com/immich-app/immich.git
synced 2026-04-17 16:11:55 -04:00
* 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>
165 lines
3.9 KiB
TypeScript
165 lines
3.9 KiB
TypeScript
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
|
|
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
|
|
|
export type ImageQuality = 'thumbnail' | 'preview' | 'original';
|
|
|
|
export type ImageStatus = 'unloaded' | 'success' | 'error';
|
|
|
|
export type ImageLoaderStatus = {
|
|
urls: Record<ImageQuality, string | undefined>;
|
|
quality: Record<ImageQuality, ImageStatus>;
|
|
started: boolean;
|
|
hasError: boolean;
|
|
};
|
|
|
|
type ImageLoaderCallbacks = {
|
|
onUrlChange?: (url: string) => void;
|
|
onImageReady?: () => void;
|
|
onError?: () => void;
|
|
};
|
|
|
|
export type QualityConfig = {
|
|
url: string;
|
|
quality: ImageQuality;
|
|
onAfterLoad?: (loader: AdaptiveImageLoader) => void;
|
|
onAfterError?: (loader: AdaptiveImageLoader) => void;
|
|
};
|
|
|
|
export type QualityList = [
|
|
QualityConfig & { quality: 'thumbnail' },
|
|
QualityConfig & { quality: 'preview' },
|
|
QualityConfig & { quality: 'original' },
|
|
];
|
|
|
|
export class AdaptiveImageLoader {
|
|
private destroyFunctions: (() => void)[] = [];
|
|
private qualityConfigs: Record<ImageQuality, QualityConfig>;
|
|
private highestLoadedQualityIndex = -1;
|
|
private destroyed = false;
|
|
|
|
status = $state<ImageLoaderStatus>({
|
|
started: false,
|
|
hasError: false,
|
|
urls: { thumbnail: undefined, preview: undefined, original: undefined },
|
|
quality: { thumbnail: 'unloaded', preview: 'unloaded', original: 'unloaded' },
|
|
});
|
|
|
|
constructor(
|
|
private readonly qualityList: QualityList,
|
|
private readonly callbacks?: ImageLoaderCallbacks,
|
|
private readonly imageLoader?: LoadImageFunction,
|
|
) {
|
|
this.qualityConfigs = {
|
|
thumbnail: qualityList[0],
|
|
preview: qualityList[1],
|
|
original: qualityList[2],
|
|
};
|
|
this.status.urls.thumbnail = qualityList[0].url;
|
|
}
|
|
|
|
start() {
|
|
if (!this.imageLoader) {
|
|
throw new Error('Start requires imageLoader to be specified');
|
|
}
|
|
|
|
this.destroyFunctions.push(
|
|
this.imageLoader(
|
|
this.qualityList[0].url,
|
|
() => this.onLoad('thumbnail'),
|
|
() => this.onError('thumbnail'),
|
|
() => this.onStart('thumbnail'),
|
|
),
|
|
);
|
|
}
|
|
|
|
onStart(_: ImageQuality) {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
this.status.started = true;
|
|
}
|
|
|
|
onLoad(quality: ImageQuality) {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
const config = this.qualityConfigs[quality];
|
|
|
|
if (!this.status.urls[quality]) {
|
|
return;
|
|
}
|
|
|
|
const index = this.qualityList.indexOf(config);
|
|
if (index <= this.highestLoadedQualityIndex) {
|
|
return;
|
|
}
|
|
|
|
this.highestLoadedQualityIndex = index;
|
|
this.status.quality[quality] = 'success';
|
|
this.callbacks?.onUrlChange?.(this.qualityConfigs[quality].url);
|
|
this.callbacks?.onImageReady?.();
|
|
|
|
config.onAfterLoad?.(this);
|
|
}
|
|
|
|
onError(quality: ImageQuality) {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
const config = this.qualityConfigs[quality];
|
|
|
|
this.status.hasError = true;
|
|
this.status.quality[quality] = 'error';
|
|
this.status.urls[quality] = undefined;
|
|
this.callbacks?.onError?.();
|
|
|
|
config.onAfterError?.(this);
|
|
}
|
|
|
|
trigger(quality: ImageQuality) {
|
|
if (this.destroyed) {
|
|
return false;
|
|
}
|
|
|
|
const url = this.qualityConfigs[quality].url;
|
|
if (!url) {
|
|
this.qualityConfigs[quality].onAfterError?.(this);
|
|
return false;
|
|
}
|
|
|
|
if (this.status.urls[quality]) {
|
|
return true;
|
|
}
|
|
|
|
this.status.hasError = false;
|
|
this.status.urls[quality] = url;
|
|
if (this.imageLoader) {
|
|
this.destroyFunctions.push(
|
|
this.imageLoader(
|
|
url,
|
|
() => this.onLoad(quality),
|
|
() => this.onError(quality),
|
|
() => this.onStart(quality),
|
|
),
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
destroy() {
|
|
this.destroyed = true;
|
|
if (this.imageLoader) {
|
|
for (const destroy of this.destroyFunctions) {
|
|
destroy();
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (const config of Object.values(this.qualityConfigs)) {
|
|
cancelImageUrl(config.url);
|
|
}
|
|
}
|
|
}
|