mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
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>
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
|
||||
vi.mock('$lib/utils/sw-messaging', () => ({
|
||||
cancelImageUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
function createQualityList(overrides?: {
|
||||
onAfterLoad?: Record<string, (loader: AdaptiveImageLoader) => void>;
|
||||
onAfterError?: Record<string, (loader: AdaptiveImageLoader) => void>;
|
||||
}): QualityList {
|
||||
return [
|
||||
{
|
||||
quality: 'thumbnail',
|
||||
url: '/thumbnail.jpg',
|
||||
onAfterLoad: overrides?.onAfterLoad?.thumbnail,
|
||||
onAfterError: overrides?.onAfterError?.thumbnail,
|
||||
},
|
||||
{
|
||||
quality: 'preview',
|
||||
url: '/preview.jpg',
|
||||
onAfterLoad: overrides?.onAfterLoad?.preview,
|
||||
onAfterError: overrides?.onAfterError?.preview,
|
||||
},
|
||||
{
|
||||
quality: 'original',
|
||||
url: '/original.jpg',
|
||||
onAfterLoad: overrides?.onAfterLoad?.original,
|
||||
onAfterError: overrides?.onAfterError?.original,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('AdaptiveImageLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('initializes with thumbnail URL set', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
expect(loader.status.urls.thumbnail).toBe('/thumbnail.jpg');
|
||||
expect(loader.status.urls.preview).toBeUndefined();
|
||||
expect(loader.status.urls.original).toBeUndefined();
|
||||
});
|
||||
|
||||
it('initializes all qualities as unloaded', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
expect(loader.status.quality.thumbnail).toBe('unloaded');
|
||||
expect(loader.status.quality.preview).toBe('unloaded');
|
||||
expect(loader.status.quality.original).toBe('unloaded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onStart', () => {
|
||||
it('sets started to true', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
expect(loader.status.started).toBe(false);
|
||||
loader.onStart('thumbnail');
|
||||
expect(loader.status.started).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op after destroy', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
loader.destroy();
|
||||
loader.onStart('thumbnail');
|
||||
expect(loader.status.started).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLoad', () => {
|
||||
it('sets quality to success and calls callbacks', () => {
|
||||
const onUrlChange = vi.fn();
|
||||
const onImageReady = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onUrlChange, onImageReady });
|
||||
|
||||
loader.onLoad('thumbnail');
|
||||
|
||||
expect(loader.status.quality.thumbnail).toBe('success');
|
||||
expect(onUrlChange).toHaveBeenCalledWith('/thumbnail.jpg');
|
||||
expect(onImageReady).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onAfterLoad callback', () => {
|
||||
const onAfterLoad = vi.fn();
|
||||
const qualityList = createQualityList({ onAfterLoad: { thumbnail: onAfterLoad } });
|
||||
const loader = new AdaptiveImageLoader(qualityList);
|
||||
|
||||
loader.onLoad('thumbnail');
|
||||
|
||||
expect(onAfterLoad).toHaveBeenCalledWith(loader);
|
||||
});
|
||||
|
||||
it('ignores load if URL is not set', () => {
|
||||
const onImageReady = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onImageReady });
|
||||
|
||||
loader.onLoad('preview');
|
||||
|
||||
expect(loader.status.quality.preview).toBe('unloaded');
|
||||
expect(onImageReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores load if a higher quality is already loaded', () => {
|
||||
const onUrlChange = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onUrlChange });
|
||||
|
||||
loader.onLoad('thumbnail');
|
||||
loader.trigger('preview');
|
||||
loader.onLoad('preview');
|
||||
|
||||
onUrlChange.mockClear();
|
||||
loader.onLoad('thumbnail');
|
||||
|
||||
expect(onUrlChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op after destroy', () => {
|
||||
const onImageReady = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onImageReady });
|
||||
|
||||
loader.destroy();
|
||||
loader.onLoad('thumbnail');
|
||||
|
||||
expect(onImageReady).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onError', () => {
|
||||
it('sets quality to error and clears URL', () => {
|
||||
const onError = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onError });
|
||||
|
||||
loader.onError('thumbnail');
|
||||
|
||||
expect(loader.status.quality.thumbnail).toBe('error');
|
||||
expect(loader.status.urls.thumbnail).toBeUndefined();
|
||||
expect(loader.status.hasError).toBe(true);
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onAfterError callback', () => {
|
||||
const onAfterError = vi.fn();
|
||||
const qualityList = createQualityList({ onAfterError: { thumbnail: onAfterError } });
|
||||
const loader = new AdaptiveImageLoader(qualityList);
|
||||
|
||||
loader.onError('thumbnail');
|
||||
|
||||
expect(onAfterError).toHaveBeenCalledWith(loader);
|
||||
});
|
||||
|
||||
it('is a no-op after destroy', () => {
|
||||
const onError = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onError });
|
||||
|
||||
loader.destroy();
|
||||
loader.onError('thumbnail');
|
||||
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger', () => {
|
||||
it('sets the URL for the quality', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
loader.trigger('preview');
|
||||
|
||||
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
||||
});
|
||||
|
||||
it('returns true if URL is already set', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
expect(loader.trigger('thumbnail')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when triggering a new quality', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
expect(loader.trigger('preview')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears hasError when triggering', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
loader.onError('thumbnail');
|
||||
expect(loader.status.hasError).toBe(true);
|
||||
|
||||
loader.trigger('preview');
|
||||
expect(loader.status.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('calls imageLoader when provided', () => {
|
||||
const imageLoader = vi.fn(() => vi.fn());
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
||||
|
||||
loader.trigger('preview');
|
||||
|
||||
expect(imageLoader).toHaveBeenCalledWith(
|
||||
'/preview.jpg',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false after destroy', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
loader.destroy();
|
||||
|
||||
expect(loader.trigger('preview')).toBe(false);
|
||||
});
|
||||
|
||||
it('calls onAfterError if URL is empty', () => {
|
||||
const onAfterError = vi.fn();
|
||||
const qualityList = createQualityList({ onAfterError: { preview: onAfterError } });
|
||||
(qualityList[1] as { url: string }).url = '';
|
||||
const loader = new AdaptiveImageLoader(qualityList);
|
||||
|
||||
expect(loader.trigger('preview')).toBe(false);
|
||||
expect(onAfterError).toHaveBeenCalledWith(loader);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('throws if no imageLoader is provided', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
expect(() => loader.start()).toThrow('Start requires imageLoader to be specified');
|
||||
});
|
||||
|
||||
it('calls imageLoader with thumbnail URL', () => {
|
||||
const imageLoader = vi.fn(() => vi.fn());
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
||||
|
||||
loader.start();
|
||||
|
||||
expect(imageLoader).toHaveBeenCalledWith(
|
||||
'/thumbnail.jpg',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('cancels all image URLs when no imageLoader', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
loader.destroy();
|
||||
|
||||
expect(cancelImageUrl).toHaveBeenCalledWith('/thumbnail.jpg');
|
||||
expect(cancelImageUrl).toHaveBeenCalledWith('/preview.jpg');
|
||||
expect(cancelImageUrl).toHaveBeenCalledWith('/original.jpg');
|
||||
});
|
||||
|
||||
it('calls destroy functions when imageLoader is provided', () => {
|
||||
const destroyFn = vi.fn();
|
||||
const imageLoader = vi.fn(() => destroyFn);
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
||||
|
||||
loader.start();
|
||||
loader.destroy();
|
||||
|
||||
expect(destroyFn).toHaveBeenCalledOnce();
|
||||
expect(cancelImageUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('progressive loading flow', () => {
|
||||
it('thumbnail load triggers preview via onAfterLoad', () => {
|
||||
const triggerSpy = vi.fn();
|
||||
const qualityList = createQualityList({
|
||||
onAfterLoad: {
|
||||
thumbnail: (loader) => {
|
||||
triggerSpy();
|
||||
loader.trigger('preview');
|
||||
},
|
||||
},
|
||||
});
|
||||
const loader = new AdaptiveImageLoader(qualityList);
|
||||
|
||||
loader.onLoad('thumbnail');
|
||||
|
||||
expect(triggerSpy).toHaveBeenCalledOnce();
|
||||
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
||||
});
|
||||
|
||||
it('thumbnail error triggers preview via onAfterError', () => {
|
||||
const qualityList = createQualityList({
|
||||
onAfterError: {
|
||||
thumbnail: (loader) => loader.trigger('preview'),
|
||||
},
|
||||
});
|
||||
const loader = new AdaptiveImageLoader(qualityList);
|
||||
|
||||
loader.onError('thumbnail');
|
||||
|
||||
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,8 @@ const supportedImageMimeTypes = new Set([
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export const isFirefox = typeof navigator !== 'undefined' && navigator.userAgent.includes('Firefox');
|
||||
|
||||
async function addSupportedMimeTypes(): Promise<void> {
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
||||
if (isSafari) {
|
||||
|
||||
@@ -5,6 +5,19 @@ export interface ContentMetrics {
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
export const scaleToCover = (
|
||||
dimensions: { width: number; height: number },
|
||||
container: { width: number; height: number },
|
||||
): { width: number; height: number } => {
|
||||
const scaleX = container.width / dimensions.width;
|
||||
const scaleY = container.height / dimensions.height;
|
||||
const scale = Math.max(scaleX, scaleY);
|
||||
return {
|
||||
width: dimensions.width * scale,
|
||||
height: dimensions.height * scale,
|
||||
};
|
||||
};
|
||||
|
||||
export const scaleToFit = (
|
||||
dimensions: { width: number; height: number },
|
||||
container: { width: number; height: number },
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { scaleToFit } from '$lib/utils/container-utils';
|
||||
|
||||
describe('scaleToFit', () => {
|
||||
const tests = [
|
||||
{
|
||||
name: 'landscape image in square container',
|
||||
dimensions: { width: 2000, height: 1000 },
|
||||
container: { width: 500, height: 500 },
|
||||
expected: { width: 500, height: 250 },
|
||||
},
|
||||
{
|
||||
name: 'portrait image in square container',
|
||||
dimensions: { width: 1000, height: 2000 },
|
||||
container: { width: 500, height: 500 },
|
||||
expected: { width: 250, height: 500 },
|
||||
},
|
||||
{
|
||||
name: 'square image in square container',
|
||||
dimensions: { width: 1000, height: 1000 },
|
||||
container: { width: 500, height: 500 },
|
||||
expected: { width: 500, height: 500 },
|
||||
},
|
||||
{
|
||||
name: 'landscape image in landscape container',
|
||||
dimensions: { width: 1600, height: 900 },
|
||||
container: { width: 800, height: 600 },
|
||||
expected: { width: 800, height: 450 },
|
||||
},
|
||||
{
|
||||
name: 'portrait image in portrait container',
|
||||
dimensions: { width: 900, height: 1600 },
|
||||
container: { width: 600, height: 800 },
|
||||
expected: { width: 450, height: 800 },
|
||||
},
|
||||
{
|
||||
name: 'image matches container exactly',
|
||||
dimensions: { width: 500, height: 300 },
|
||||
container: { width: 500, height: 300 },
|
||||
expected: { width: 500, height: 300 },
|
||||
},
|
||||
{
|
||||
name: 'image smaller than container scales up',
|
||||
dimensions: { width: 100, height: 50 },
|
||||
container: { width: 400, height: 400 },
|
||||
expected: { width: 400, height: 200 },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, dimensions, container, expected } of tests) {
|
||||
it(`should handle ${name}`, () => {
|
||||
expect(scaleToFit(dimensions, container)).toEqual(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user