From d0fb25eba9e312c12e17c9b0bac73cffb08327b7 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 +- .../asset-viewer/adaptive-image.svelte | 6 +- .../asset-viewer/asset-viewer.svelte | 181 ++++---- .../asset-viewer/photo-viewer.svelte | 52 ++- .../asset-viewer/swipe-feedback.svelte | 400 ++++++++++++++++++ .../asset-viewer/video-native-viewer.svelte | 235 ++++++---- .../asset-viewer/video-wrapper-viewer.svelte | 26 +- web/src/lib/managers/ImageManager.svelte.ts | 10 +- web/src/lib/managers/event-manager.svelte.ts | 3 + .../lib/utils/adaptive-image-loader.svelte.ts | 6 + web/src/lib/utils/invocationTracker.ts | 11 +- 11 files changed, 772 insertions(+), 190 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/components/asset-viewer/adaptive-image.svelte b/web/src/lib/components/asset-viewer/adaptive-image.svelte index c20c2cf633..56eea2a4c7 100644 --- a/web/src/lib/components/asset-viewer/adaptive-image.svelte +++ b/web/src/lib/components/asset-viewer/adaptive-image.svelte @@ -163,7 +163,7 @@ onStart: () => adaptiveImageLoader.onThumbnailStart(), onLoad: () => adaptiveImageLoader.onThumbnailLoad(), onError: () => adaptiveImageLoader.onThumbnailError(), - onElementCreated: (el) => (thumbnailElement = el), + onElementCreated: (element) => (thumbnailElement = element), imgClass: ['absolute h-full', 'w-full'], alt: '', role: 'presentation', @@ -200,7 +200,7 @@ onStart: () => adaptiveImageLoader.onPreviewStart(), onLoad: () => adaptiveImageLoader.onPreviewLoad(), onError: () => adaptiveImageLoader.onPreviewError(), - onElementCreated: (el) => (previewElement = el), + onElementCreated: (element) => (previewElement = element), imgClass: ['h-full', 'w-full', imageClass], alt: imageAltText, draggable: false, @@ -223,7 +223,7 @@ onStart: () => adaptiveImageLoader.onOriginalStart(), onLoad: () => adaptiveImageLoader.onOriginalLoad(), onError: () => adaptiveImageLoader.onOriginalError(), - onElementCreated: (el) => (originalElement = el), + onElementCreated: (element) => (originalElement = element), imgClass: ['h-full', 'w-full', imageClass], alt: imageAltText, draggable: false, diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 6ef428ff01..d118fdcaa0 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -188,12 +188,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; }; @@ -201,14 +219,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; @@ -223,72 +244,84 @@ 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'); + } + }; + + const getNavigationTarget = () => { + if ($slideshowState === SlideshowState.PlaySlideshow) { + return $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; + } else { + return 'skip'; + } + }; + + const completeNavigation = async (target: 'previous' | 'next') => { + cancelPreloadsBeforeNavigation(target); + let hasNext = false; + + if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { + hasNext = target === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); + if (!hasNext) { + const asset = await onRandom?.(); + if (asset) { + slideshowHistory.queue(asset); + hasNext = true; + } + } + } else { + hasNext = + target === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); + } + + if ($slideshowState !== SlideshowState.PlaySlideshow) { + return; + } + + if (hasNext) { + $restartSlideshowProgress = true; + } else if ($slideshowRepeat && slideshowStartAssetId) { + await setAssetId(slideshowStartAssetId); + $restartSlideshowProgress = true; + } else { + await handleStopSlideshow(); } }; const tracker = new InvocationTracker(); - const navigateAsset = (order?: 'previous' | 'next') => { - if (!order) { - if ($slideshowState === SlideshowState.PlaySlideshow) { - order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; - } else { - return; - } - } - - cancelPreloadsBeforeNavigation(order); - - if (tracker.isActive()) { + const navigateAsset = (target: 'previous' | 'next' | 'skip') => { + if (target === 'skip' || tracker.isActive()) { return; } - void tracker.invoke(async () => { - let hasNext = false; - - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { - hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); - if (!hasNext) { - const asset = await onRandom?.(); - if (asset) { - slideshowHistory.queue(asset); - hasNext = true; - } - } - } else { - hasNext = - order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); - } - - 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')); + void tracker.invoke( + () => completeNavigation(target), + (error: unknown) => handleError(error, $t('error_while_navigating')), + () => eventManager.emit('ViewerFinishNavigate'), + ); }; /** @@ -439,10 +472,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; @@ -554,26 +587,26 @@
{#if viewerKind === 'StackVideoViewer'} navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} + onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')} onClose={closeViewer} - onVideoEnded={() => navigateAsset()} + onVideoEnded={() => navigateAsset(getNavigationTarget())} onVideoStarted={handleVideoStarted} {playOriginalVideo} /> {:else if viewerKind === 'LiveVideoViewer'} navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} + onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')} onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)} {playOriginalVideo} /> @@ -582,17 +615,21 @@ {: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()} + onVideoEnded={() => navigateAsset(getNavigationTarget())} onVideoStarted={handleVideoStarted} {playOriginalVideo} /> diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 96fe5b09c3..294f10100f 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 AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.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'; @@ -27,9 +28,10 @@ sharedLink?: SharedLinkResponseDto; onReady?: () => void; onError?: () => void; + onSwipe?: (direction: 'left' | 'right') => void; } - let { cursor, element = $bindable(), sharedLink, onReady, onError }: Props = $props(); + let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props(); const { slideshowState, slideshowLook } = slideshowStore; const asset = $derived(cursor.current); @@ -109,6 +111,13 @@ width: containerWidth, height: containerHeight, }); + 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?.()); + }); @@ -122,11 +131,16 @@ ]} /> -
1} + disableSwipeLeft={!cursor.nextAsset} + disableSwipeRight={!cursor.previousAsset} + bind:reset={swipeFeedbackReset} + {onSwipe} > {/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..eb6a09e18e --- /dev/null +++ b/web/src/lib/components/asset-viewer/swipe-feedback.svelte @@ -0,0 +1,400 @@ + + + + + + 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 78fdc3a1ba..76e192b9a4 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 57d8acd78a..590b1724f9 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -1,50 +1,50 @@ {#if projectionType === ProjectionType.EQUIRECTANGULAR} - + {:else} number; + private readonly imageLoader?: LoadImageFunction; private readonly destroyFunctions: (() => void)[] = []; readonly thumbnailUrl: string; @@ -56,6 +57,7 @@ export class AdaptiveImageLoader { currentZoomFn: () => number; onImageReady?: () => void; onError?: () => void; + onQualityUpgrade?: (url: string, quality: ImageQuality) => void; }; destroyed = false; @@ -66,12 +68,14 @@ export class AdaptiveImageLoader { currentZoomFn: () => number; onImageReady?: () => void; onError?: () => void; + onQualityUpgrade?: (url: string, quality: ImageQuality) => 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 }); @@ -112,6 +116,7 @@ export class AdaptiveImageLoader { this.state.quality = 'thumbnail'; this.state.thumbnailImage = ImageStatus.Success; this.callbacks?.onImageReady?.(); + this.callbacks?.onQualityUpgrade?.(this.thumbnailUrl, 'thumbnail'); this.triggerMainImage(); } @@ -166,6 +171,7 @@ export class AdaptiveImageLoader { this.state.quality = 'preview'; this.state.previewImage = ImageStatus.Success; this.callbacks?.onImageReady?.(); + this.callbacks?.onQualityUpgrade?.(this.previewUrl, 'preview'); } onPreviewError() { diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts index 88c395a4ad..b5ed3ac7c7 100644 --- a/web/src/lib/utils/invocationTracker.ts +++ b/web/src/lib/utils/invocationTracker.ts @@ -1,5 +1,3 @@ -import { handleError } from '$lib/utils/handle-error'; - /** * Tracks the state of asynchronous invocations to handle race conditions and stale operations. * This class helps manage concurrent operations by tracking which invocations are active @@ -53,14 +51,19 @@ export class InvocationTracker { return this.invocationsStarted !== this.invocationsEnded; } - async invoke(invocable: () => Promise, localizedMessage: string) { + async invoke(invocable: () => Promise, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) { const invocation = this.startInvocation(); try { return await invocable(); } catch (error: unknown) { - handleError(error, localizedMessage); + if (catchCallback) { + catchCallback(error); + } else { + console.error(error); + } } finally { invocation.endInvocation(); + finallyCallback?.(); } } }