From 3c07fe0116e4e0d473f4de88fcd6b9b5b1825923 Mon Sep 17 00:00:00 2001 From: midzelis Date: Mon, 12 Jan 2026 23:43:41 +0000 Subject: [PATCH] feat: swipe feedback --- 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 | 79 +++- .../asset-viewer/photo-viewer.svelte | 52 ++- .../asset-viewer/swipe-feedback.svelte | 356 ++++++++++++++++++ web/src/lib/managers/ImageManager.svelte.ts | 9 +- .../lib/utils/adaptive-image-loader.svelte.ts | 5 + 8 files changed, 498 insertions(+), 28 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/swipe-feedback.svelte 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 23515d1163..8a879596ac 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -201,12 +201,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; }; @@ -214,14 +232,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; @@ -236,22 +257,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'); } }; @@ -448,10 +477,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; @@ -565,7 +594,13 @@
{#if viewerKind === 'StackPhotoViewer'} - + navigateAsset(direction === 'left' ? 'previous' : 'next')} + /> {:else if viewerKind === 'StackVideoViewer'} {:else if viewerKind === 'PhotoViewer'} - + navigateAsset(direction === 'left' ? 'next' : 'previous')} + /> {:else if viewerKind === 'VideoViewer'} 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..0f4aae80b7 --- /dev/null +++ b/web/src/lib/components/asset-viewer/swipe-feedback.svelte @@ -0,0 +1,356 @@ + + + + + + diff --git a/web/src/lib/managers/ImageManager.svelte.ts b/web/src/lib/managers/ImageManager.svelte.ts index 8503804172..83c5874fbf 100644 --- a/web/src/lib/managers/ImageManager.svelte.ts +++ b/web/src/lib/managers/ImageManager.svelte.ts @@ -7,14 +7,15 @@ class ImageManager { if (!asset) { return; } + this.preloadImageUrl(getAssetUrlForKind(asset, kind)); + } - const url = getAssetUrlForKind(asset, kind); - if (!url) { + preloadImageUrl(src: string | undefined) { + if (!src) { return; } - const img = new Image(); - img.src = url; + img.src = src; } cancel(asset: AssetResponseDto | undefined, kind: ImageKind | 'all' = 'preview') { diff --git a/web/src/lib/utils/adaptive-image-loader.svelte.ts b/web/src/lib/utils/adaptive-image-loader.svelte.ts index a9f2bb2e9b..d65fd3714d 100644 --- a/web/src/lib/utils/adaptive-image-loader.svelte.ts +++ b/web/src/lib/utils/adaptive-image-loader.svelte.ts @@ -48,6 +48,7 @@ export class AdaptiveImageLoader { private readonly currentZoomFn?: () => number; private readonly onImageReady?: () => void; private readonly onError?: () => void; + private readonly onQualityUpgrade?: (url: string, quality: ImageQuality) => void; private readonly imageLoader?: LoadImageFunction; private readonly destroyFunctions: (() => void)[] = []; readonly thumbnailUrl: string; @@ -61,6 +62,7 @@ export class AdaptiveImageLoader { currentZoomFn: () => number; onImageReady?: () => void; onError?: () => void; + onQualityUpgrade?: (url: string, quality: ImageQuality) => void; }, imageLoader?: LoadImageFunction, ) { @@ -68,6 +70,7 @@ export class AdaptiveImageLoader { this.currentZoomFn = callbacks?.currentZoomFn; this.onImageReady = callbacks?.onImageReady; this.onError = callbacks?.onError; + this.onQualityUpgrade = callbacks?.onQualityUpgrade; this.imageLoader = imageLoader; this.thumbnailUrl = getAssetUrlForKind(asset, 'thumbnail'); @@ -103,6 +106,7 @@ export class AdaptiveImageLoader { this.state.quality = 'thumbnail'; this.state.thumbnailImage = ImageStatus.Success; this.onImageReady?.(); + this.onQualityUpgrade?.(this.thumbnailUrl, 'thumbnail'); this.triggerMainImage(); } @@ -145,6 +149,7 @@ export class AdaptiveImageLoader { this.state.quality = 'preview'; this.state.previewImage = ImageStatus.Success; this.onImageReady?.(); + this.onQualityUpgrade?.(this.previewUrl, 'preview'); } onPreviewError() {