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 786f9fd0ec..15c61ce08c 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -2,6 +2,7 @@ import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { focusTrap } from '$lib/actions/focus-trap'; + import { loadImage } from '$lib/actions/image-loader.svelte'; import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; @@ -13,16 +14,17 @@ 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 { Route } from '$lib/route'; 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'; import { user } from '$lib/stores/user.store'; import { getSharedLink, handlePromiseError } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; + import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte'; import { navigateToAsset } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { InvocationTracker } from '$lib/utils/invocationTracker'; @@ -39,6 +41,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'; @@ -95,20 +98,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 = $derived(stack?.assets.find(({ id }) => id === stack?.primaryAssetId)); + 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(); @@ -118,62 +121,61 @@ }; 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; + 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 }); }; 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); + destroyNextPreloader(); + destroyPreviousPreloader(); }); const closeViewer = () => { @@ -189,9 +191,64 @@ assetViewerManager.closeEditor(); }; - const tracker = new InvocationTracker(); + let nextPreloader: AdaptiveImageLoader | undefined; + let previousPreloader: AdaptiveImageLoader | undefined; - const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { + const startPreloader = (asset: AssetResponseDto | undefined) => { + if (!asset) { + return; + } + const loader = new AdaptiveImageLoader(asset, undefined, undefined, loadImage); + loader.start(); + return loader; + }; + + const destroyPreviousPreloader = () => { + previousPreloader?.destroy(); + previousPreloader = undefined; + }; + + const destroyNextPreloader = () => { + nextPreloader?.destroy(); + nextPreloader = undefined; + }; + + const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => { + if (direction === 'next') { + destroyPreviousPreloader(); + return; + } + destroyNextPreloader(); + }; + + const updatePreloadsAfterNavigation = (oldCursor: AssetCursor, newCursor: AssetCursor) => { + const movedForward = newCursor.current.id === oldCursor.nextAsset?.id; + const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id; + + const shouldDestroyPrevious = !movedBackward; + const shouldDestroyNext = !movedForward; + + if (shouldDestroyPrevious) { + destroyPreviousPreloader(); + } + + if (shouldDestroyNext) { + destroyNextPreloader(); + } + + if (movedForward) { + nextPreloader = startPreloader(newCursor.nextAsset); + } else if (movedBackward) { + previousPreloader = startPreloader(newCursor.previousAsset); + } else { + // Non-adjacent navigation (e.g., slideshow random) + previousPreloader = startPreloader(newCursor.previousAsset); + nextPreloader = startPreloader(newCursor.nextAsset); + } + }; + + const tracker = new InvocationTracker(); + const navigateAsset = (order?: 'previous' | 'next') => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -200,8 +257,8 @@ } } - e?.stopPropagation(); - imageManager.cancel(asset); + cancelPreloadsBeforeNavigation(order); + if (tracker.isActive()) { return; } @@ -223,16 +280,18 @@ 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; + } else if ($slideshowRepeat && slideshowStartAssetId) { + // Loop back to starting asset + await setAssetId(slideshowStartAssetId); + $restartSlideshowProgress = true; + } else { + await handleStopSlideshow(); } }, $t('error_while_navigating')); }; @@ -277,12 +336,20 @@ } }; - const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { - previewStackedAsset = isMouseOver ? asset : undefined; + const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => { + if (isMouseOver) { + previewStackedAsset = stackedAsset; + } }; + + const handleStackedAssetsMouseLeave = () => { + previewStackedAsset = undefined; + }; + const handlePreAction = (action: Action) => { preAction?.(action); }; + const handleAction = async (action: Action) => { switch (action.type) { case AssetAction.DELETE: @@ -291,7 +358,7 @@ break; } case AssetAction.REMOVE_ASSET_FROM_STACK: { - stack = action.stack; + stack = action.stack ?? undefined; if (stack) { cursor.current = stack.assets[0]; } @@ -348,19 +415,43 @@ const refresh = async () => { await refreshStack(); ocrManager.clear(); - if (!sharedLink) { - if (previewStackedAsset) { - await ocrManager.getAssetOcr(previewStackedAsset.id); - } - await ocrManager.getAssetOcr(asset.id); + if (sharedLink) { + return; } + + if (previewStackedAsset) { + await ocrManager.getAssetOcr(previewStackedAsset.id); + } + await ocrManager.getAssetOcr(asset.id); }; $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions - asset; + cursor.current; untrack(() => handlePromiseError(refresh())); - imageManager.preload(cursor.nextAsset); - imageManager.preload(cursor.previousAsset); + }); + + let lastCursor = $state(); + + $effect(() => { + if (cursor.current.id === lastCursor?.current.id) { + return; + } + if (lastCursor) { + selectedStackAsset = undefined; + previewStackedAsset = undefined; + // After navigation completes, reconcile preloads with full state information + updatePreloadsAfterNavigation(lastCursor, cursor); + } + if (!lastCursor) { + // "first time" load, start preloads + if (cursor.nextAsset) { + nextPreloader = startPreloader(cursor.nextAsset); + } + if (cursor.previousAsset) { + previousPreloader = startPreloader(cursor.previousAsset); + } + } + lastCursor = cursor; }); const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => { @@ -380,7 +471,7 @@ const viewerKind = $derived.by(() => { if (previewStackedAsset) { - return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; + return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; } if (asset.type === AssetTypeEnum.Video) { return 'VideoViewer'; @@ -421,6 +512,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'); + } + }; @@ -467,23 +576,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')} />
@@ -582,10 +677,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 69a6f0f103..b809d996b7 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 @@ @@ -207,47 +162,41 @@ { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, ]} /> -{#if imageError} -
- -
-{/if} - +
onSwipe?.(event))} > - {#if !imageLoaded} -
- -
- {:else if !imageError} -
- {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} - + { + visibleImageReady = true; + onReady?.(); + }} + onError={() => { + onError?.(); + onReady?.(); + }} + bind:imgRef={assetViewerManager.imgRef} + bind:ref={adaptiveImage} + > + {#snippet backdrop()} + {#if blurredSlideshow} + {/if} - (visibleImageReady = true)} - alt={$getAltText(toTimelineAsset(asset))} - class="h-full w-full {$slideshowState === SlideshowState.None - ? 'object-contain' - : slideshowLookCssMapping[$slideshowLook]}" - draggable="false" - /> + {/snippet} + {#snippet overlays()} {#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox (boundingbox.id)}
{/each} -
+ {/snippet} +
- {#if isFaceEditMode.value} - - {/if} + {#if isFaceEditMode.value && assetViewerManager.imgRef} + {/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..5c14a3f835 --- /dev/null +++ b/web/src/lib/utils/adaptive-image-loader.svelte.ts @@ -0,0 +1,297 @@ +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; + } + imageManager.cancel(this.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); + }); + } +});