From 872a6ae993384c0a75121ae386027666344b9575 Mon Sep 17 00:00:00 2001 From: midzelis Date: Thu, 15 Jan 2026 20:34:21 +0000 Subject: [PATCH] feat: adaptive progressive image loading for photo viewer --- e2e/src/specs/web/photo-viewer.e2e-spec.ts | 36 ++- e2e/src/ui/mock-network/timeline-network.ts | 4 +- .../asset-viewer/broken-asset.e2e-spec.ts | 2 +- .../specs/search/search-gallery.e2e-spec.ts | 3 +- e2e/src/ui/specs/timeline/utils.ts | 10 +- web/src/lib/actions/image-loader.svelte.ts | 25 ++ web/src/lib/actions/zoom-image.ts | 13 +- web/src/lib/components/AdaptiveImage.svelte | 238 ++++++++++++++ web/src/lib/components/AlphaBackground.svelte | 11 + .../asset-viewer/asset-viewer.svelte | 289 +++++++++++------ .../face-editor/face-editor.svelte | 37 ++- .../asset-viewer/ocr-bounding-box.svelte | 2 +- .../asset-viewer/photo-viewer.svelte | 250 +++++---------- web/src/lib/managers/ImageManager.svelte.ts | 39 ++- .../lib/utils/adaptive-image-loader.svelte.ts | 300 ++++++++++++++++++ web/src/lib/utils/layout-utils.spec.ts | 54 ++++ 16 files changed, 1004 insertions(+), 309 deletions(-) create mode 100644 web/src/lib/actions/image-loader.svelte.ts create mode 100644 web/src/lib/components/AdaptiveImage.svelte create mode 100644 web/src/lib/components/AlphaBackground.svelte create mode 100644 web/src/lib/utils/adaptive-image-loader.svelte.ts create mode 100644 web/src/lib/utils/layout-utils.spec.ts diff --git a/e2e/src/specs/web/photo-viewer.e2e-spec.ts b/e2e/src/specs/web/photo-viewer.e2e-spec.ts index 3f9bb4237a..9c2c96be78 100644 --- a/e2e/src/specs/web/photo-viewer.e2e-spec.ts +++ b/e2e/src/specs/web/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,44 @@ 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(); + + const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); + const original = page.getByTestId('original').filter({ visible: true }); + + await expect(thumbnail).toHaveAttribute('src', /thumbnail/); + const box = await 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(original).toBeInViewport(); + await expect(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(); + + const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); + const original = page.getByTestId('original').filter({ visible: true }); + + await expect(thumbnail).toHaveAttribute('src', /thumbnail/); + const box = await 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(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'); + + const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); + const preview = page.getByTestId('preview').filter({ visible: true }); + + await expect(thumbnail).toHaveAttribute('src', /thumbnail/); + const initialSrc = await thumbnail.getAttribute('src'); await utils.replaceAsset(admin.accessToken, asset.id); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc); + await expect(preview).not.toHaveAttribute('src', initialSrc!); }); }); diff --git a/e2e/src/ui/mock-network/timeline-network.ts b/e2e/src/ui/mock-network/timeline-network.ts index 6af2ebb7c1..0f1075afd9 100644 --- a/e2e/src/ui/mock-network/timeline-network.ts +++ b/e2e/src/ui/mock-network/timeline-network.ts @@ -99,13 +99,13 @@ export const setupTimelineMockApiRoutes = async ( }); await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => { - const pattern = /\/api\/assets\/(?[^/]+)\/thumbnail\?size=(?preview|thumbnail)/; + const pattern = /\/api\/assets\/(?[^/]+)\/thumbnail\?size=(?preview|thumbnail|fullsize)/; const match = request.url().match(pattern); if (!match?.groups) { throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`); } - if (match.groups.size === 'preview') { + if (match.groups.size === 'preview' || match.groups.size === 'fullsize') { if (!route.request().serviceWorker()) { return route.continue(); } diff --git a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts index fa010f0c1b..b2502df6fc 100644 --- a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts +++ b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts @@ -73,7 +73,7 @@ test.describe('broken-asset responsiveness', () => { await page.goto(`/photos/${fixture.primaryAsset.id}`); await page.waitForSelector('#immich-asset-viewer'); - const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]'); + const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]'); await expect(viewerBrokenAsset).toBeVisible(); await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); diff --git a/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts b/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts index c3721b1c54..4aa4bede99 100644 --- a/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts +++ b/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts @@ -6,6 +6,7 @@ import { generateTimelineData, TimelineAssetConfig, TimelineData, + toAssetResponseDto, } from 'src/ui/generators/timeline'; import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network'; import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network'; @@ -53,7 +54,7 @@ test.describe('search gallery-viewer', () => { assets: { total: searchAssets.length, count: searchAssets.length, - items: searchAssets, + items: searchAssets.map((asset) => toAssetResponseDto(asset)), facets: [], nextPage: null, }, diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index d3e4e5f7ec..5d6478fb5b 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -163,13 +163,11 @@ export const assetViewerUtils = { return page.locator('#immich-asset-viewer'); }, async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) { + const previewUrl = `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true`; await page - .locator( - `img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`, - ) - .or( - page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`), - ) + .getByTestId('preview') + .and(page.locator(`[src="${previewUrl}"]`)) + .or(page.locator(`video[poster="${previewUrl}"]`)) .waitFor(); }, async expectActiveAssetToBe(page: Page, assetId: string) { 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..49a53dac26 --- /dev/null +++ b/web/src/lib/actions/image-loader.svelte.ts @@ -0,0 +1,25 @@ +import { cancelImageUrl } from '$lib/utils/sw-messaging'; + +export function loadImage(src: string, onLoad: () => void, onError: () => void, onStart?: () => void) { + let destroyed = false; + + const handleLoad = () => !destroyed && onLoad(); + const handleError = () => !destroyed && onError(); + + const img = document.createElement('img'); + img.addEventListener('load', handleLoad); + img.addEventListener('error', handleError); + + onStart?.(); + img.src = src; + + return () => { + destroyed = true; + img.removeEventListener('load', handleLoad); + img.removeEventListener('error', handleError); + cancelImageUrl(src); + img.remove(); + }; +} + +export type LoadImageFunction = typeof loadImage; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 6288daa380..759074c4f0 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -1,8 +1,12 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { createZoomImageWheel } from '@zoom-image/core'; -export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { - const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState }); +export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean; zoomTarget?: HTMLElement }) => { + const zoomInstance = createZoomImageWheel(node, { + maxZoom: 10, + initialState: assetViewerManager.zoomState, + zoomTarget: options?.zoomTarget, + }); const unsubscribes = [ assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }), @@ -20,8 +24,11 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea node.style.overflow = 'visible'; return { - update(newOptions?: { disabled?: boolean }) { + update(newOptions?: { disabled?: boolean; zoomTarget?: HTMLElement }) { options = newOptions; + if (newOptions?.zoomTarget !== undefined) { + zoomInstance.setState({ zoomTarget: newOptions.zoomTarget }); + } }, destroy() { for (const unsubscribe of unsubscribes) { diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte new file mode 100644 index 0000000000..764e83a5e8 --- /dev/null +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -0,0 +1,238 @@ + + +
+ {@render backdrop?.()} + +
+ {#if showAlphaBackground} + + {/if} + + {#if showThumbhash} + {#if asset.thumbhash} + + + {:else if showSpinner} +
+ +
+ {/if} + {/if} + + {#if showThumbnail} + {#key adaptiveImageLoader} + {@const loader = adaptiveImageLoader} +
+ loader.onThumbnailStart()} + onLoad={() => loader.onThumbnailLoad()} + onError={() => loader.onThumbnailError()} + bind:ref={thumbnailElement} + class={['absolute h-full', 'w-full']} + alt="" + role="presentation" + data-testid="thumbnail" + /> +
+ {/key} + {/if} + + {#if showBrokenAsset} + + {/if} + + {#if showPreview} + {#key adaptiveImageLoader} + {@const loader = adaptiveImageLoader} +
+ loader.onPreviewStart()} + onLoad={() => loader.onPreviewLoad()} + onError={() => loader.onPreviewError()} + bind:ref={previewElement} + class={['h-full', 'w-full', imageClass]} + alt={imageAltText} + draggable={false} + data-testid="preview" + /> + {@render overlays?.()} +
+ {/key} + {/if} + + {#if showOriginal} + {#key adaptiveImageLoader} + {@const loader = adaptiveImageLoader} +
+ loader.onOriginalStart()} + onLoad={() => loader.onOriginalLoad()} + onError={() => loader.onOriginalError()} + bind:ref={originalElement} + class={['h-full', 'w-full', imageClass]} + alt={imageAltText} + draggable={false} + data-testid="original" + /> + {@render overlays?.()} +
+ {/key} + {/if} +
+
+ + diff --git a/web/src/lib/components/AlphaBackground.svelte b/web/src/lib/components/AlphaBackground.svelte new file mode 100644 index 0000000000..c0d8536a2f --- /dev/null +++ b/web/src/lib/components/AlphaBackground.svelte @@ -0,0 +1,11 @@ + + +
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index c87baef42a..b080eac457 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 @@ @@ -448,23 +557,15 @@ {/if} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
navigateAsset('previous')} />
{/if} -
- {#if viewerKind === 'StackPhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - haveFadeTransition={false} - {sharedLink} - /> - {:else if viewerKind === 'StackVideoViewer'} +
+ {#if viewerKind === 'StackVideoViewer'} {:else if viewerKind === 'PhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - {sharedLink} - haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} - /> + {:else if viewerKind === 'VideoViewer'} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
navigateAsset('next')} />
@@ -563,10 +658,14 @@ {#if stack && withStacked && !assetViewerManager.isShowEditor} {@const stackedAssets = stack.assets}
-
+ diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index 39088b23de..e84bc9fa0c 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -3,7 +3,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; - import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils'; + import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { handleError } from '$lib/utils/handle-error'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { Button, Input, modalManager, toastManager } from '@immich/ui'; @@ -81,15 +81,20 @@ await getPeople(); }); - $effect(() => { - const metrics = getContentMetrics(htmlElement); - - const imageBoundingBox = { - top: metrics.offsetY, - left: metrics.offsetX, - width: metrics.contentWidth, - height: metrics.contentHeight, + const imageContentMetrics = $derived.by(() => { + const natural = getNaturalSize(htmlElement); + const container = { width: containerWidth, height: containerHeight }; + const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container); + return { + contentWidth, + contentHeight, + offsetX: (containerWidth - contentWidth) / 2, + offsetY: (containerHeight - contentHeight) / 2, }; + }); + + $effect(() => { + const { offsetX, offsetY } = imageContentMetrics; if (!canvas) { return; @@ -105,8 +110,8 @@ } faceRect.set({ - top: imageBoundingBox.top + 200, - left: imageBoundingBox.left + 200, + top: offsetY + 200, + left: offsetX + 200, }); faceRect.setCoords(); @@ -214,13 +219,13 @@ } const { left, top, width, height } = faceRect.getBoundingRect(); - const metrics = getContentMetrics(htmlElement); + const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; const natural = getNaturalSize(htmlElement); - const scaleX = natural.width / metrics.contentWidth; - const scaleY = natural.height / metrics.contentHeight; - const imageX = (left - metrics.offsetX) * scaleX; - const imageY = (top - metrics.offsetY) * scaleY; + const scaleX = natural.width / contentWidth; + const scaleY = natural.height / contentHeight; + const imageX = (left - offsetX) * scaleX; + const imageY = (top - offsetY) * scaleY; return { imageWidth: natural.width, diff --git a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte index 6f6caad0fc..029559517a 100644 --- a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte +++ b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte @@ -19,7 +19,7 @@
{ocrBox.text} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 3e609ff130..12e62cacf6 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,66 +1,56 @@ @@ -243,53 +176,57 @@ { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, ]} /> -{#if imageError} -
- -
-{/if} - + - - diff --git a/web/src/lib/managers/ImageManager.svelte.ts b/web/src/lib/managers/ImageManager.svelte.ts index 004974d677..491437c72d 100644 --- a/web/src/lib/managers/ImageManager.svelte.ts +++ b/web/src/lib/managers/ImageManager.svelte.ts @@ -4,19 +4,42 @@ import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk'; type AllAssetMediaSize = AssetMediaSize | 'all'; +type AssetLoadState = 'loading' | 'cancelled'; + class ImageManager { + private assetStates = new Map(); + private readonly MAX_TRACKED_ASSETS = 10; + + private trackAction(asset: AssetResponseDto, action: AssetLoadState) { + this.assetStates.delete(asset.id); + this.assetStates.set(asset.id, action); + + if (this.assetStates.size > this.MAX_TRACKED_ASSETS) { + const firstKey = this.assetStates.keys().next().value!; + this.assetStates.delete(firstKey); + } + } + + isCanceled(asset: AssetResponseDto) { + return 'cancelled' === this.assetStates.get(asset.id); + } + + trackLoad(asset: AssetResponseDto) { + this.trackAction(asset, 'loading'); + } + + trackCancelled(asset: AssetResponseDto) { + this.trackAction(asset, 'cancelled'); + } + preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) { if (!asset) { return; } - - const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash }); - if (!url) { - return; - } - + const src = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash }); + this.trackLoad(asset); const img = new Image(); - img.src = url; + img.src = src; } cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) { @@ -24,6 +47,8 @@ class ImageManager { return; } + this.trackCancelled(asset); + const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size]; for (const size of sizes) { const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash }); 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..f78d44b420 --- /dev/null +++ b/web/src/lib/utils/adaptive-image-loader.svelte.ts @@ -0,0 +1,300 @@ +import type { LoadImageFunction } from '$lib/actions/image-loader.svelte'; +import { imageManager } from '$lib/managers/ImageManager.svelte'; +import { getAssetMediaUrl, getAssetUrl } from '$lib/utils'; +import { AssetMediaSize, 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'; + +const qualityOrder: Record = { + basic: 0, + 'loading-thumbnail': 1, + thumbnail: 2, + 'loading-preview': 3, + preview: 4, + 'loading-original': 5, + original: 6, +}; + +export interface ImageLoaderState { + previewUrl?: string; + thumbnailUrl?: string; + originalUrl?: string; + quality: ImageQuality; + hasError: boolean; + thumbnailImage: ImageStatus; + previewImage: ImageStatus; + originalImage: ImageStatus; +} + +export enum ImageStatus { + Unloaded = 'Unloaded', + Success = 'Success', + Error = 'Error', +} + +/** + * Coordinates adaptive loading of a single asset image: + * thumbhash → thumbnail → preview → original (on zoom) + * + */ +let nextLoaderId = 0; + +export class AdaptiveImageLoader { + readonly id = nextLoaderId++; + + private internalState = $state({ + quality: 'basic', + hasError: false, + thumbnailImage: ImageStatus.Unloaded, + previewImage: ImageStatus.Unloaded, + originalImage: ImageStatus.Unloaded, + }); + + private readonly currentZoomFn?: () => number; + private readonly imageLoader?: LoadImageFunction; + private readonly destroyFunctions: (() => void)[] = []; + readonly thumbnailUrl: string; + readonly previewUrl: string; + readonly originalUrl: string; + readonly asset: AssetResponseDto; + readonly sharedLink?: SharedLinkResponseDto; + readonly callbacks?: { + currentZoomFn: () => number; + onUrlChange?: (url: string) => void; + onImageReady?: () => void; + onError?: () => void; + }; + destroyed = false; + + constructor( + asset: AssetResponseDto, + sharedLink: SharedLinkResponseDto | undefined, + callbacks?: { + currentZoomFn: () => number; + onUrlChange?: (url: string) => void; + onImageReady?: () => void; + onError?: () => void; + }, + imageLoader?: LoadImageFunction, + ) { + imageManager.trackLoad(asset); + this.asset = asset; + this.callbacks = callbacks; + this.imageLoader = imageLoader; + this.thumbnailUrl = getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail }); + this.previewUrl = getAssetUrl({ asset, sharedLink })!; + this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true })!; + this.internalState.thumbnailUrl = this.thumbnailUrl; + this.sharedLink = sharedLink; + } + + 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 state(): ImageLoaderState { + return this.internalState; + } + + private shouldUpdateQuality(newQuality: ImageQuality): boolean { + const currentLevel = qualityOrder[this.internalState.quality]; + const newLevel = qualityOrder[newQuality]; + return newLevel > currentLevel; + } + + onThumbnailStart() { + if (this.destroyed) { + return; + } + if (!this.shouldUpdateQuality('loading-thumbnail')) { + return; + } + this.internalState.quality = 'loading-thumbnail'; + } + + onThumbnailLoad() { + if (this.destroyed) { + return; + } + if (!this.shouldUpdateQuality('thumbnail')) { + return; + } + this.internalState.quality = 'thumbnail'; + this.internalState.thumbnailImage = ImageStatus.Success; + this.callbacks?.onUrlChange?.(this.thumbnailUrl); + this.callbacks?.onImageReady?.(); + this.triggerMainImage(); + } + + onThumbnailError() { + if (this.destroyed) { + return; + } + this.internalState.hasError = true; + this.internalState.thumbnailUrl = undefined; + this.internalState.thumbnailImage = ImageStatus.Error; + this.callbacks?.onError?.(); + 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; + } + if (this.internalState.previewUrl) { + // Already triggered + return true; + } + this.internalState.hasError = false; + this.internalState.previewUrl = this.previewUrl; + if (this.imageLoader) { + this.destroyFunctions.push( + this.imageLoader( + this.previewUrl, + + () => this.onPreviewLoad(), + () => this.onPreviewError(), + () => this.onPreviewStart(), + ), + ); + } + } + + onPreviewStart() { + if (this.destroyed) { + return; + } + if (!this.shouldUpdateQuality('loading-preview')) { + return; + } + this.internalState.quality = 'loading-preview'; + } + + onPreviewLoad() { + if (this.destroyed) { + return; + } + if (!this.internalState.previewUrl) { + return; + } + if (!this.shouldUpdateQuality('preview')) { + return; + } + this.internalState.quality = 'preview'; + this.internalState.previewImage = ImageStatus.Success; + this.callbacks?.onUrlChange?.(this.previewUrl); + this.callbacks?.onImageReady?.(); + } + + onPreviewError() { + if (this.destroyed || imageManager.isCanceled(this.asset)) { + return; + } + this.internalState.hasError = true; + this.internalState.previewImage = ImageStatus.Error; + this.internalState.previewUrl = undefined; + this.callbacks?.onError?.(); + this.triggerOriginal(); + } + + triggerOriginal() { + if (!this.originalUrl) { + return false; + } + if (this.internalState.originalUrl) { + // Already triggered + return true; + } + this.internalState.hasError = false; + this.internalState.originalUrl = this.originalUrl; + + if (this.imageLoader) { + this.destroyFunctions.push( + this.imageLoader( + this.originalUrl, + + () => this.onOriginalLoad(), + () => this.onOriginalError(), + () => this.onOriginalStart(), + ), + ); + } + } + + onOriginalStart() { + if (this.destroyed || imageManager.isCanceled(this.asset)) { + return; + } + if (!this.shouldUpdateQuality('loading-original')) { + return; + } + this.internalState.quality = 'loading-original'; + } + + onOriginalLoad() { + if (this.destroyed || imageManager.isCanceled(this.asset)) { + return; + } + if (!this.internalState.originalUrl) { + return; + } + if (!this.shouldUpdateQuality('original')) { + return; + } + this.internalState.quality = 'original'; + this.internalState.originalImage = ImageStatus.Success; + this.callbacks?.onUrlChange?.(this.originalUrl); + this.callbacks?.onImageReady?.(); + } + + onOriginalError() { + if (this.destroyed || imageManager.isCanceled(this.asset)) { + return; + } + this.internalState.hasError = true; + this.internalState.originalImage = ImageStatus.Error; + this.internalState.originalUrl = undefined; + this.callbacks?.onError?.(); + } + + destroy(): void { + this.destroyed = true; + if (this.imageLoader) { + for (const destroy of this.destroyFunctions) { + destroy(); + } + return; + } + this.cancel(this.asset); + } + cancel(asset: AssetResponseDto | undefined) { + imageManager.cancel(asset); + } +} diff --git a/web/src/lib/utils/layout-utils.spec.ts b/web/src/lib/utils/layout-utils.spec.ts new file mode 100644 index 0000000000..94f1ffb335 --- /dev/null +++ b/web/src/lib/utils/layout-utils.spec.ts @@ -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); + }); + } +});