diff --git a/e2e/src/specs/web/photo-viewer.e2e-spec.ts b/e2e/src/specs/web/photo-viewer.e2e-spec.ts index 9c2c96be78..5e8b000f76 100644 --- a/e2e/src/specs/web/photo-viewer.e2e-spec.ts +++ b/e2e/src/specs/web/photo-viewer.e2e-spec.ts @@ -28,10 +28,9 @@ test.describe('Photo Viewer', () => { 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); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); await expect(original).toBeInViewport(); await expect(original).toHaveAttribute('src', /original/); @@ -44,10 +43,9 @@ test.describe('Photo Viewer', () => { 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); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); await expect(original).toHaveAttribute('src', /fullsize/); }); diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 764e83a5e8..ec2eb2a24a 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -2,21 +2,21 @@ import { thumbhash } from '$lib/actions/thumbhash'; import AlphaBackground from '$lib/components/AlphaBackground.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; - import Image from '$lib/components/Image.svelte'; + import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte'; + import ImageLayer from '$lib/components/ImageLayer.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; - import { AdaptiveImageLoader, ImageStatus } from '$lib/utils/adaptive-image-loader.svelte'; - import { getDimensions } from '$lib/utils/asset-utils'; - import { scaleToFit } from '$lib/utils/container-utils'; + import { getAssetUrls } from '$lib/utils'; + import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte'; + import { scaleToCover, scaleToFit } from '$lib/utils/container-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk'; - import { LoadingSpinner } from '@immich/ui'; import { untrack, type Snippet } from 'svelte'; - interface Props { + type Props = { asset: AssetResponseDto; sharedLink?: SharedLinkResponseDto; - imageClass?: string; + objectFit?: 'contain' | 'cover'; container: { width: number; height: number; @@ -28,7 +28,7 @@ imgRef?: HTMLImageElement; backdrop?: Snippet; overlays?: Snippet; - } + }; let { ref = $bindable(), @@ -36,7 +36,7 @@ imgRef = $bindable(), asset, sharedLink, - imageClass = '', + objectFit = 'contain', container, onUrlChange, onImageReady, @@ -45,6 +45,35 @@ overlays, }: Props = $props(); + const afterThumbnail = (loader: AdaptiveImageLoader) => { + if (assetViewerManager.zoom > 1) { + loader.trigger('original'); + } else { + loader.trigger('preview'); + } + }; + + const buildQualityList = () => { + const assetUrls = getAssetUrls(asset, sharedLink); + const qualityList: QualityList = [ + { + quality: 'thumbnail', + url: assetUrls.thumbnail, + checkCanceled: false, + onAfterLoad: afterThumbnail, + onAfterError: afterThumbnail, + }, + { + quality: 'preview', + url: assetUrls.preview, + checkCanceled: true, + onAfterError: (loader) => loader.trigger('original'), + }, + { quality: 'original', url: assetUrls.original, checkCanceled: true }, + ]; + return qualityList; + }; + const loaderKey = $derived(`${asset.id}:${asset.thumbhash}:${sharedLink?.id}`); const adaptiveImageLoader = $derived.by(() => { @@ -52,8 +81,7 @@ return untrack( () => - new AdaptiveImageLoader(asset, sharedLink, { - currentZoomFn: () => assetViewerManager.zoom, + new AdaptiveImageLoader(asset.id, buildQualityList(), { onImageReady, onError, onUrlChange, @@ -64,27 +92,20 @@ $effect.pre(() => { const loader = adaptiveImageLoader; untrack(() => assetViewerManager.resetZoomState()); - return () => { - loader.destroy(); - }; + return () => loader.destroy(); }); const imageDimensions = $derived.by(() => { - if ((asset.width ?? 0) > 0 && (asset.height ?? 0) > 0) { - return { width: asset.width!, height: asset.height! }; + const { width, height } = asset; + if (width && width > 0 && height && height > 0) { + return { width, height }; } - - if (asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageWidth) { - return getDimensions(asset.exifInfo) as { width: number; height: number }; - } - return { width: 1, height: 1 }; }); - const scaledDimensions = $derived(scaleToFit(imageDimensions, container)); - - const renderDimensions = $derived.by(() => { - const { width, height } = scaledDimensions; + const { width, height, left, top } = $derived.by(() => { + const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit; + const { width, height } = scaleFn(imageDimensions, container); return { width: width + 'px', height: height + 'px', @@ -93,146 +114,99 @@ }; }); - const loaderState = $derived(adaptiveImageLoader.state); - const imageAltText = $derived(loaderState.previewUrl ? $getAltText(toTimelineAsset(asset)) : ''); + const { status } = $derived(adaptiveImageLoader); + const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : ''); - const showAlphaBackground = $derived( - !loaderState.hasError && - ['thumbnail', 'loading-thumbnail', 'loading-preview', 'loading-original', 'preview', 'original'].includes( - loaderState.quality, - ), - ); - const showSpinner = $derived(!asset.thumbhash && loaderState.quality === 'basic'); - const showBrokenAsset = $derived(loaderState.hasError); - const showThumbhash = $derived(['basic', 'loading-thumbnail'].includes(loaderState.quality)); - const showThumbnail = true; - const showPreview = true; - const showOriginal = true; + const show = $derived.by(() => { + const { quality, started, hasError, urls } = status; + return { + alphaBackground: !hasError && started, + spinner: !asset.thumbhash && !started, + brokenAsset: hasError, + thumbhash: quality.thumbnail !== 'success' && quality.preview !== 'success' && quality.original !== 'success', + thumbnail: quality.thumbnail !== 'error' && quality.preview !== 'success' && quality.original !== 'success', + preview: quality.preview !== 'error' && quality.original !== 'success', + original: quality.original !== 'error' && urls.original !== undefined, + }; + }); - // Effect: Upgrade to original when user zooms in $effect(() => { - if (assetViewerManager.zoom > 1 && loaderState.quality === 'preview') { - untrack(() => { - void adaptiveImageLoader.triggerOriginal(); - }); + if (assetViewerManager.zoom > 1 && status.quality.preview === 'success' && status.quality.original !== 'success') { + untrack(() => void adaptiveImageLoader.trigger('original')); } }); + let thumbnailElement = $state(); let previewElement = $state(); let originalElement = $state(); - const loadedOriginalElement = $derived( - loaderState.originalImage === ImageStatus.Success ? originalElement : undefined, - ); - const loadedPreviewElement = $derived(loaderState.previewImage === ImageStatus.Success ? previewElement : undefined); - const loadedThumbnailElement = $derived( - loaderState.thumbnailImage === ImageStatus.Success ? thumbnailElement : undefined, - ); - $effect(() => { - imgRef = loadedOriginalElement ?? loadedPreviewElement ?? loadedThumbnailElement; + const quality = status.quality; + imgRef = + (quality.original === 'success' ? originalElement : undefined) ?? + (quality.preview === 'success' ? previewElement : undefined) ?? + (quality.thumbnail === 'success' ? thumbnailElement : undefined); }); -
+
{@render backdrop?.()} -
- {#if showAlphaBackground} - +
+ {#if show.alphaBackground} + {/if} - {#if showThumbhash} + {#if show.thumbhash} {#if asset.thumbhash} - - {:else if showSpinner} -
- -
+ + {:else if show.spinner} + {/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 show.thumbnail} + {/if} - {#if showBrokenAsset} + {#if show.brokenAsset} {/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 show.preview} + {/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 show.original} + {/if}
- - diff --git a/web/src/lib/components/DelayedLoadingSpinner.svelte b/web/src/lib/components/DelayedLoadingSpinner.svelte new file mode 100644 index 0000000000..d18d373566 --- /dev/null +++ b/web/src/lib/components/DelayedLoadingSpinner.svelte @@ -0,0 +1,20 @@ + + +
+ +
+ + diff --git a/web/src/lib/components/ImageLayer.svelte b/web/src/lib/components/ImageLayer.svelte new file mode 100644 index 0000000000..9dea7eaf4c --- /dev/null +++ b/web/src/lib/components/ImageLayer.svelte @@ -0,0 +1,47 @@ + + +{#key adaptiveImageLoader} +
+ adaptiveImageLoader.onStart(quality)} + onLoad={() => adaptiveImageLoader.onLoad(quality)} + onError={() => adaptiveImageLoader.onError(quality)} + bind:ref + class="h-full w-full" + {alt} + {role} + draggable={false} + data-testid={quality} + /> + {@render overlays?.()} +
+{/key} diff --git a/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts new file mode 100644 index 0000000000..57935a4be8 --- /dev/null +++ b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts @@ -0,0 +1,103 @@ +import { loadImage } from '$lib/actions/image-loader.svelte'; +import { getAssetUrls } from '$lib/utils'; +import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte'; +import type { AssetResponseDto } from '@immich/sdk'; + +type AssetCursor = { + current: AssetResponseDto; + nextAsset?: AssetResponseDto; + previousAsset?: AssetResponseDto; +}; + +export class PreloadManager { + private nextPreloader: AdaptiveImageLoader | undefined; + private previousPreloader: AdaptiveImageLoader | undefined; + + private startPreloader(asset: AssetResponseDto | undefined): AdaptiveImageLoader | undefined { + if (!asset) { + return; + } + const urls = getAssetUrls(asset); + const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview'); + const qualityList: QualityList = [ + { + quality: 'thumbnail', + url: urls.thumbnail, + checkCanceled: false, + onAfterLoad: afterThumbnail, + onAfterError: afterThumbnail, + }, + { + quality: 'preview', + url: urls.preview, + checkCanceled: true, + onAfterError: (loader) => loader.trigger('original'), + }, + { quality: 'original', url: urls.original, checkCanceled: true }, + ]; + const loader = new AdaptiveImageLoader(asset.id, qualityList, undefined, loadImage); + loader.start(); + return loader; + } + + private destroyPreviousPreloader() { + this.previousPreloader?.destroy(); + this.previousPreloader = undefined; + } + + private destroyNextPreloader() { + this.nextPreloader?.destroy(); + this.nextPreloader = undefined; + } + + cancelBeforeNavigation(direction: 'previous' | 'next') { + switch (direction) { + case 'next': { + this.destroyPreviousPreloader(); + break; + } + case 'previous': { + this.destroyNextPreloader(); + break; + } + } + } + + updateAfterNavigation(oldCursor: AssetCursor, newCursor: AssetCursor) { + const movedForward = newCursor.current.id === oldCursor.nextAsset?.id; + const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id; + + if (!movedBackward) { + this.destroyPreviousPreloader(); + } + + if (!movedForward) { + this.destroyNextPreloader(); + } + + if (movedForward) { + this.nextPreloader = this.startPreloader(newCursor.nextAsset); + } else if (movedBackward) { + this.previousPreloader = this.startPreloader(newCursor.previousAsset); + } else { + this.previousPreloader = this.startPreloader(newCursor.previousAsset); + this.nextPreloader = this.startPreloader(newCursor.nextAsset); + } + } + + initializePreloads(cursor: AssetCursor) { + if (cursor.nextAsset) { + this.nextPreloader = this.startPreloader(cursor.nextAsset); + } + if (cursor.previousAsset) { + this.previousPreloader = this.startPreloader(cursor.previousAsset); + } + } + + destroy() { + this.destroyNextPreloader(); + this.destroyPreviousPreloader(); + } +} + +export const preloadManager = new PreloadManager(); diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index b080eac457..684a2a44bf 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,11 +1,11 @@ @@ -190,7 +189,7 @@ {asset} {sharedLink} {container} - imageClass={`${$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook]}`} + objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'} {onUrlChange} onImageReady={() => { visibleImageReady = true; @@ -207,20 +206,23 @@ {#if blurredSlideshow} {/if} {/snippet} {#snippet overlays()} {#if !isFaceEditMode.value && !ocrManager.showOverlay} {#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)} - + aria-label="{$t('person')}: {faceToNameMap.get(faces[index]) || $t('unknown')}" + onmouseenter={() => ($boundingBoxesArray = [faces[index]])} + onmouseleave={() => ($boundingBoxesArray = [])} + onfocus={() => ($boundingBoxesArray = [faces[index]])} + onblur={() => ($boundingBoxesArray = [])} + > {/each} {/if} @@ -231,6 +233,7 @@ >
{#if faceToNameMap.get($boundingBoxesArray[index])} + {:else if imageLoaded}
{/if} - - diff --git a/web/src/lib/managers/ImageManager.spec.ts b/web/src/lib/managers/ImageManager.spec.ts deleted file mode 100644 index 6147b3ac6f..0000000000 --- a/web/src/lib/managers/ImageManager.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { imageManager } from '$lib/managers/ImageManager.svelte'; -import { getAssetMediaUrl } from '$lib/utils'; -import { cancelImageUrl } from '$lib/utils/sw-messaging'; -import { AssetMediaSize } from '@immich/sdk'; -import { assetFactory } from '@test-data/factories/asset-factory'; - -vi.mock('$lib/utils/sw-messaging', () => ({ - cancelImageUrl: vi.fn(), -})); - -vi.mock('$lib/utils', () => ({ - getAssetMediaUrl: vi.fn(), -})); - -describe('ImageManager', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('preload', () => { - it('creates an Image with the correct URL', () => { - vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media'); - const asset = assetFactory.build(); - - imageManager.preload(asset); - - expect(getAssetMediaUrl).toHaveBeenCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - }); - - it('does nothing for undefined asset', () => { - imageManager.preload(undefined); - expect(getAssetMediaUrl).not.toHaveBeenCalled(); - }); - - it('does nothing when getAssetMediaUrl returns falsy', () => { - vi.mocked(getAssetMediaUrl).mockReturnValue(''); - const asset = assetFactory.build(); - - imageManager.preload(asset); - - expect(getAssetMediaUrl).toHaveBeenCalled(); - }); - - it('uses the specified size', () => { - vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media'); - const asset = assetFactory.build(); - - imageManager.preload(asset, AssetMediaSize.Thumbnail); - - expect(getAssetMediaUrl).toHaveBeenCalledWith({ - id: asset.id, - size: AssetMediaSize.Thumbnail, - cacheKey: asset.thumbhash, - }); - }); - }); - - describe('cancel', () => { - it('calls cancelImageUrl with the correct URL', () => { - vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media'); - const asset = assetFactory.build(); - - imageManager.cancel(asset, AssetMediaSize.Preview); - - expect(cancelImageUrl).toHaveBeenCalledWith('/api/assets/123/media'); - }); - - it('does nothing for undefined asset', () => { - imageManager.cancel(undefined); - expect(getAssetMediaUrl).not.toHaveBeenCalled(); - expect(cancelImageUrl).not.toHaveBeenCalled(); - }); - - it('cancels all sizes when size is "all"', () => { - vi.mocked(getAssetMediaUrl).mockImplementation(({ size }) => `/api/assets/123/${size}`); - const asset = assetFactory.build(); - - imageManager.cancel(asset, 'all'); - - expect(getAssetMediaUrl).toHaveBeenCalledTimes(Object.values(AssetMediaSize).length); - for (const size of Object.values(AssetMediaSize)) { - expect(cancelImageUrl).toHaveBeenCalledWith(`/api/assets/123/${size}`); - } - }); - - it('does not call cancelImageUrl when URL is falsy', () => { - vi.mocked(getAssetMediaUrl).mockReturnValue(''); - const asset = assetFactory.build(); - - imageManager.cancel(asset, AssetMediaSize.Preview); - - expect(cancelImageUrl).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/web/src/lib/managers/ImageManager.svelte.ts b/web/src/lib/managers/ImageManager.svelte.ts deleted file mode 100644 index 491437c72d..0000000000 --- a/web/src/lib/managers/ImageManager.svelte.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { getAssetMediaUrl } from '$lib/utils'; -import { cancelImageUrl } from '$lib/utils/sw-messaging'; -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 src = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash }); - this.trackLoad(asset); - const img = new Image(); - img.src = src; - } - - cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) { - if (!asset) { - 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 }); - if (url) { - cancelImageUrl(url); - } - } - } -} - -export const imageManager = new ImageManager(); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index cb8095109e..8b6665bf94 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -186,6 +186,14 @@ export const getAssetUrl = ({ return getAssetMediaUrl({ id, size, cacheKey }); }; +export function getAssetUrls(asset: AssetResponseDto, sharedLink?: SharedLinkResponseDto) { + return { + thumbnail: getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail }), + preview: getAssetUrl({ asset, sharedLink })!, + original: getAssetUrl({ asset, sharedLink, forceOriginal: true })!, + }; +} + const forceUseOriginal = (asset: AssetResponseDto) => { return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000'); }; diff --git a/web/src/lib/utils/adaptive-image-loader.svelte.ts b/web/src/lib/utils/adaptive-image-loader.svelte.ts index f78d44b420..e39054ea5b 100644 --- a/web/src/lib/utils/adaptive-image-loader.svelte.ts +++ b/web/src/lib/utils/adaptive-image-loader.svelte.ts @@ -1,290 +1,175 @@ 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'; +import { cancelImageUrl } from '$lib/utils/sw-messaging'; -/** - * Quality levels for progressive image loading - */ -type ImageQuality = - | 'basic' - | 'loading-thumbnail' - | 'thumbnail' - | 'loading-preview' - | 'preview' - | 'loading-original' - | 'original'; +export type ImageQuality = 'thumbnail' | 'preview' | 'original'; -const qualityOrder: Record = { - basic: 0, - 'loading-thumbnail': 1, - thumbnail: 2, - 'loading-preview': 3, - preview: 4, - 'loading-original': 5, - original: 6, +export type ImageStatus = 'unloaded' | 'success' | 'error'; + +export type ImageLoaderStatus = { + urls: Record; + quality: Record; + started: boolean; + hasError: boolean; }; -export interface ImageLoaderState { - previewUrl?: string; - thumbnailUrl?: string; - originalUrl?: string; +type ImageLoaderCallbacks = { + onUrlChange?: (url: string) => void; + onImageReady?: () => void; + onError?: () => void; +}; + +export type QualityConfig = { + url: string; quality: ImageQuality; - hasError: boolean; - thumbnailImage: ImageStatus; - previewImage: ImageStatus; - originalImage: ImageStatus; -} + checkCanceled: boolean; + onAfterLoad?: (loader: AdaptiveImageLoader) => void; + onAfterError?: (loader: AdaptiveImageLoader) => void; +}; -export enum ImageStatus { - Unloaded = 'Unloaded', - Success = 'Success', - Error = 'Error', -} +const MAX_TRACKED_ASSETS = 10; +// eslint-disable-next-line svelte/prefer-svelte-reactivity +const tracker = new Map(); -/** - * Coordinates adaptive loading of a single asset image: - * thumbhash → thumbnail → preview → original (on zoom) - * - */ -let nextLoaderId = 0; +const updateTracker = (id: string, action: 'loading' | 'canceled') => { + tracker.delete(id); + tracker.set(id, action); + + if (tracker.size > MAX_TRACKED_ASSETS) { + const firstKey = tracker.keys().next().value!; + tracker.delete(firstKey); + } +}; + +const isCanceled = (id: string) => 'canceled' === tracker.get(id); +const setLoading = (id: string) => updateTracker(id, 'loading'); +const setCanceled = (id: string) => updateTracker(id, 'canceled'); + +export type QualityList = [ + QualityConfig & { quality: 'thumbnail' }, + QualityConfig & { quality: 'preview' }, + QualityConfig & { quality: 'original' }, +]; export class AdaptiveImageLoader { - readonly id = nextLoaderId++; + private destroyFunctions: (() => void)[] = []; + private qualityConfigs: Record; + private highestLoadedQualityIndex = -1; + private destroyed = false; - private internalState = $state({ - quality: 'basic', + status = $state({ + started: false, hasError: false, - thumbnailImage: ImageStatus.Unloaded, - previewImage: ImageStatus.Unloaded, - originalImage: ImageStatus.Unloaded, + urls: { thumbnail: undefined, preview: undefined, original: undefined }, + quality: { thumbnail: 'unloaded', preview: 'unloaded', original: '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, + private readonly id: string, + private readonly qualityList: QualityList, + private readonly callbacks?: ImageLoaderCallbacks, + private readonly 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; + this.qualityConfigs = { + thumbnail: qualityList[0], + preview: qualityList[1], + original: qualityList[2], + }; + this.status.urls.thumbnail = qualityList[0].url; + setLoading(id); } 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(), + this.qualityList[0].url, + () => this.onLoad('thumbnail'), + () => this.onError('thumbnail'), + () => this.onStart('thumbnail'), ), ); } - get state(): ImageLoaderState { - return this.internalState; + onStart(quality: ImageQuality) { + const config = this.qualityConfigs[quality]; + if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) { + return; + } + this.status.started = true; } - private shouldUpdateQuality(newQuality: ImageQuality): boolean { - const currentLevel = qualityOrder[this.internalState.quality]; - const newLevel = qualityOrder[newQuality]; - return newLevel > currentLevel; - } + onLoad(quality: ImageQuality) { + const config = this.qualityConfigs[quality]; + if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) { + return; + } - onThumbnailStart() { - if (this.destroyed) { + if (!this.status.urls[quality]) { return; } - if (!this.shouldUpdateQuality('loading-thumbnail')) { - return; - } - this.internalState.quality = 'loading-thumbnail'; - } - onThumbnailLoad() { - if (this.destroyed) { + const index = this.qualityList.indexOf(config); + if (index <= this.highestLoadedQualityIndex) { return; } - if (!this.shouldUpdateQuality('thumbnail')) { - return; - } - this.internalState.quality = 'thumbnail'; - this.internalState.thumbnailImage = ImageStatus.Success; - this.callbacks?.onUrlChange?.(this.thumbnailUrl); + + this.highestLoadedQualityIndex = index; + this.status.quality[quality] = 'success'; + this.callbacks?.onUrlChange?.(this.qualityConfigs[quality].url); this.callbacks?.onImageReady?.(); - this.triggerMainImage(); + + config.onAfterLoad?.(this); } - onThumbnailError() { - if (this.destroyed) { + onError(quality: ImageQuality) { + const config = this.qualityConfigs[quality]; + if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) { return; } - this.internalState.hasError = true; - this.internalState.thumbnailUrl = undefined; - this.internalState.thumbnailImage = ImageStatus.Error; + + this.status.hasError = true; + this.status.quality[quality] = 'error'; + this.status.urls[quality] = undefined; this.callbacks?.onError?.(); - this.triggerMainImage(); + + config.onAfterError?.(this); } - triggerMainImage() { - const wantsOriginal = (this.currentZoomFn?.() ?? 1) > 1; - return wantsOriginal ? this.triggerOriginal() : this.triggerPreview(); - } - - triggerPreview() { - if (!this.previewUrl) { - // no preview, try original? - this.triggerOriginal(); + trigger(quality: ImageQuality) { + if (this.destroyed) { return false; } - if (this.internalState.previewUrl) { - // Already triggered + + const url = this.qualityConfigs[quality].url; + if (!url) { + this.qualityConfigs[quality].onAfterError?.(this); + return false; + } + + if (this.status.urls[quality]) { return true; } - this.internalState.hasError = false; - this.internalState.previewUrl = this.previewUrl; + + this.status.hasError = false; + this.status.urls[quality] = url; if (this.imageLoader) { this.destroyFunctions.push( this.imageLoader( - this.previewUrl, - - () => this.onPreviewLoad(), - () => this.onPreviewError(), - () => this.onPreviewStart(), + url, + () => this.onLoad(quality), + () => this.onError(quality), + () => this.onStart(quality), ), ); } + return false; } - 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 { + destroy() { + setCanceled(this.id); this.destroyed = true; if (this.imageLoader) { for (const destroy of this.destroyFunctions) { @@ -292,9 +177,9 @@ export class AdaptiveImageLoader { } return; } - this.cancel(this.asset); - } - cancel(asset: AssetResponseDto | undefined) { - imageManager.cancel(asset); + + for (const config of Object.values(this.qualityConfigs)) { + cancelImageUrl(config.url); + } } } diff --git a/web/src/lib/utils/container-utils.ts b/web/src/lib/utils/container-utils.ts index 7f770b0e21..ffa2fae769 100644 --- a/web/src/lib/utils/container-utils.ts +++ b/web/src/lib/utils/container-utils.ts @@ -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 },