diff --git a/e2e/src/specs/web/photo-viewer.e2e-spec.ts b/e2e/src/specs/web/photo-viewer.e2e-spec.ts
index 3f9bb4237a..88b61278bc 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,51 @@ 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 preview = page.getByTestId('preview').filter({ visible: true });
+ await expect(preview).toHaveAttribute('src', /.+/);
+
+ 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 preview = page.getByTestId('preview').filter({ visible: true });
+ await expect(preview).toHaveAttribute('src', /.+/);
+
+ 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 preview = page.getByTestId('preview').filter({ visible: true });
+ await expect(preview).toHaveAttribute('src', /.+/);
+ const initialSrc = await preview.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;
+
+ await expect(preview).not.toHaveAttribute('src', initialSrc!);
});
});
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..2b036d3f52 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
@@ -64,7 +64,9 @@ test.describe('broken-asset responsiveness', () => {
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route(
- (url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
+ (url) =>
+ url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
+ url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
async (route) => {
return route.fulfill({ status: 404 });
},
@@ -73,7 +75,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]').first();
await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
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..2b0dddfe8e 100644
--- a/web/src/lib/actions/zoom-image.ts
+++ b/web/src/lib/actions/zoom-image.ts
@@ -2,7 +2,11 @@ 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 });
+ const zoomInstance = createZoomImageWheel(node, {
+ maxZoom: 10,
+ initialState: assetViewerManager.zoomState,
+ zoomTarget: null,
+ });
const unsubscribes = [
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte
new file mode 100644
index 0000000000..fc8f3346b5
--- /dev/null
+++ b/web/src/lib/components/AdaptiveImage.svelte
@@ -0,0 +1,224 @@
+
+
+
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..1dba4e4c7a
--- /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 bg-transparent"
+ {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..38da1dc08d
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts
@@ -0,0 +1,104 @@
+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, SharedLinkResponseDto } 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,
+ sharedlink: SharedLinkResponseDto | undefined,
+ ): AdaptiveImageLoader | undefined {
+ if (!asset) {
+ return;
+ }
+ const urls = getAssetUrls(asset, sharedlink);
+ const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview');
+ const qualityList: QualityList = [
+ {
+ quality: 'thumbnail',
+ url: urls.thumbnail,
+ onAfterLoad: afterThumbnail,
+ onAfterError: afterThumbnail,
+ },
+ {
+ quality: 'preview',
+ url: urls.preview,
+ onAfterError: (loader) => loader.trigger('original'),
+ },
+ { quality: 'original', url: urls.original },
+ ];
+ const loader = new AdaptiveImageLoader(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, sharedlink: SharedLinkResponseDto | undefined) {
+ 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, sharedlink);
+ } else if (movedBackward) {
+ this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink);
+ } else {
+ this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink);
+ this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink);
+ }
+ }
+
+ initializePreloads(cursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) {
+ if (cursor.nextAsset) {
+ this.nextPreloader = this.startPreloader(cursor.nextAsset, sharedlink);
+ }
+ if (cursor.previousAsset) {
+ this.previousPreloader = this.startPreloader(cursor.previousAsset, sharedlink);
+ }
+ }
+
+ 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..46353170e8 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,19 @@
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
- slideshowTransition,
slideshowRepeat,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
- const asset = $derived(cursor.current);
+ let previewStackedAsset: AssetResponseDto | undefined = $state();
+ let stack: StackResponseDto | null = $state(null);
+
+ const asset = $derived(previewStackedAsset ?? 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,7 +116,7 @@
};
const refreshStack = async () => {
- if (authManager.isSharedLink) {
+ if (authManager.isSharedLink || !withStacked) {
return;
}
@@ -126,51 +127,50 @@
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
-
- untrack(() => {
- imageManager.preload(stack?.assets[1]);
- });
};
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 +187,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 +196,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 +222,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 +281,14 @@
}
};
- const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
- previewStackedAsset = isMouseOver ? asset : undefined;
+ const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
+ previewStackedAsset = isMouseOver ? stackedAsset : undefined;
};
+
const handlePreAction = (action: Action) => {
preAction?.(action);
};
+
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.DELETE:
@@ -352,17 +361,31 @@
await ocrManager.getAssetOcr(asset.id);
}
};
+
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
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) {
+ preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
+ }
+ if (!lastCursor) {
+ preloadManager.initializePreloads(cursor, sharedLink);
+ }
+ 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 +426,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 +489,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')} />
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..ef07b2f510 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 @@