From ec48504238fb1da0ebcfde71f0c7b4d3a1fb9a8a Mon Sep 17 00:00:00 2001 From: midzelis Date: Tue, 9 Dec 2025 19:04:41 +0000 Subject: [PATCH] refactor: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte, increase performance esp. on low BW conn --- web/src/lib/actions/thumbhash.ts | 18 +- web/src/lib/actions/zoom-image.ts | 28 +-- .../asset-viewer/adaptive-image.svelte | 163 +++++++++++++ .../asset-viewer/asset-viewer.svelte | 104 ++++---- .../asset-viewer/detail-panel.svelte | 39 ++- .../photo-sphere-viewer-adapter.svelte | 2 +- .../asset-viewer/photo-viewer.svelte | 229 +++++------------- .../assets/thumbnail/image-thumbnail.svelte | 4 +- .../timeline/TimelineAssetViewer.svelte | 13 +- web/src/lib/managers/ImageManager.svelte.ts | 170 +++++++++++++ web/src/lib/managers/PreloadManager.svelte.ts | 38 --- web/src/lib/managers/event-manager.svelte.ts | 2 +- web/src/lib/stores/assets-store.svelte.ts | 2 +- web/src/lib/stores/zoom-image.store.ts | 18 +- .../lib/utils/adaptive-image-loader.svelte.ts | 161 ++++++++++++ web/src/lib/utils/handle-error.ts | 18 +- web/src/lib/utils/invocationTracker.ts | 4 + web/src/lib/utils/layout-utils.ts | 16 ++ web/src/lib/utils/people-utils.ts | 4 +- web/src/service-worker/cache.ts | 2 +- web/src/service-worker/request.ts | 2 +- 21 files changed, 738 insertions(+), 299 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/adaptive-image.svelte create mode 100644 web/src/lib/managers/ImageManager.svelte.ts delete mode 100644 web/src/lib/managers/PreloadManager.svelte.ts create mode 100644 web/src/lib/utils/adaptive-image-loader.svelte.ts diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts index e49f04dbee..5c834d0544 100644 --- a/web/src/lib/actions/thumbhash.ts +++ b/web/src/lib/actions/thumbhash.ts @@ -3,17 +3,25 @@ import { thumbHashToRGBA } from 'thumbhash'; /** * Renders a thumbnail onto a canvas from a base64 encoded hash. - * @param canvas - * @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString) */ -export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) { +export function thumbhash(canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) { + render(canvas, options); + + return { + update(newOptions: { base64ThumbHash: string }) { + render(canvas, newOptions); + }, + }; +} + +const render = (canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) => { const ctx = canvas.getContext('2d'); if (ctx) { - const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash)); + const { w, h, rgba } = thumbHashToRGBA(decodeBase64(options.base64ThumbHash)); const pixels = ctx.createImageData(w, h); canvas.width = w; canvas.height = h; pixels.data.set(rgba); ctx.putImageData(pixels, 0, 0); } -} +}; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index e67d3e1928..f39cefbbff 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -1,20 +1,21 @@ import { photoZoomState } from '$lib/stores/zoom-image.store'; -import { useZoomImageWheel } from '@zoom-image/svelte'; +import { createZoomImageWheel } from '@zoom-image/core'; import { get } from 'svelte/store'; export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { - const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel(); - - createZoomImage(node, { + const state = get(photoZoomState); + const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, + initialState: state, }); - const state = get(photoZoomState); - if (state) { - setZoomImageState(state); - } + const unsubscribes = [ + photoZoomState.subscribe((state) => zoomInstance.setState(state)), + zoomInstance.subscribe(({ state }) => { + photoZoomState.set(state); + }), + ]; - // Store original event handlers so we can prevent them when disabled const wheelHandler = (event: WheelEvent) => { if (options?.disabled) { event.stopImmediatePropagation(); @@ -27,22 +28,21 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea } }; - // Add handlers at capture phase with higher priority node.addEventListener('wheel', wheelHandler, { capture: true }); node.addEventListener('pointerdown', pointerDownHandler, { capture: true }); - const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)]; - + node.style.overflow = 'visible'; return { update(newOptions?: { disabled?: boolean }) { options = newOptions; }, destroy() { - node.removeEventListener('wheel', wheelHandler, { capture: true }); - node.removeEventListener('pointerdown', pointerDownHandler, { capture: true }); for (const unsubscribe of unsubscribes) { unsubscribe(); } + node.removeEventListener('wheel', wheelHandler, { capture: true }); + node.removeEventListener('pointerdown', pointerDownHandler, { capture: true }); + zoomInstance.cleanup(); }, }; }; diff --git a/web/src/lib/components/asset-viewer/adaptive-image.svelte b/web/src/lib/components/asset-viewer/adaptive-image.svelte new file mode 100644 index 0000000000..23b6ec7f2d --- /dev/null +++ b/web/src/lib/components/asset-viewer/adaptive-image.svelte @@ -0,0 +1,163 @@ + + +
+ {#if asset.thumbhash} + + {@const thumbKey = loadState.thumbnailUrl + loadState.thumbOpacity} +
+ + {#key thumbKey} + + {/key} +
+ {:else if showSpinner} +
+ +
+ {/if} + + {#if showBrokenAsset} +
+ +
+ {:else} + + {#if loadState.currentUrl && slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground} + + {/if} + + {#key asset.id} +
+ {imageAltText} + + {@render overlays?.()} +
+ {/key} + {/if} +
+ + diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index d280a6294b..901e6ffd0b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -12,7 +12,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { preloadManager } from '$lib/managers/PreloadManager.svelte'; + import { imageManager } from '$lib/managers/ImageManager.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; @@ -29,7 +29,6 @@ import { AssetJobName, AssetTypeEnum, - getAllAlbums, getAssetInfo, getStack, runAssetJobs, @@ -106,12 +105,11 @@ const asset = $derived(cursor.current); let nextAsset = $derived(cursor.nextAsset); let previousAsset = $derived(cursor.previousAsset); - let appearsInAlbums: AlbumResponseDto[] = $state([]); + let sharedLink = getSharedLink(); let previewStackedAsset: AssetResponseDto | undefined = $state(); let isShowEditor = $state(false); let fullscreenElement = $state(); - let unsubscribes: (() => void)[] = []; let stack: StackResponseDto | null = $state(null); let zoomToggle = $state(() => void 0); @@ -151,59 +149,43 @@ } }; - onMount(async () => { - unsubscribes.push( - slideshowState.subscribe((value) => { - if (value === SlideshowState.PlaySlideshow) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - handlePromiseError(handlePlaySlideshow()); - } else if (value === SlideshowState.StopSlideshow) { - handlePromiseError(handleStopSlideshow()); - } - }), - slideshowNavigation.subscribe((value) => { - if (value === SlideshowNavigation.Shuffle) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - } - }), - ); + onMount(() => { + const slideshowStateUnsubscribe = slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + handlePromiseError(handlePlaySlideshow()); + } else if (value === SlideshowState.StopSlideshow) { + handlePromiseError(handleStopSlideshow()); + } + }); - if (!sharedLink) { - await handleGetAllAlbums(); - } + const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => { + if (value === SlideshowNavigation.Shuffle) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + } + }); + + return () => { + slideshowStateUnsubscribe(); + slideshowNavigationUnsubscribe(); + }; }); onDestroy(() => { - for (const unsubscribe of unsubscribes) { - unsubscribe(); - } - activityManager.reset(); + imageManager.cancel(cursor.nextAsset); + imageManager.cancel(cursor.previousAsset); }); - const handleGetAllAlbums = async () => { - if (authManager.isSharedLink) { - return; - } - - try { - appearsInAlbums = await getAllAlbums({ assetId: asset.id }); - } catch (error) { - console.error('Error getting album that asset belong to', error); - } - }; - const closeViewer = () => { onClose?.(asset); }; const closeEditor = async () => { if (editManager.hasAppliedEdits) { - console.log(asset); const refreshedAsset = await getAssetInfo({ id: asset.id }); - console.log(refreshedAsset); onAssetChange?.(refreshedAsset); assetViewingStore.setAsset(refreshedAsset); } @@ -212,7 +194,7 @@ const tracker = new InvocationTracker(); - const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { + const navigateAsset = (order?: 'previous' | 'next') => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -221,8 +203,7 @@ } } - e?.stopPropagation(); - preloadManager.cancel(asset); + imageManager.cancel(asset); if (tracker.isActive()) { return; } @@ -318,7 +299,7 @@ const handleAction = async (action: Action) => { switch (action.type) { case AssetAction.ADD_TO_ALBUM: { - await handleGetAllAlbums(); + eventManager.emit('AlbumAddAssets'); break; } case AssetAction.REMOVE_ASSET_FROM_STACK: { @@ -373,7 +354,6 @@ const refresh = async () => { await refreshStack(); - await handleGetAllAlbums(); ocrManager.clear(); if (!sharedLink) { if (previewStackedAsset) { @@ -386,8 +366,19 @@ // eslint-disable-next-line @typescript-eslint/no-unused-expressions asset; untrack(() => handlePromiseError(refresh())); - preloadManager.preload(cursor.nextAsset); - preloadManager.preload(cursor.previousAsset); + }); + + let lastCursor = $state(); + + $effect(() => { + if (cursor !== lastCursor) { + imageManager.cancel(lastCursor?.current); + imageManager.cancel(lastCursor?.nextAsset); + imageManager.cancel(lastCursor?.previousAsset); + imageManager.preload(cursor.nextAsset); + imageManager.preload(cursor.previousAsset); + lastCursor = cursor; + } }); const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => { @@ -409,7 +400,7 @@ // eslint-disable-next-line @typescript-eslint/no-unused-expressions asset.id; if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') { - eventManager.emit('AssetViewerFree'); + eventManager.emit('AssetViewerReady'); } }); @@ -494,10 +485,8 @@ bind:zoomToggle bind:copyImage cursor={{ ...cursor, current: previewStackedAsset! }} - onPreviousAsset={() => navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - haveFadeTransition={false} {sharedLink} + onReady={() => eventManager.emit('AssetViewerReady')} /> {:else if viewerKind === 'StackVideoViewer'} navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} {sharedLink} - haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} - onFree={() => eventManager.emit('AssetViewerFree')} + onReady={() => eventManager.emit('AssetViewerReady')} /> {:else if viewerKind === 'VideoViewer'} - + {/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 0328025594..156f987130 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -8,6 +8,7 @@ import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import { eventManager } from '$lib/managers/event-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; @@ -17,10 +18,17 @@ import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { delay, getDimensions } from '$lib/utils/asset-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; + import { handleError } from '$lib/utils/handle-error'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { getParentPath } from '$lib/utils/tree-utils'; - import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; + import { + AssetMediaSize, + getAllAlbums, + getAssetInfo, + type AlbumResponseDto, + type AssetResponseDto, + } from '@immich/sdk'; import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui'; import { mdiCalendar, @@ -35,6 +43,7 @@ mdiPlus, } from '@mdi/js'; import { DateTime } from 'luxon'; + import { onDestroy, untrack } from 'svelte'; import { t } from 'svelte-i18n'; import { slide } from 'svelte/transition'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; @@ -44,11 +53,10 @@ interface Props { asset: AssetResponseDto; - albums?: AlbumResponseDto[]; currentAlbum?: AlbumResponseDto | null; } - let { asset, albums = [], currentAlbum = null }: Props = $props(); + let { asset, currentAlbum = null }: Props = $props(); let showAssetPath = $state(false); let showEditFaces = $state(false); @@ -74,6 +82,31 @@ ); let previousId: string | undefined = $state(); + let albums = $state([]); + + const refreshAlbums = async () => { + if (authManager.isSharedLink) { + return; + } + + try { + albums = await getAllAlbums({ assetId: asset.id }); + } catch (error) { + handleError(error, 'Error getting asset album membership'); + } + }; + + eventManager.on('AlbumAddAssets', () => void refreshAlbums()); + onDestroy(() => { + eventManager.off('AlbumAddAssets', () => void refreshAlbums()); + }); + + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + asset; + untrack(() => void refreshAlbums()); + }); + $effect(() => { if (!previousId) { previousId = asset.id; diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 399c4fb7de..e329be09b5 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -111,7 +111,7 @@ viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 }); }; - const handleReady = () => eventManager.emit('AssetViewerFree'); + const handleReady = () => eventManager.emit('AssetViewerReady'); let hasChangedResolution: boolean = false; onMount(() => { diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 1631faaa23..d0b98477a0 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,59 +1,41 @@ @@ -219,74 +152,44 @@ { shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false }, ]} /> -{#if imageError} -
- -
-{/if} - +
- {#if !imageLoaded} -
- -
- {:else if !imageError} -
+ onReady?.()} + onError={() => onReady?.()} + bind:imgElement={$photoViewerImgElement} > - {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} - - {/if} - {$getAltText(toTimelineAsset(asset))} - - {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox} -
- {/each} + {#snippet overlays()} + + {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox} +
+ {/each} - {#each ocrBoxes as ocrBox (ocrBox.id)} - - {/each} -
+ {#each ocrBoxes as ocrBox (ocrBox.id)} + + {/each} + {/snippet} + {#if isFaceEditMode.value} {/if} - {/if} +
- - diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 740cf784b7..a1dd22f44f 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,6 +1,6 @@