From e21c99c4203a13361d9345e590d3f512ac149cc5 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 fully rework/condense/simplify AdaptiveImage.svelte --- e2e/src/specs/web/photo-viewer.e2e-spec.ts | 63 ++-- 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 | 15 +- e2e/src/utils.ts | 69 +++-- web/src/lib/actions/image-loader.svelte.ts | 25 ++ web/src/lib/actions/zoom-image.ts | 13 +- web/src/lib/components/AdaptiveImage.svelte | 212 +++++++++++++ web/src/lib/components/AlphaBackground.svelte | 11 + .../components/DelayedLoadingSpinner.svelte | 20 ++ web/src/lib/components/ImageLayer.svelte | 47 +++ .../asset-viewer/PreloadManager.svelte.ts | 103 +++++++ .../asset-viewer/asset-viewer.svelte | 241 ++++++++------- .../face-editor/face-editor.svelte | 37 ++- .../asset-viewer/photo-viewer.svelte | 280 +++++++----------- .../memory-page/memory-photo-viewer.svelte | 18 +- web/src/lib/managers/ImageManager.spec.ts | 99 ------- web/src/lib/managers/ImageManager.svelte.ts | 39 ++- web/src/lib/stores/ocr.svelte.spec.ts | 5 +- web/src/lib/stores/ocr.svelte.ts | 1 - web/src/lib/utils.ts | 8 + .../lib/utils/adaptive-image-loader.svelte.ts | 185 ++++++++++++ web/src/lib/utils/container-utils.ts | 13 + web/src/lib/utils/layout-utils.spec.ts | 54 ++++ 25 files changed, 1086 insertions(+), 481 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/components/DelayedLoadingSpinner.svelte create mode 100644 web/src/lib/components/ImageLayer.svelte create mode 100644 web/src/lib/components/asset-viewer/PreloadManager.svelte.ts delete mode 100644 web/src/lib/managers/ImageManager.spec.ts 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..1e723916d3 100644 --- a/e2e/src/specs/web/photo-viewer.e2e-spec.ts +++ b/e2e/src/specs/web/photo-viewer.e2e-spec.ts @@ -1,14 +1,13 @@ import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; -import { Page, expect, test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import type { Socket } from 'socket.io-client'; 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; let rawAsset: AssetMediaResponseDto; + let websocket: Socket; test.beforeAll(async () => { utils.initSdk(); @@ -16,6 +15,11 @@ test.describe('Photo Viewer', () => { admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } }); + websocket = await utils.connectWebsocket(admin.accessToken); + }); + + test.afterAll(() => { + utils.disconnectWebsocket(websocket); }); test.beforeEach(async ({ context, page }) => { @@ -26,31 +30,52 @@ 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(); - expect(box).toBeTruthy(); - const { x, y, width, height } = box!; - await page.mouse.move(x + width / 2, y + height / 2); + + const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); + await expect(thumbnail).toHaveAttribute('src', /thumbnail/); + + const originalResponse = page.waitForResponse((response) => response.url().includes('/original')); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original'); + + await originalResponse; + + const original = page.getByTestId('original').filter({ visible: true }); + 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(); - expect(box).toBeTruthy(); - const { x, y, width, height } = box!; - await page.mouse.move(x + width / 2, y + height / 2); + + const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); + await expect(thumbnail).toHaveAttribute('src', /thumbnail/); + + const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize')); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize'); + + await fullsizeResponse; + + const original = page.getByTestId('original').filter({ visible: true }); + 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 }); + await expect(thumbnail).toHaveAttribute('src', /thumbnail/); + const initialSrc = await thumbnail.getAttribute('src'); + + const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); await utils.replaceAsset(admin.accessToken, asset.id); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc); + await websocketEvent; + + const preview = page.getByTestId('preview').filter({ visible: true }); + 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..229d118b5b 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) { @@ -215,8 +213,9 @@ export const pageUtils = { await page.getByText('Confirm').click(); }, async selectDay(page: Page, day: string) { - await page.getByTitle(day).hover(); - await page.locator('[data-group] .w-8').click(); + const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]'); + await section.hover(); + await section.locator('.w-8').click(); }, async pauseTestDebug() { console.log('NOTE: pausing test indefinately for debug'); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 7307f87854..a5567f0778 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -177,40 +177,51 @@ export const utils = { }, resetDatabase: async (tables?: string[]) => { - try { - client = await utils.connectDatabase(); + client = await utils.connectDatabase(); - tables = tables || [ - // TODO e2e test for deleting a stack, since it is quite complex - 'stack', - 'library', - 'shared_link', - 'person', - 'album', - 'asset', - 'asset_face', - 'activity', - 'api_key', - 'session', - 'user', - 'system_metadata', - 'tag', - ]; + tables = tables || [ + // TODO e2e test for deleting a stack, since it is quite complex + 'stack', + 'library', + 'shared_link', + 'person', + 'album', + 'asset', + 'asset_face', + 'activity', + 'api_key', + 'session', + 'user', + 'system_metadata', + 'tag', + ]; - const sql: string[] = []; + const truncateTables = tables.filter((table) => table !== 'system_metadata'); + const sql: string[] = []; - for (const table of tables) { - if (table === 'system_metadata') { - sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); - } else { - sql.push(`DELETE FROM "${table}" CASCADE;`); + if (truncateTables.length > 0) { + sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`); + } + + if (tables.includes('system_metadata')) { + sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); + } + + const query = sql.join('\n'); + const maxRetries = 3; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await client.query(query); + return; + } catch (error: any) { + if (error?.code === '40P01' && attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 250 * attempt)); + continue; } + console.error('Failed to reset database', error); + throw error; } - - await client.query(sql.join('\n')); - } catch (error) { - console.error('Failed to reset database', error); - throw error; } }, 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..20dc5d4a2f --- /dev/null +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -0,0 +1,212 @@ + + +
+ {@render backdrop?.()} + +
+ {#if show.alphaBackground} + + {/if} + + {#if show.thumbhash} + {#if asset.thumbhash} + + + {:else if show.spinner} + + {/if} + {/if} + + {#if show.thumbnail} + + {/if} + + {#if show.brokenAsset} + + {/if} + + {#if show.preview} + + {/if} + + {#if show.original} + + {/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/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 2a75ca4e83..69b6a4f6b2 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -5,15 +5,16 @@ import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; + import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { imageManager } from '$lib/managers/ImageManager.svelte'; import { getAssetActions } from '$lib/services/asset.service'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -36,6 +37,7 @@ } from '@immich/sdk'; import { CommandPaletteDefaultProvider } from '@immich/ui'; import { onDestroy, onMount, untrack } from 'svelte'; + import type { SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; @@ -92,20 +94,20 @@ stopProgress: stopSlideshowProgress, slideshowNavigation, slideshowState, - slideshowTransition, slideshowRepeat, } = slideshowStore; const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; - const asset = $derived(cursor.current); + let stack: StackResponseDto | undefined = $state(); + let selectedStackAsset: AssetResponseDto | undefined = $state(); + let previewStackedAsset: AssetResponseDto | undefined = $state(); + + const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current); const nextAsset = $derived(cursor.nextAsset); const previousAsset = $derived(cursor.previousAsset); let sharedLink = getSharedLink(); - let previewStackedAsset: AssetResponseDto | undefined = $state(); let fullscreenElement = $state(); - let unsubscribes: (() => void)[] = []; - let stack: StackResponseDto | null = $state(null); let playOriginalVideo = $state($alwaysLoadOriginalVideo); let slideshowStartAssetId = $state(); @@ -115,62 +117,62 @@ }; const refreshStack = async () => { - if (authManager.isSharedLink) { + if (authManager.isSharedLink || !withStacked) { return; } - if (asset.stack) { - stack = await getStack({ id: asset.stack.id }); + if (!cursor.current.stack) { + stack = undefined; + selectedStackAsset = undefined; + return; } - if (!stack?.assets.some(({ id }) => id === asset.id)) { - stack = null; - } - - untrack(() => { - imageManager.preload(stack?.assets[1]); - }); + stack = await getStack({ id: cursor.current.stack.id }); + selectedStackAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId); }; const handleFavorite = async () => { - if (album && album.isActivityEnabled) { - try { - await activityManager.toggleLike(); - } catch (error) { - handleError(error, $t('errors.unable_to_change_favorite')); - } + if (!album || !album.isActivityEnabled) { + return; + } + + try { + await activityManager.toggleLike(); + } catch (error) { + handleError(error, $t('errors.unable_to_change_favorite')); } }; onMount(() => { syncAssetViewerOpenClass(true); - unsubscribes.push( - slideshowState.subscribe((value) => { - if (value === SlideshowState.PlaySlideshow) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - handlePromiseError(handlePlaySlideshow()); - } else if (value === SlideshowState.StopSlideshow) { - handlePromiseError(handleStopSlideshow()); - } - }), - slideshowNavigation.subscribe((value) => { - if (value === SlideshowNavigation.Shuffle) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - } - }), - ); + const slideshowStateUnsubscribe = slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + handlePromiseError(handlePlaySlideshow()); + } else if (value === SlideshowState.StopSlideshow) { + handlePromiseError(handleStopSlideshow()); + } + }); + + const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => { + if (value === SlideshowNavigation.Shuffle) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + } + }); + + return () => { + slideshowStateUnsubscribe(); + slideshowNavigationUnsubscribe(); + }; }); onDestroy(() => { - for (const unsubscribe of unsubscribes) { - unsubscribe(); - } - activityManager.reset(); assetViewerManager.closeEditor(); syncAssetViewerOpenClass(false); + preloadManager.destroy(); }); const closeViewer = () => { @@ -187,8 +189,7 @@ }; const tracker = new InvocationTracker(); - - const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { + const navigateAsset = (order?: 'previous' | 'next') => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -197,16 +198,19 @@ } } - e?.stopPropagation(); - imageManager.cancel(asset); + preloadManager.cancelBeforeNavigation(order); + if (tracker.isActive()) { return; } void tracker.invoke(async () => { + const isShuffle = + $slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle; + let hasNext: boolean; - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { + if (isShuffle) { hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); if (!hasNext) { const asset = await onRandom?.(); @@ -220,17 +224,22 @@ order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); } - if ($slideshowState === SlideshowState.PlaySlideshow) { - if (hasNext) { - $restartSlideshowProgress = true; - } else if ($slideshowRepeat && slideshowStartAssetId) { - // Loop back to starting asset - await setAssetId(slideshowStartAssetId); - $restartSlideshowProgress = true; - } else { - await handleStopSlideshow(); - } + if ($slideshowState !== SlideshowState.PlaySlideshow) { + return; } + + if (hasNext) { + $restartSlideshowProgress = true; + return; + } + + if ($slideshowRepeat && slideshowStartAssetId) { + await setAssetId(slideshowStartAssetId); + $restartSlideshowProgress = true; + return; + } + + await handleStopSlideshow(); }, $t('error_while_navigating')); }; @@ -274,12 +283,10 @@ } }; - const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { - previewStackedAsset = isMouseOver ? asset : undefined; - }; const handlePreAction = (action: Action) => { preAction?.(action); }; + const handleAction = async (action: Action) => { switch (action.type) { case AssetAction.DELETE: @@ -288,7 +295,7 @@ break; } case AssetAction.REMOVE_ASSET_FROM_STACK: { - stack = action.stack; + stack = action.stack ?? undefined; if (stack) { cursor.current = stack.assets[0]; } @@ -342,27 +349,53 @@ } }; + const refreshOcr = async () => { + ocrManager.clear(); + if (sharedLink) { + return; + } + + await ocrManager.getAssetOcr(asset.id); + }; + const refresh = async () => { await refreshStack(); - ocrManager.clear(); - if (!sharedLink) { - if (previewStackedAsset) { - await ocrManager.getAssetOcr(previewStackedAsset.id); - } - await ocrManager.getAssetOcr(asset.id); - } + await refreshOcr(); }; + $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions - asset; + cursor.current; untrack(() => handlePromiseError(refresh())); - imageManager.preload(cursor.nextAsset); - imageManager.preload(cursor.previousAsset); + }); + + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + previewStackedAsset; + untrack(() => handlePromiseError(refreshOcr())); + }); + + let lastCursor = $state(); + + $effect(() => { + if (cursor.current.id === lastCursor?.current.id) { + return; + } + if (lastCursor) { + selectedStackAsset = undefined; + previewStackedAsset = undefined; + ocrManager.showOverlay = false; + preloadManager.updateAfterNavigation(lastCursor, cursor); + } + if (!lastCursor) { + preloadManager.initializePreloads(cursor); + } + lastCursor = cursor; }); const viewerKind = $derived.by(() => { if (previewStackedAsset) { - return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; + return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; } if (asset.type === AssetTypeEnum.Video) { return 'VideoViewer'; @@ -403,6 +436,24 @@ assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor, ); + + const onSwipe = (event: SwipeCustomEvent) => { + if (assetViewerManager.zoom > 1) { + return; + } + + if (ocrManager.showOverlay) { + return; + } + + if (event.detail.direction === 'left') { + navigateAsset('next'); + } + + if (event.detail.direction === 'right') { + navigateAsset('previous'); + } + }; @@ -448,23 +499,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 +600,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/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index fd87450d58..1c24321a79 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,12 +175,7 @@ { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, ]} /> -{#if imageError} -
- -
-{/if} - + - - diff --git a/web/src/lib/components/memory-page/memory-photo-viewer.svelte b/web/src/lib/components/memory-page/memory-photo-viewer.svelte index 6e726478bb..e69f31fbd0 100644 --- a/web/src/lib/components/memory-page/memory-photo-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-photo-viewer.svelte @@ -4,7 +4,7 @@ import { getAssetMediaUrl } from '$lib/utils'; import { getAltText } from '$lib/utils/thumbnail-util'; import { AssetMediaSize } from '@immich/sdk'; - import { LoadingSpinner } from '@immich/ui'; + import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte'; import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; @@ -44,9 +44,7 @@ {/if} {#if !imageLoaded} -
- -
+ {: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 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/stores/ocr.svelte.spec.ts b/web/src/lib/stores/ocr.svelte.spec.ts index 1e2aeecb73..a5092a0d80 100644 --- a/web/src/lib/stores/ocr.svelte.spec.ts +++ b/web/src/lib/stores/ocr.svelte.spec.ts @@ -32,6 +32,7 @@ describe('OcrManager', () => { beforeEach(() => { // Reset the singleton state before each test ocrManager.clear(); + ocrManager.showOverlay = false; assetCacheManager.clearOcrCache(); vi.clearAllMocks(); }); @@ -132,12 +133,12 @@ describe('OcrManager', () => { expect(ocrManager.hasOcrData).toBe(false); }); - it('should reset showOverlay to false', () => { + it('should not reset showOverlay', () => { ocrManager.showOverlay = true; ocrManager.clear(); - expect(ocrManager.showOverlay).toBe(false); + expect(ocrManager.showOverlay).toBe(true); }); it('should mark as cleared for next load', async () => { diff --git a/web/src/lib/stores/ocr.svelte.ts b/web/src/lib/stores/ocr.svelte.ts index 39c42875de..4969554f32 100644 --- a/web/src/lib/stores/ocr.svelte.ts +++ b/web/src/lib/stores/ocr.svelte.ts @@ -45,7 +45,6 @@ class OcrManager { clear() { this.#cleared = true; this.#data = []; - this.showOverlay = false; } toggleOcrBoundingBox() { 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 new file mode 100644 index 0000000000..e39054ea5b --- /dev/null +++ b/web/src/lib/utils/adaptive-image-loader.svelte.ts @@ -0,0 +1,185 @@ +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; + quality: Record; + started: boolean; + hasError: boolean; +}; + +type ImageLoaderCallbacks = { + onUrlChange?: (url: string) => void; + onImageReady?: () => void; + onError?: () => void; +}; + +export type QualityConfig = { + url: string; + quality: ImageQuality; + checkCanceled: boolean; + onAfterLoad?: (loader: AdaptiveImageLoader) => void; + onAfterError?: (loader: AdaptiveImageLoader) => void; +}; + +const MAX_TRACKED_ASSETS = 10; +// eslint-disable-next-line svelte/prefer-svelte-reactivity +const tracker = new Map(); + +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 { + private destroyFunctions: (() => void)[] = []; + private qualityConfigs: Record; + private highestLoadedQualityIndex = -1; + private destroyed = false; + + status = $state({ + started: false, + hasError: false, + urls: { thumbnail: undefined, preview: undefined, original: undefined }, + quality: { thumbnail: 'unloaded', preview: 'unloaded', original: 'unloaded' }, + }); + + constructor( + private readonly id: string, + 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; + setLoading(id); + } + + 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(quality: ImageQuality) { + const config = this.qualityConfigs[quality]; + if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) { + return; + } + this.status.started = true; + } + + onLoad(quality: ImageQuality) { + const config = this.qualityConfigs[quality]; + if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) { + return; + } + + 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) { + const config = this.qualityConfigs[quality]; + if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) { + return; + } + + 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() { + setCanceled(this.id); + 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); + } + } +} 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 }, 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); + }); + } +});