From 6daa5c8f435ec0fdf7cc389eb043ae4aaf0cf6d6 Mon Sep 17 00:00:00 2001 From: midzelis Date: Thu, 15 Jan 2026 20:34:21 +0000 Subject: [PATCH] feat: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte preload thumbs and previews --- e2e/src/web/specs/photo-viewer.e2e-spec.ts | 24 +- web/src/lib/actions/image-loader.svelte.ts | 196 ++++++++++++++ .../asset-viewer/adaptive-image.svelte | 246 +++++++++++++++++ .../asset-viewer/asset-viewer.svelte | 248 +++++++++++------- .../asset-viewer/detail-panel.svelte | 49 +++- .../asset-viewer/photo-viewer.svelte | 173 +++--------- .../assets/thumbnail/image-thumbnail.svelte | 45 ++-- web/src/lib/stores/assets-store.svelte.ts | 2 +- web/src/lib/utils.ts | 5 +- .../lib/utils/adaptive-image-loader.svelte.ts | 202 ++++++++++++++ web/src/lib/utils/layout-utils.ts | 16 ++ 11 files changed, 923 insertions(+), 283 deletions(-) create mode 100644 web/src/lib/actions/image-loader.svelte.ts create mode 100644 web/src/lib/components/asset-viewer/adaptive-image.svelte create mode 100644 web/src/lib/utils/adaptive-image-loader.svelte.ts diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index 3f9bb4237a..0918309596 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -1,10 +1,7 @@ import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; -import { Page, expect, test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import { utils } from 'src/utils'; -function imageLocator(page: Page) { - return page.getByAltText('Image taken').locator('visible=true'); -} test.describe('Photo Viewer', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; @@ -26,31 +23,32 @@ test.describe('Photo Viewer', () => { test('loads original photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const box = await imageLocator(page).boundingBox(); + await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/); + const box = await page.getByTestId('thumbnail').boundingBox(); expect(box).toBeTruthy(); const { x, y, width, height } = box!; await page.mouse.move(x + width / 2, y + height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original'); + await expect(page.getByTestId('original')).toBeInViewport(); + await expect(page.getByTestId('original')).toHaveAttribute('src', /original/); }); test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => { await page.goto(`/photos/${rawAsset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const box = await imageLocator(page).boundingBox(); + await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/); + const box = await page.getByTestId('thumbnail').boundingBox(); expect(box).toBeTruthy(); const { x, y, width, height } = box!; await page.mouse.move(x + width / 2, y + height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize'); + await expect(page.getByTestId('original')).toHaveAttribute('src', /fullsize/); }); test('reloads photo when checksum changes', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const initialSrc = await imageLocator(page).getAttribute('src'); + await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/); + const initialSrc = await page.getByTestId('thumbnail').getAttribute('src'); await utils.replaceAsset(admin.accessToken, asset.id); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc); + await expect(page.getByTestId('preview')).not.toHaveAttribute('src', initialSrc!); }); }); diff --git a/web/src/lib/actions/image-loader.svelte.ts b/web/src/lib/actions/image-loader.svelte.ts new file mode 100644 index 0000000000..ef01fcda26 --- /dev/null +++ b/web/src/lib/actions/image-loader.svelte.ts @@ -0,0 +1,196 @@ +import { cancelImageUrl } from '$lib/utils/sw-messaging'; +import type { ClassValue } from 'svelte/elements'; + +/** + * Converts a ClassValue to a string suitable for className assignment. + * Handles strings, arrays, and objects similar to how clsx works. + */ +function classValueToString(value: ClassValue | undefined): string { + if (!value) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + return value + .map((v) => classValueToString(v)) + .filter(Boolean) + .join(' '); + } + // Object/dictionary case + return Object.entries(value) + .filter(([, v]) => v) + .map(([k]) => k) + .join(' '); +} + +export interface ImageLoaderProperties { + imgClass?: ClassValue; + alt?: string; + draggable?: boolean; + role?: string; + style?: string; + title?: string | null; + loading?: 'lazy' | 'eager'; + dataAttributes?: Record; +} +export interface ImageSourceProperty { + src: string | undefined; +} +export interface ImageLoaderCallbacks { + onStart?: () => void; + onLoad?: () => void; + onError?: (error: Error) => void; + onElementCreated?: (element: HTMLImageElement) => void; +} + +const updateImageAttributes = (img: HTMLImageElement, params: ImageLoaderProperties) => { + if (params.alt !== undefined) { + img.alt = params.alt; + } + if (params.draggable !== undefined) { + img.draggable = params.draggable; + } + if (params.imgClass) { + img.className = classValueToString(params.imgClass); + } + if (params.role) { + img.role = params.role; + } + if (params.style !== undefined) { + img.setAttribute('style', params.style); + } + if (params.title !== undefined && params.title !== null) { + img.title = params.title; + } + if (params.loading !== undefined) { + img.loading = params.loading; + } + if (params.dataAttributes) { + for (const [key, value] of Object.entries(params.dataAttributes)) { + img.setAttribute(key, value); + } + } +}; + +const cleanupImageElement = ( + imgElement: HTMLImageElement, + currentSrc: string | undefined, + handleLoad: () => void, + handleError: () => void, +) => { + cancelImageUrl(currentSrc); + if (imgElement) { + imgElement.removeEventListener('load', handleLoad); + imgElement.removeEventListener('error', handleError); + imgElement.remove(); + } +}; + +const createImageElement = ( + src: string | undefined, + properties: ImageLoaderProperties, + onLoad: () => void, + onError: () => void, + onStart?: () => void, + onElementCreated?: (imgElement: HTMLImageElement) => void, +) => { + if (!src) { + return undefined; + } + const img = document.createElement('img'); + updateImageAttributes(img, properties); + + img.addEventListener('load', onLoad); + img.addEventListener('error', onError); + + onStart?.(); + + if (src) { + img.src = src; + onElementCreated?.(img); + } + + return img; +}; + +export function loadImage( + src: string, + properties: ImageLoaderProperties, + onLoad: () => void, + onError: () => void, + onStart?: () => void, +) { + const img = createImageElement(src, properties, onLoad, onError, onStart); + if (!img) { + return () => void 0; + } + return () => cleanupImageElement(img, src, onLoad, onError); +} + +export type LoadImageFunction = typeof loadImage; + +/** + * 1. Creates and appends an element to the parent + * 2. Coordinates with service worker before src triggers fetch + * 3. Adds load/error listeners + * 4. Cancels SW request when element is removed from DOM + */ +export function imageLoader( + node: HTMLElement, + params: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks, +) { + let currentSrc = params.src; + let currentCallbacks = params; + let imgElement: HTMLImageElement | undefined = undefined; + + const handleLoad = () => { + currentCallbacks.onLoad?.(); + }; + + const handleError = () => { + currentCallbacks.onError?.(new Error(`Failed to load image: ${currentSrc}`)); + }; + + const handleElementCreated = (img: HTMLImageElement) => { + if (img) { + node.append(img); + currentCallbacks.onElementCreated?.(img); + } + }; + + const createImage = () => { + imgElement = createImageElement(currentSrc, params, handleLoad, handleError, params.onStart, handleElementCreated); + }; + createImage(); + + return { + update(newParams: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks) { + // If src changed, recreate the image element + if (newParams.src !== currentSrc) { + cleanupImageElement(imgElement!, currentSrc, handleLoad, handleError); + + currentSrc = newParams.src; + currentCallbacks = newParams; + + createImage(); + return; + } + + currentCallbacks = newParams; + + if (!imgElement) { + return; + } + + updateImageAttributes(imgElement, newParams); + }, + + destroy() { + if (imgElement) { + cleanupImageElement(imgElement, currentSrc, handleLoad, handleError); + } + }, + }; +} diff --git a/web/src/lib/components/asset-viewer/adaptive-image.svelte b/web/src/lib/components/asset-viewer/adaptive-image.svelte new file mode 100644 index 0000000000..1f798b3430 --- /dev/null +++ b/web/src/lib/components/asset-viewer/adaptive-image.svelte @@ -0,0 +1,246 @@ + + +
+ {#if asset.thumbhash} + +
+ +
+ {:else if showSpinner} +
+ +
+ {/if} + +
adaptiveImageLoader.onThumbnailStart(), + onLoad: () => adaptiveImageLoader.onThumbnailLoad(), + onError: () => adaptiveImageLoader.onThumbnailError(), + onElementCreated: (el) => (thumbnailElement = el), + imgClass: ['absolute h-full', 'w-full'], + alt: '', + role: 'presentation', + dataAttributes: { + 'data-testid': 'thumbnail', + }, + }} + >
+ + {#if showBrokenAsset} +
+ +
+ {:else} + + {#if thumbnailUrl && slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground} + + {/if} + +
+
adaptiveImageLoader.onPreviewStart(), + onLoad: () => adaptiveImageLoader.onPreviewLoad(), + onError: () => adaptiveImageLoader.onPreviewError(), + onElementCreated: (el) => (previewElement = el), + imgClass: ['h-full', 'w-full', { imageClass }], + alt: imageAltText, + draggable: false, + dataAttributes: { + 'data-testid': 'preview', + }, + }} + >
+ + {@render overlays?.()} +
+ +
+
adaptiveImageLoader.onOriginalStart(), + onLoad: () => adaptiveImageLoader.onOriginalLoad(), + onError: () => adaptiveImageLoader.onOriginalError(), + onElementCreated: (el) => (originalElement = el), + imgClass: ['h-full', 'w-full', { imageClass }], + alt: imageAltText, + draggable: false, + dataAttributes: { + 'data-testid': 'original', + }, + }} + >
+ + {@render overlays?.()} +
+ + +
+ +
+ {/if} +
+ + diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 56bc5dd615..65fbde216e 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,6 +1,7 @@ @@ -193,49 +126,28 @@ { shortcut: { key: 's' }, onShortcut: onPlaySlideshow, preventDefault: true }, { shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false }, { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, - { shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false }, ]} /> -{#if imageError} -
- -
-{/if} - +
- {#if !imageLoaded} -
- -
- {:else if !imageError} -
- {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} - - {/if} - {$getAltText(toTimelineAsset(asset))} + onReady?.()} + onError={() => onReady?.()} + bind:imgElement={$photoViewerImgElement} + > + {#snippet overlays()} {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
{/each} -
+ {/snippet} +
- {#if isFaceEditMode.value} - - {/if} + {#if isFaceEditMode.value} + {/if}
- - diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index a1dd22f44f..18bfd11058 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,9 +1,8 @@ {#if errored} {:else} - {loaded +
{/if} {#if hidden} diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index cef5d98e3b..c7e18ddd8f 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -1,4 +1,4 @@ import { writable } from 'svelte/store'; -export const photoViewerImgElement = writable(null); +export const photoViewerImgElement = writable(); export const isSelectingAllAssets = writable(false); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index ee14da2b48..de477b922d 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -226,13 +226,10 @@ export const getAssetUrl = ({ sharedLink, forceOriginal = false, }: { - asset: AssetResponseDto | undefined; + asset: AssetResponseDto; sharedLink?: SharedLinkResponseDto; forceOriginal?: boolean; }) => { - if (!asset) { - return; - } const id = asset.id; const cacheKey = asset.thumbhash; if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { diff --git a/web/src/lib/utils/adaptive-image-loader.svelte.ts b/web/src/lib/utils/adaptive-image-loader.svelte.ts new file mode 100644 index 0000000000..a9f2bb2e9b --- /dev/null +++ b/web/src/lib/utils/adaptive-image-loader.svelte.ts @@ -0,0 +1,202 @@ +import type { LoadImageFunction } from '$lib/actions/image-loader.svelte'; +import { imageManager } from '$lib/managers/ImageManager.svelte'; +import { getAssetUrl, getAssetUrlForKind } from '$lib/utils'; +import { type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; + +/** + * Quality levels for progressive image loading + */ +type ImageQuality = + | 'basic' + | 'loading-thumbnail' + | 'thumbnail' + | 'loading-preview' + | 'preview' + | 'loading-original' + | 'original'; + +export interface ImageLoaderState { + previewUrl?: string; + thumbnailUrl?: string; + originalUrl?: string; + quality: ImageQuality; + hasError: boolean; + thumbnailImage: ImageStatus; + previewImage: ImageStatus; + originalImage: ImageStatus; +} +enum ImageStatus { + Unloaded = 'Unloaded', + Success = 'Success', + Error = 'Error', +} + +/** + * Coordinates adaptive loading of a single asset image: + * thumbhash → thumbnail → preview → original (on zoom) + * + */ +export class AdaptiveImageLoader { + private state = $state({ + quality: 'basic', + hasError: false, + thumbnailImage: ImageStatus.Unloaded, + previewImage: ImageStatus.Unloaded, + originalImage: ImageStatus.Unloaded, + }); + + private readonly currentZoomFn?: () => number; + private readonly onImageReady?: () => void; + private readonly onError?: () => void; + private readonly imageLoader?: LoadImageFunction; + private readonly destroyFunctions: (() => void)[] = []; + readonly thumbnailUrl: string; + readonly previewUrl: string; + readonly originalUrl: string; + asset: AssetResponseDto; + constructor( + asset: AssetResponseDto, + sharedLink: SharedLinkResponseDto | undefined, + callbacks?: { + currentZoomFn: () => number; + onImageReady?: () => void; + onError?: () => void; + }, + imageLoader?: LoadImageFunction, + ) { + this.asset = asset; + this.currentZoomFn = callbacks?.currentZoomFn; + this.onImageReady = callbacks?.onImageReady; + this.onError = callbacks?.onError; + this.imageLoader = imageLoader; + + this.thumbnailUrl = getAssetUrlForKind(asset, 'thumbnail'); + this.previewUrl = getAssetUrl({ asset, sharedLink }); + this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true }); + this.state.thumbnailUrl = this.thumbnailUrl; + } + + start() { + if (!this.imageLoader) { + throw new Error('Start requires imageLoader to be specified'); + } + this.destroyFunctions.push( + this.imageLoader( + this.thumbnailUrl, + {}, + () => this.onThumbnailLoad(), + () => this.onThumbnailError(), + () => this.onThumbnailStart(), + ), + ); + } + + get adaptiveLoaderState(): ImageLoaderState { + return this.state; + } + + onThumbnailStart() { + this.state.quality = 'loading-thumbnail'; + } + + onThumbnailLoad() { + this.state.quality = 'thumbnail'; + this.state.thumbnailImage = ImageStatus.Success; + this.onImageReady?.(); + this.triggerMainImage(); + } + + onThumbnailError() { + this.state.thumbnailUrl = undefined; + this.state.thumbnailImage = ImageStatus.Error; + this.triggerMainImage(); + } + + triggerMainImage() { + const wantsOriginal = (this.currentZoomFn?.() ?? 1) > 1; + return wantsOriginal ? this.triggerOriginal() : this.triggerPreview(); + } + + triggerPreview() { + if (!this.previewUrl) { + // no preview, try original? + this.triggerOriginal(); + return false; + } + this.state.previewUrl = this.previewUrl; + if (this.imageLoader) { + this.destroyFunctions.push( + this.imageLoader( + this.previewUrl, + {}, + () => this.onPreviewLoad(), + () => this.onPreviewError(), + () => this.onPreviewStart(), + ), + ); + } + } + + onPreviewStart() { + this.state.quality = 'loading-preview'; + } + + onPreviewLoad() { + this.state.quality = 'preview'; + this.state.previewImage = ImageStatus.Success; + this.onImageReady?.(); + } + + onPreviewError() { + this.state.previewImage = ImageStatus.Error; + this.state.previewUrl = undefined; + // TODO: maybe try original, but only if preview's error isnt due to cancelation + } + + triggerOriginal() { + if (!this.originalUrl) { + this.onError?.(); + return false; + } + this.state.originalUrl = this.originalUrl; + + if (this.imageLoader) { + this.destroyFunctions.push( + this.imageLoader( + this.originalUrl, + {}, + () => this.onOriginalLoad(), + () => this.onOriginalError(), + () => this.onOriginalStart(), + ), + ); + } + } + + onOriginalStart() { + this.state.quality = 'loading-original'; + } + + onOriginalLoad() { + this.state.quality = 'original'; + this.state.originalImage = ImageStatus.Success; + this.onImageReady?.(); + } + + onOriginalError() { + this.state.originalImage = ImageStatus.Error; + this.state.originalUrl = undefined; + } + + destroy(): void { + if (this.imageLoader) { + for (const destroy of this.destroyFunctions) { + destroy(); + } + return; + } + imageManager.cancelPreloadUrl(this.thumbnailUrl); + imageManager.cancelPreloadUrl(this.previewUrl); + imageManager.cancelPreloadUrl(this.originalUrl); + } +} diff --git a/web/src/lib/utils/layout-utils.ts b/web/src/lib/utils/layout-utils.ts index 3533851ddf..1864933a0b 100644 --- a/web/src/lib/utils/layout-utils.ts +++ b/web/src/lib/utils/layout-utils.ts @@ -129,3 +129,19 @@ export type CommonPosition = { width: number; height: number; }; + +// Scales dimensions to fit within a container (like object-fit: contain) +export const scaleToFit = ( + dimensions: { width: number; height: number }, + container: { width: number; height: number }, +) => { + const scaleX = container.width / dimensions.width; + const scaleY = container.height / dimensions.height; + + const scale = Math.min(scaleX, scaleY); + + return { + width: dimensions.width * scale, + height: dimensions.height * scale, + }; +};