diff --git a/e2e/src/specs/web/photo-viewer.e2e-spec.ts b/e2e/src/specs/web/photo-viewer.e2e-spec.ts
index 3f9bb4237a188..0918309596a07 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,32 @@ 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();
+ await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
+ const box = await page.getByTestId('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(page.getByTestId('original')).toBeInViewport();
+ await expect(page.getByTestId('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();
+ await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
+ const box = await page.getByTestId('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(page.getByTestId('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');
+ await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
+ const initialSrc = await page.getByTestId('thumbnail').getAttribute('src');
await utils.replaceAsset(admin.accessToken, asset.id);
- await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
+ await expect(page.getByTestId('preview')).not.toHaveAttribute('src', initialSrc!);
});
});
diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte
new file mode 100644
index 0000000000000..920e8e5e47dbd
--- /dev/null
+++ b/web/src/lib/components/AdaptiveImage.svelte
@@ -0,0 +1,253 @@
+
+
+
+
+ {#if blurredSlideshow}
+
+ {/if}
+
+
+
+ {#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/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts
index 19cc5afa8d540..761887a465d61 100644
--- a/web/src/lib/components/asset-viewer/actions/action.ts
+++ b/web/src/lib/components/asset-viewer/actions/action.ts
@@ -14,7 +14,7 @@ type ActionMap = {
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
- [AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
+ [AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | undefined; asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index 848870b654e2b..7666168d8ffe5 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -1,6 +1,7 @@
@@ -477,7 +568,7 @@
{/if}
- {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
+ {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
navigateAsset('previous')} />
@@ -485,15 +576,7 @@
- {#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')} />
@@ -577,7 +654,7 @@
class="row-start-1 row-span-4 w-90 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes"
>
-
+
{/if}
@@ -595,10 +672,14 @@
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}