From 227baac9383b410baa5ffe46ce31f4c81c2b761e 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 refactor: make InvocationTracker.invoke accept catch/finally callbacks --- .../asset-viewer/asset-viewer.svelte | 151 ++++--- .../asset-viewer/photo-viewer.svelte | 51 ++- .../asset-viewer/swipe-feedback.svelte | 393 ++++++++++++++++++ .../asset-viewer/video-native-viewer.svelte | 227 ++++++---- .../asset-viewer/video-wrapper-viewer.svelte | 26 +- web/src/lib/managers/event-manager.svelte.ts | 3 + web/src/lib/utils/invocationTracker.ts | 11 +- 7 files changed, 682 insertions(+), 180 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/swipe-feedback.svelte diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 06f5dacfde..8122d0ba1b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -41,7 +41,6 @@ } 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'; @@ -193,12 +192,11 @@ let nextPreloader: AdaptiveImageLoader | undefined; let previousPreloader: AdaptiveImageLoader | undefined; - const startPreloader = (asset: AssetResponseDto | undefined) => { if (!asset) { return; } - const loader = new AdaptiveImageLoader(asset, undefined, undefined, loadImage); + const loader = new AdaptiveImageLoader(asset, undefined, { currentZoomFn: () => 1 }, loadImage); loader.start(); return loader; }; @@ -228,72 +226,77 @@ const shouldDestroyPrevious = !movedBackward; const shouldDestroyNext = !movedForward; - if (shouldDestroyPrevious) { - destroyPreviousPreloader(); - } - - if (shouldDestroyNext) { - destroyNextPreloader(); - } - if (movedForward) { + destroyPreviousPreloader(); + destroyNextPreloader(); nextPreloader = startPreloader(newCursor.nextAsset); } else if (movedBackward) { + destroyNextPreloader(); + destroyPreviousPreloader(); previousPreloader = startPreloader(newCursor.previousAsset); } else { - // Non-adjacent navigation (e.g., slideshow random) + if (shouldDestroyPrevious) { + destroyPreviousPreloader(); + } + if (shouldDestroyNext) { + destroyNextPreloader(); + } 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'; - } else { - return; + 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: boolean; + + 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); } - cancelPreloadsBeforeNavigation(order); - - if (tracker.isActive()) { + if ($slideshowState !== SlideshowState.PlaySlideshow) { return; } - void tracker.invoke(async () => { - let hasNext: boolean; + if (hasNext) { + $restartSlideshowProgress = true; + } else if ($slideshowRepeat && slideshowStartAssetId) { + await setAssetId(slideshowStartAssetId); + $restartSlideshowProgress = true; + } else { + await handleStopSlideshow(); + } + }; - 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); - } + const tracker = new InvocationTracker(); + const navigateAsset = (target: 'previous' | 'next' | 'skip') => { + if (target === 'skip' || tracker.isActive()) { + return; + } - 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'), + ); }; /** @@ -512,24 +515,6 @@ assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor, ); - - const onSwipe = (event: SwipeCustomEvent) => { - if (assetViewerManager.zoom > 1) { - return; - } - - if (ocrManager.showOverlay) { - return; - } - - if (event.detail.direction === 'left') { - navigateAsset('previous'); - } - - if (event.detail.direction === 'right') { - navigateAsset('next'); - } - }; @@ -586,26 +571,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} /> @@ -614,17 +599,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 bf2b90c6cc..185038448d 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -4,6 +4,7 @@ import AdaptiveImage from '$lib/components/AdaptiveImage.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'; @@ -20,7 +21,7 @@ import { type SharedLinkResponseDto } from '@immich/sdk'; import { toastManager } from '@immich/ui'; import { onDestroy, untrack } from 'svelte'; - import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; + import { fromAction } from 'svelte/attachments'; import { t } from 'svelte-i18n'; import type { AssetCursor } from './asset-viewer.svelte'; @@ -30,7 +31,7 @@ sharedLink?: SharedLinkResponseDto; onReady?: () => void; onError?: () => void; - onSwipe?: (event: SwipeCustomEvent) => void; + onSwipe?: (direction: 'left' | 'right') => void; } let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props(); @@ -138,6 +139,12 @@ }; let adaptiveImage = $state(); + let swipeFeedbackReset = $state<(() => void) | undefined>(); + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + asset.id; + untrack(() => swipeFeedbackReset?.()); + }); @@ -151,13 +158,17 @@ ]} /> -
onSwipe?.(event))} + disabled={!onSwipe || ocrManager.showOverlay || assetViewerManager.zoom > 1} + disableSwipeLeft={!cursor.nextAsset} + disableSwipeRight={!cursor.previousAsset} + bind:reset={swipeFeedbackReset} + onSwipe={onSwipe ?? (() => {})} + {@attach fromAction(zoomImageAction, () => ({ disabled: isFaceEditMode.value, zoomTarget: adaptiveImage }))} > {/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..9cae46139b --- /dev/null +++ b/web/src/lib/components/asset-viewer/swipe-feedback.svelte @@ -0,0 +1,393 @@ + + + + + +
+ {#if leftPreview} + +
+ {@render leftPreview()} +
+ {/if} + + {#if rightPreview} + +
+ {@render rightPreview()} +
+ {/if} + +
+ {@render children()} +
+
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..276b3cb6ba 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..291c5a14eb 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} (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?.(); } } }