From d13895748acdeb41e9926bae7bb5fd96a5bfddfc Mon Sep 17 00:00:00 2001 From: midzelis Date: Mon, 12 Jan 2026 23:43:41 +0000 Subject: [PATCH] feat: swipe feedback refactor: replace onPreviousAsset/onNextAsset with onSwipe --- e2e/src/web/specs/photo-viewer.e2e-spec.ts | 32 +- web/src/lib/actions/image-loader.svelte.ts | 5 + web/src/lib/actions/zoom-image.ts | 17 +- .../asset-viewer/adaptive-image.svelte | 3 + .../asset-viewer/asset-viewer.svelte | 93 +++-- .../asset-viewer/photo-viewer.svelte | 52 ++- .../asset-viewer/swipe-feedback.svelte | 370 ++++++++++++++++++ .../asset-viewer/video-native-viewer.svelte | 231 +++++++---- .../asset-viewer/video-wrapper-viewer.svelte | 17 +- web/src/lib/managers/ImageManager.svelte.ts | 9 +- .../lib/utils/adaptive-image-loader.svelte.ts | 5 + 11 files changed, 712 insertions(+), 122 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/swipe-feedback.svelte diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index 0918309596..9c2c96be78 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -23,32 +23,44 @@ test.describe('Photo Viewer', () => { test('loads original photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/); - const box = await page.getByTestId('thumbnail').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(page.getByTestId('original')).toBeInViewport(); - await expect(page.getByTestId('original')).toHaveAttribute('src', /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(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/); - const box = await page.getByTestId('thumbnail').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(page.getByTestId('original')).toHaveAttribute('src', /fullsize/); + await expect(original).toHaveAttribute('src', /fullsize/); }); test('reloads photo when checksum changes', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/); - const initialSrc = await page.getByTestId('thumbnail').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(page.getByTestId('preview')).not.toHaveAttribute('src', initialSrc!); + await expect(preview).not.toHaveAttribute('src', initialSrc!); }); }); diff --git a/web/src/lib/actions/image-loader.svelte.ts b/web/src/lib/actions/image-loader.svelte.ts index ef01fcda26..1eed072d75 100644 --- a/web/src/lib/actions/image-loader.svelte.ts +++ b/web/src/lib/actions/image-loader.svelte.ts @@ -156,6 +156,11 @@ export function imageLoader( const handleElementCreated = (img: HTMLImageElement) => { if (img) { node.append(img); + // const a = document.createElement('p'); + + // a.classList.add('absolute', 'h-full', 'w-full', 'top-0'); + // a.textContent = img.src; + // node.append(a); currentCallbacks.onElementCreated?.(img); } }; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 36cce538d1..8741cdb635 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -9,10 +9,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea initialState: state, }); + let isUpdatingFromInstance = false; + let isUpdatingFromStore = false; + const unsubscribes = [ - photoZoomState.subscribe((state) => zoomInstance.setState(state)), + photoZoomState.subscribe((state) => { + if (isUpdatingFromInstance || options?.disabled) { + return; + } + isUpdatingFromStore = true; + zoomInstance.setState(state); + isUpdatingFromStore = false; + }), zoomInstance.subscribe(({ state }) => { + if (isUpdatingFromStore || options?.disabled) { + return; + } + isUpdatingFromInstance = true; photoZoomState.set(state); + isUpdatingFromInstance = false; }), ]; diff --git a/web/src/lib/components/asset-viewer/adaptive-image.svelte b/web/src/lib/components/asset-viewer/adaptive-image.svelte index 1f798b3430..484d6507dc 100644 --- a/web/src/lib/components/asset-viewer/adaptive-image.svelte +++ b/web/src/lib/components/asset-viewer/adaptive-image.svelte @@ -28,11 +28,13 @@ onImageReady?: () => void; onError?: () => void; imgElement?: HTMLImageElement; + imgContainerElement?: HTMLElement; overlays?: Snippet; } let { imgElement = $bindable(), + imgContainerElement = $bindable(), asset, sharedLink, zoomDisabled = false, @@ -125,6 +127,7 @@ style:top={renderDimensions.top} style:width={renderDimensions.width} style:height={renderDimensions.height} + bind:this={imgContainerElement} > {#if asset.thumbhash} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 7d3cea02f9..986dc0e058 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -198,12 +198,30 @@ let nextPreloader: AdaptiveImageLoader | undefined; let previousPreloader: AdaptiveImageLoader | undefined; + let nextPreviewUrl = $state(); + let previousPreviewUrl = $state(); - const startPreloader = (asset: AssetResponseDto | undefined) => { + const setPreviewUrl = (direction: 'next' | 'previous', url: string | undefined) => { + if (direction === 'next') { + nextPreviewUrl = url; + } else { + previousPreviewUrl = url; + } + }; + + const startPreloader = (asset: AssetResponseDto | undefined, direction: 'next' | 'previous') => { if (!asset) { return; } - const loader = new AdaptiveImageLoader(asset, undefined, undefined, loadImage); + const loader = new AdaptiveImageLoader( + asset, + undefined, + { + currentZoomFn: () => 1, + onQualityUpgrade: (url) => setPreviewUrl(direction, url), + }, + loadImage, + ); loader.start(); return loader; }; @@ -211,14 +229,17 @@ const destroyPreviousPreloader = () => { previousPreloader?.destroy(); previousPreloader = undefined; + previousPreviewUrl = undefined; }; const destroyNextPreloader = () => { nextPreloader?.destroy(); nextPreloader = undefined; + nextPreviewUrl = undefined; }; const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => { + setPreviewUrl(direction, undefined); if (direction === 'next') { destroyPreviousPreloader(); return; @@ -233,22 +254,30 @@ const shouldDestroyPrevious = movedForward || !movedBackward; const shouldDestroyNext = movedBackward || !movedForward; - if (shouldDestroyPrevious) { - destroyPreviousPreloader(); - } - - if (shouldDestroyNext) { - destroyNextPreloader(); - } - if (movedForward) { - nextPreloader = startPreloader(newCursor.nextAsset); + // When moving forward: old next becomes current, shift preview URLs + const oldNextUrl = nextPreviewUrl; + destroyPreviousPreloader(); + previousPreviewUrl = oldNextUrl; + destroyNextPreloader(); + nextPreloader = startPreloader(newCursor.nextAsset, 'next'); } else if (movedBackward) { - previousPreloader = startPreloader(newCursor.previousAsset); + // When moving backward: old previous becomes current, shift preview URLs + const oldPreviousUrl = previousPreviewUrl; + destroyNextPreloader(); + nextPreviewUrl = oldPreviousUrl; + destroyPreviousPreloader(); + previousPreloader = startPreloader(newCursor.previousAsset, 'previous'); } else { - // Non-adjacent navigation (e.g., slideshow random) - previousPreloader = startPreloader(newCursor.previousAsset); - nextPreloader = startPreloader(newCursor.nextAsset); + // Non-adjacent navigation (e.g., slideshow random) - clear everything + if (shouldDestroyPrevious) { + destroyPreviousPreloader(); + } + if (shouldDestroyNext) { + destroyNextPreloader(); + } + previousPreloader = startPreloader(newCursor.previousAsset, 'previous'); + nextPreloader = startPreloader(newCursor.nextAsset, 'next'); } }; @@ -441,10 +470,10 @@ if (!lastCursor && cursor) { // "first time" load, start preloads if (cursor.nextAsset) { - nextPreloader = startPreloader(cursor.nextAsset); + nextPreloader = startPreloader(cursor.nextAsset, 'next'); } if (cursor.previousAsset) { - previousPreloader = startPreloader(cursor.previousAsset); + previousPreloader = startPreloader(cursor.previousAsset, 'previous'); } } lastCursor = cursor; @@ -557,15 +586,21 @@
{#if viewerKind === 'StackPhotoViewer'} - + navigateAsset(direction === 'left' ? 'previous' : 'next')} + /> {:else if viewerKind === 'StackVideoViewer'} navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} + onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')} onClose={closeViewer} onVideoEnded={() => navigateAsset()} onVideoStarted={handleVideoStarted} @@ -573,12 +608,13 @@ /> {:else if viewerKind === 'LiveVideoViewer'} navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} + onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')} onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)} {playOriginalVideo} /> @@ -587,15 +623,22 @@ {:else if viewerKind === 'CropArea'} {:else if viewerKind === 'PhotoViewer'} - + navigateAsset(direction === 'left' ? 'next' : 'previous')} + /> {:else if viewerKind === 'VideoViewer'} navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} + onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')} onClose={closeViewer} onVideoEnded={() => navigateAsset()} onVideoStarted={handleVideoStarted} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index b7f48b78f9..31e12a8b7c 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -3,6 +3,7 @@ import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte'; + import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; @@ -17,7 +18,7 @@ import { getBoundingBox } from '$lib/utils/people-utils'; import { type SharedLinkResponseDto } from '@immich/sdk'; import { toastManager } from '@immich/ui'; - import { onDestroy } from 'svelte'; + import { onDestroy, untrack } from 'svelte'; import { t } from 'svelte-i18n'; import type { AssetCursor } from './asset-viewer.svelte'; @@ -26,6 +27,7 @@ element?: HTMLDivElement; sharedLink?: SharedLinkResponseDto; onReady?: () => void; + onSwipe?: (direction: 'left' | 'right') => void; copyImage?: () => Promise; zoomToggle?: () => void; } @@ -35,6 +37,7 @@ element = $bindable(), sharedLink, onReady, + onSwipe, copyImage = $bindable(), zoomToggle = $bindable(), }: Props = $props(); @@ -118,6 +121,15 @@ width: containerWidth, height: containerHeight, }); + let imgContainerElement = $state(); + let swipeFeedbackReset = $state<(() => void) | undefined>(); + + $effect(() => { + // Reset swipe feedback when asset changes + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + asset.id; + untrack(() => swipeFeedbackReset?.()); + }); -
1} + {onSwipe} + bind:reset={swipeFeedbackReset} > onReady?.()} onError={() => onReady?.()} bind:imgElement={$photoViewerImgElement} + bind:imgContainerElement > {#snippet overlays()} @@ -165,4 +181,32 @@ {#if isFaceEditMode.value} {/if} -
+ + {#snippet leftPreview()} + {#if cursor.previousAsset} + + {/if} + {/snippet} + + {#snippet rightPreview()} + {#if cursor.nextAsset} + + {/if} + {/snippet} + diff --git a/web/src/lib/components/asset-viewer/swipe-feedback.svelte b/web/src/lib/components/asset-viewer/swipe-feedback.svelte new file mode 100644 index 0000000000..5f9a653b4c --- /dev/null +++ b/web/src/lib/components/asset-viewer/swipe-feedback.svelte @@ -0,0 +1,370 @@ + + + + + + diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index a25789a76c..ba522e76f6 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -1,5 +1,8 @@ -{#if showVideo} -
- {#if castManager.isCasting} -
- -
- {:else} - + + {#if showVideo} +
+ {#if castManager.isCasting} +
+ +
+ {:else} +
+ - {#if isLoading} -
- + {#if isLoading} +
+ +
+ {/if} + + {#if isFaceEditMode.value} + + {/if}
{/if} - - {#if isFaceEditMode.value} - - {/if} +
+ {/if} + {#snippet leftPreview()} + {#if previousAsset} + {/if} -
-{/if} + {/snippet} + + {#snippet rightPreview()} + {#if nextAsset} + + {/if} + {/snippet} +
+ + diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index 748886d901..bb7b7ea530 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -1,30 +1,34 @@