From 78229baeabefa8393bfbb5331b1d1da99b896d6b Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 7 Jan 2026 15:17:12 -0500 Subject: [PATCH] feat: improve asset-viewer next/prev perf and standardize preloading behavior (#24422) Co-authored-by: Alex --- e2e/src/generators.ts | 1 - pnpm-lock.yaml | 10 - web/package.json | 1 - .../asset-viewer/asset-viewer.svelte | 136 ++++++------ .../asset-viewer/photo-viewer.spec.ts | 210 ------------------ .../asset-viewer/photo-viewer.svelte | 98 +++----- .../assets/thumbnail/image-thumbnail.svelte | 4 +- .../memory-page/memory-viewer.svelte | 6 +- .../individual-shared-viewer.svelte | 6 +- .../gallery-viewer/gallery-viewer.svelte | 16 +- .../lib/components/timeline/Timeline.svelte | 10 +- .../timeline/TimelineAssetViewer.svelte | 120 +++++++--- .../duplicates-compare-control.svelte | 9 +- web/src/lib/managers/PreloadManager.svelte.ts | 38 ++++ .../modals/ProfileImageCropperModal.svelte | 2 +- web/src/lib/stores/asset-viewing.store.ts | 10 +- web/src/lib/utils.spec.ts | 137 +++++++++++- web/src/lib/utils.ts | 39 +++- web/src/lib/utils/asset-utils.ts | 8 + web/src/lib/utils/invocationTracker.ts | 9 + web/src/lib/utils/sw-messaging.ts | 10 +- .../[[assetId=id]]/+page.svelte | 60 ++++- .../[[assetId=id]]/+page.svelte | 11 +- .../[[assetId=id]]/+page.svelte | 11 +- 24 files changed, 529 insertions(+), 433 deletions(-) delete mode 100644 web/src/lib/components/asset-viewer/photo-viewer.spec.ts create mode 100644 web/src/lib/managers/PreloadManager.svelte.ts diff --git a/e2e/src/generators.ts b/e2e/src/generators.ts index c87427ceab..5e4895d708 100644 --- a/e2e/src/generators.ts +++ b/e2e/src/generators.ts @@ -26,6 +26,5 @@ export const makeRandomImage = () => { if (!value) { throw new Error('Ran out of random asset data'); } - return value; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91285d6784..5048babbc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -761,9 +761,6 @@ importers: '@zoom-image/svelte': specifier: ^0.3.0 version: 0.3.8(svelte@5.46.1) - async-mutex: - specifier: ^0.5.0 - version: 0.5.0 dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -5620,9 +5617,6 @@ packages: async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} - async-mutex@0.5.0: - resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} - async@0.2.10: resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} @@ -18001,10 +17995,6 @@ snapshots: async-lock@1.4.1: {} - async-mutex@0.5.0: - dependencies: - tslib: 2.8.1 - async@0.2.10: {} async@3.2.6: {} diff --git a/web/package.json b/web/package.json index 52d06bb519..f05fc6dee7 100644 --- a/web/package.json +++ b/web/package.json @@ -39,7 +39,6 @@ "@types/geojson": "^7946.0.16", "@zoom-image/core": "^0.41.0", "@zoom-image/svelte": "^0.3.0", - "async-mutex": "^0.5.0", "dom-to-image": "^2.6.0", "fabric": "^6.5.4", "geo-coordinates-parser": "^1.7.4", diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 03571bbebf..19b9bd3555 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -10,18 +10,19 @@ import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; - import { websocketEvents } from '$lib/stores/websocket'; - import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils'; + import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; import { handleError } from '$lib/utils/handle-error'; + import { InvocationTracker } from '$lib/utils/invocationTracker'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; + import { preloadImageUrl } from '$lib/utils/sw-messaging'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetJobName, @@ -53,17 +54,22 @@ type HasAsset = boolean; + export type AssetCursor = { + current: AssetResponseDto; + nextAsset?: AssetResponseDto; + previousAsset?: AssetResponseDto; + }; + interface Props { - asset: AssetResponseDto; - preloadAssets?: TimelineAsset[]; + cursor: AssetCursor; showNavigation?: boolean; withStacked?: boolean; isShared?: boolean; - album?: AlbumResponseDto | null; - person?: PersonResponseDto | null; - preAction?: PreAction | undefined; - onAction?: OnAction | undefined; - onUndoDelete?: OnUndoDelete | undefined; + album?: AlbumResponseDto; + person?: PersonResponseDto; + preAction?: PreAction; + onAction?: OnAction; + onUndoDelete?: OnUndoDelete; onClose?: (asset: AssetResponseDto) => void; onNext: () => Promise; onPrevious: () => Promise; @@ -72,16 +78,15 @@ } let { - asset = $bindable(), - preloadAssets = $bindable([]), + cursor, showNavigation = true, withStacked = false, isShared = false, - album = null, - person = null, - preAction = undefined, - onAction = undefined, - onUndoDelete = undefined, + album, + person, + preAction, + onAction, + onUndoDelete, onClose, onNext, onPrevious, @@ -100,6 +105,7 @@ const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; + let asset = $derived(cursor.current); let appearsInAlbums: AlbumResponseDto[] = $state([]); let sharedLink = getSharedLink(); let previewStackedAsset: AssetResponseDto | undefined = $state(); @@ -131,7 +137,7 @@ untrack(() => { if (stack && stack?.assets.length > 1) { - preloadAssets.push(toTimelineAsset(stack.assets[1])); + preloadImageUrl(getAssetUrl({ asset: stack.assets[1] })); } }); }; @@ -146,16 +152,8 @@ } }; - const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => { - if (assetUpdate.id === asset.id) { - asset = assetUpdate; - } - }; - onMount(async () => { unsubscribes.push( - websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })), - websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })), slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) { slideshowHistory.reset(); @@ -208,7 +206,9 @@ }); }; - const navigateAsset = async (order?: 'previous' | 'next', e?: Event) => { + const tracker = new InvocationTracker(); + + const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -218,38 +218,37 @@ } e?.stopPropagation(); + preloadManager.cancel(asset); + if (tracker.isActive()) { + return; + } - let hasNext = false; + 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; + 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 onPrevious() : await onNext(); + } + + if ($slideshowState === SlideshowState.PlaySlideshow) { + if (hasNext) { + $restartSlideshowProgress = true; + } else { + await handleStopSlideshow(); } } - } else { - hasNext = order === 'previous' ? await onPrevious() : await onNext(); - } - - if ($slideshowState === SlideshowState.PlaySlideshow) { - if (hasNext) { - $restartSlideshowProgress = true; - } else { - await handleStopSlideshow(); - } - } + }); }; - // const showEditorHandler = () => { - // if (isShowActivity) { - // isShowActivity = false; - // } - // isShowEditor = !isShowEditor; - // }; - const handleRunJob = async (name: AssetJobName) => { try { await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } }); @@ -362,12 +361,6 @@ let isFullScreen = $derived(fullscreenElement !== null); - $effect(() => { - if (asset) { - previewStackedAsset = undefined; - handlePromiseError(refreshStack()); - } - }); $effect(() => { if (album && !album.isActivityEnabled && activityManager.commentCount === 0) { assetViewerManager.closeActivityPanel(); @@ -379,13 +372,24 @@ } }); - // primarily, this is reactive on `asset` - $effect(() => { - handlePromiseError(handleGetAllAlbums()); + const refresh = async () => { + await refreshStack(); + await handleGetAllAlbums(); ocrManager.clear(); if (!sharedLink) { - handlePromiseError(ocrManager.getAssetOcr(asset.id)); + if (previewStackedAsset) { + await ocrManager.getAssetOcr(previewStackedAsset.id); + } + await ocrManager.getAssetOcr(asset.id); } + }; + + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + asset; + untrack(() => handlePromiseError(refresh())); + preloadManager.preload(cursor.nextAsset); + preloadManager.preload(cursor.previousAsset); }); @@ -449,8 +453,7 @@ navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} haveFadeTransition={false} @@ -495,8 +498,7 @@ navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} {sharedLink} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts deleted file mode 100644 index fd1a40e4db..0000000000 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { getAnimateMock } from '$lib/__mocks__/animate.mock'; -import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte'; -import * as utils from '$lib/utils'; -import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk'; -import { assetFactory } from '@test-data/factories/asset-factory'; -import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; -import { render } from '@testing-library/svelte'; -import type { MockInstance } from 'vitest'; - -class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - -globalThis.ResizeObserver = ResizeObserver; - -vi.mock('$lib/utils', async (originalImport) => { - const meta = await originalImport(); - return { - ...meta, - getAssetOriginalUrl: vi.fn(), - getAssetThumbnailUrl: vi.fn(), - }; -}); - -describe('PhotoViewer component', () => { - let getAssetOriginalUrlSpy: MockInstance; - let getAssetThumbnailUrlSpy: MockInstance; - - beforeAll(() => { - getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl'); - getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl'); - - vi.stubGlobal('cast', { - framework: { - CastState: { - NO_DEVICES_AVAILABLE: 'NO_DEVICES_AVAILABLE', - }, - RemotePlayer: vi.fn().mockImplementation(() => ({})), - RemotePlayerEventType: { - ANY_CHANGE: 'anyChanged', - }, - RemotePlayerController: vi.fn().mockImplementation(() => ({ addEventListener: vi.fn() })), - CastContext: { - getInstance: vi.fn().mockImplementation(() => ({ setOptions: vi.fn(), addEventListener: vi.fn() })), - }, - CastContextEventType: { - SESSION_STATE_CHANGED: 'sessionstatechanged', - CAST_STATE_CHANGED: 'caststatechanged', - }, - }, - }); - vi.stubGlobal('chrome', { - cast: { media: { PlayerState: { IDLE: 'IDLE' } }, AutoJoinPolicy: { ORIGIN_SCOPED: 'origin_scoped' } }, - }); - }); - - beforeEach(() => { - Element.prototype.animate = getAnimateMock(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('loads the thumbnail', () => { - const asset = assetFactory.build({ - originalPath: 'image.jpg', - originalMimeType: 'image/jpeg', - type: AssetTypeEnum.Image, - }); - render(PhotoViewer, { asset }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); - - it('loads the thumbnail image for static gifs', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - }); - render(PhotoViewer, { asset }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); - - it('loads the thumbnail image for static webp images', () => { - const asset = assetFactory.build({ - originalPath: 'image.webp', - originalMimeType: 'image/webp', - type: AssetTypeEnum.Image, - }); - render(PhotoViewer, { asset }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); - - it('loads the original image for animated gifs', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - duration: '2.0', - }); - render(PhotoViewer, { asset }); - - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); - }); - - it('loads the original image for animated webp images', () => { - const asset = assetFactory.build({ - originalPath: 'image.webp', - originalMimeType: 'image/webp', - type: AssetTypeEnum.Image, - duration: '2.0', - }); - render(PhotoViewer, { asset }); - - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); - }); - - it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - }); - const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); - render(PhotoViewer, { asset, sharedLink }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); - - it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - duration: '2.0', - }); - const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); - render(PhotoViewer, { asset, sharedLink }); - - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); - }); - - it('not loads original animated image when shared link download permission is false', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - duration: '2.0', - }); - const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] }); - render(PhotoViewer, { asset, sharedLink }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); - - it('not loads original animated image when shared link showMetadata permission is false', () => { - const asset = assetFactory.build({ - originalPath: 'image.gif', - originalMimeType: 'image/gif', - type: AssetTypeEnum.Image, - duration: '2.0', - }); - const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] }); - render(PhotoViewer, { asset, sharedLink }); - - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - - expect(getAssetOriginalUrlSpy).not.toBeCalled(); - }); -}); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 2607f6de79..baf46052be 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -6,32 +6,30 @@ import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import { assetViewerFadeDuration } from '$lib/constants'; import { castManager } from '$lib/managers/cast-manager.svelte'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { boundingBoxesArray } from '$lib/stores/people.store'; - import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; - import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; + import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils'; + import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; - import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; + import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk'; import { LoadingSpinner, toastManager } from '@immich/ui'; import { onDestroy, onMount } from 'svelte'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; + import type { AssetCursor } from './asset-viewer.svelte'; interface Props { - asset: AssetResponseDto; - preloadAssets?: TimelineAsset[] | undefined; + cursor: AssetCursor; element?: HTMLDivElement | undefined; haveFadeTransition?: boolean; sharedLink?: SharedLinkResponseDto | undefined; @@ -42,8 +40,7 @@ } let { - asset, - preloadAssets = undefined, + cursor, element = $bindable(), haveFadeTransition = true, sharedLink = undefined, @@ -54,8 +51,8 @@ }: Props = $props(); const { slideshowState, slideshowLook } = slideshowStore; + const asset = $derived(cursor.current); - let assetFileUrl: string = $state(''); let imageLoaded: boolean = $state(false); let originalImageLoaded: boolean = $state(false); let imageError: boolean = $state(false); @@ -82,25 +79,6 @@ let isOcrActive = $derived(ocrManager.showOverlay); - const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => { - for (const preloadAsset of preloadAssets || []) { - if (preloadAsset.isImage) { - let img = new Image(); - img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash); - } - } - }; - - const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => { - if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { - return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); - } - - return targetSize === 'original' - ? getAssetOriginalUrl({ id, cacheKey }) - : getAssetThumbnailUrl({ id, size: targetSize, cacheKey }); - }; - copyImage = async () => { if (!canCopyImageToClipboard() || !$photoViewerImgElement) { return; @@ -155,23 +133,11 @@ } }; - // when true, will force loading of the original image - let forceUseOriginal: boolean = $derived( - (asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) || - $photoZoomState.currentZoom > 1, - ); - - const targetImageSize = $derived.by(() => { - if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) { - return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize; - } - - return AssetMediaSize.Preview; - }); + const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1)); $effect(() => { - if (assetFileUrl) { - void cast(assetFileUrl); + if (imageLoaderUrl) { + void cast(imageLoaderUrl); } }); @@ -191,7 +157,6 @@ const onload = () => { imageLoaded = true; - assetFileUrl = imageLoaderUrl; originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original'; }; @@ -199,27 +164,29 @@ imageError = imageLoaded = true; }; - $effect(() => { - preload(targetImageSize, preloadAssets); - }); - onMount(() => { - if (loader?.complete) { - onload(); - } - loader?.addEventListener('load', onload, { passive: true }); - loader?.addEventListener('error', onerror, { passive: true }); return () => { - loader?.removeEventListener('load', onload); - loader?.removeEventListener('error', onerror); - cancelImageUrl(imageLoaderUrl); + preloadManager.cancelPreloadUrl(imageLoaderUrl); }; }); - let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash)); + let imageLoaderUrl = $derived( + getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }), + ); let containerWidth = $state(0); let containerHeight = $state(0); + + let lastUrl: string | undefined; + + $effect(() => { + if (lastUrl && lastUrl !== imageLoaderUrl) { + imageLoaded = false; + originalImageLoaded = false; + imageError = false; + } + lastUrl = imageLoaderUrl; + }); {#if imageError} -
+
{/if} - - +
- {#if !imageLoaded}
@@ -258,7 +223,7 @@ > {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; - import { cancelImageUrl } from '$lib/utils/sw-messaging'; + import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { Icon } from '@immich/ui'; import { mdiEyeOffOutline } from '@mdi/js'; import type { ActionReturn } from 'svelte/action'; @@ -60,7 +60,7 @@ onComplete?.(false); } return { - destroy: () => cancelImageUrl(url), + destroy: () => preloadManager.cancelPreloadUrl(url), }; } diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index cfe11e1026..34c6ee18db 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -32,7 +32,7 @@ import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; - import { AssetMediaSize, getAssetInfo } from '@immich/sdk'; + import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk'; import { IconButton, toastManager } from '@immich/ui'; import { mdiCardsOutline, @@ -67,7 +67,7 @@ let currentMemoryAssetFull = $derived.by(async () => current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined, ); - let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []); + let currentTimelineAssets = $derived(current?.memory.assets || []); let isSaved = $derived(current?.memory.isSaved); let viewerHeight = $state(0); @@ -396,7 +396,7 @@

- {#if currentTimelineAssets.some(({ isVideo }) => isVideo)} + {#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
toTimelineAsset(a))); + let assets = $derived(sharedLink.assets); dragAndDropFilesStore.subscribe((value) => { if (value.isDragging && value.files.length > 0) { @@ -68,7 +68,7 @@ }; const handleSelectAll = () => { - assetInteraction.selectAssets(assets); + assetInteraction.selectAssets(assets.map((asset) => toTimelineAsset(asset))); }; const handleAction = async (action: Action) => { @@ -145,7 +145,7 @@ {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} Promise.resolve(false)} onNext={() => Promise.resolve(false)} diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index c695cafc76..f71944d20c 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -13,7 +13,7 @@ import { showDeleteModal } from '$lib/stores/preferences.store'; import { handlePromiseError } from '$lib/utils'; import { deleteAssets } from '$lib/utils/actions'; - import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; + import { archiveAssets, cancelMultiselect, getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; import { moveFocus } from '$lib/utils/focus-util'; import { handleError } from '$lib/utils/handle-error'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; @@ -27,7 +27,7 @@ interface Props { initialAssetId?: string; - assets: TimelineAsset[] | AssetResponseDto[]; + assets: AssetResponseDto[]; assetInteraction: AssetInteraction; disableAssetSelect?: boolean; showArchiveIcon?: boolean; @@ -229,7 +229,7 @@ isShowDeleteConfirmation = false; await deleteAssets( !(isTrashEnabled && !force), - (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]), + (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))), assetInteraction.selectedAssets, onReload, ); @@ -242,7 +242,7 @@ assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive, ); if (ids) { - assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[]; + assets = assets.filter((asset) => !ids.includes(asset.id)); deselectAllAssets(); } }; @@ -424,6 +424,12 @@ selectAssetCandidates(lastAssetMouseEvent); } }); + + const assetCursor = $derived({ + current: $viewingAsset, + nextAsset: getNextAsset(assets, $viewingAsset), + previousAsset: getPreviousAsset(assets, $viewingAsset), + }); {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} void; onEscape?: () => void; @@ -82,9 +82,9 @@ withStacked = false, showArchiveIcon = false, isShared = false, - album = null, + album, albumUsers = [], - person = null, + person, isShowDeleteConfirmation = $bindable(false), onSelect = () => {}, onEscape = () => {}, diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 9f8b5fe36b..29894308b2 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -1,24 +1,29 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} handleNavigateToAsset(assetCursor.previousAsset)} + onNext={() => handleNavigateToAsset(assetCursor.nextAsset)} onRandom={handleRandom} onClose={handleClose} /> diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 2afeebc559..16155d44c0 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -5,6 +5,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; + import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { navigate } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; @@ -102,6 +103,12 @@ const handleStack = () => { onStack(assets); }; + + const assetCursor = $derived({ + current: $viewingAsset, + nextAsset: getNextAsset(assets, $viewingAsset), + previousAsset: getPreviousAsset(assets, $viewingAsset), + }); 1} {onNext} {onPrevious} diff --git a/web/src/lib/managers/PreloadManager.svelte.ts b/web/src/lib/managers/PreloadManager.svelte.ts new file mode 100644 index 0000000000..a68c07d505 --- /dev/null +++ b/web/src/lib/managers/PreloadManager.svelte.ts @@ -0,0 +1,38 @@ +import { getAssetUrl } from '$lib/utils'; +import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging'; +import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; + +class PreloadManager { + preload(asset: AssetResponseDto | undefined) { + if (globalThis.isSecureContext) { + preloadImageUrl(getAssetUrl({ asset })); + return; + } + if (!asset || asset.type !== AssetTypeEnum.Image) { + return; + } + const img = new Image(); + const url = getAssetUrl({ asset }); + if (!url) { + return; + } + img.src = url; + } + + cancel(asset: AssetResponseDto | undefined) { + if (!globalThis.isSecureContext || !asset) { + return; + } + const url = getAssetUrl({ asset }); + cancelImageUrl(url); + } + + cancelPreloadUrl(url: string | undefined) { + if (!globalThis.isSecureContext) { + return; + } + cancelImageUrl(url); + } +} + +export const preloadManager = new PreloadManager(); diff --git a/web/src/lib/modals/ProfileImageCropperModal.svelte b/web/src/lib/modals/ProfileImageCropperModal.svelte index 7f7050f663..f7cc09f0ea 100644 --- a/web/src/lib/modals/ProfileImageCropperModal.svelte +++ b/web/src/lib/modals/ProfileImageCropperModal.svelte @@ -85,7 +85,7 @@
- +
diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index 99ee1b8c46..00e0224a0e 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -1,19 +1,15 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; -import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { type AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; -import { Mutex } from 'async-mutex'; import { readonly, writable } from 'svelte/store'; function createAssetViewingStore() { const viewingAssetStoreState = writable(); - const preloadAssets = writable([]); + const viewState = writable(false); - const viewingAssetMutex = new Mutex(); const gridScrollTarget = writable(); - const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => { - preloadAssets.set(assetsToPreload); + const setAsset = (asset: AssetResponseDto) => { viewingAssetStoreState.set(asset); viewState.set(true); }; @@ -30,8 +26,6 @@ function createAssetViewingStore() { return { asset: readonly(viewingAssetStoreState), - mutex: viewingAssetMutex, - preloadAssets: readonly(preloadAssets), isViewing: viewState, gridScrollTarget, setAsset, diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 169f42409c..3bc8665279 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -1,6 +1,141 @@ -import { getReleaseType } from '$lib/utils'; +import { getAssetUrl, getReleaseType } from '$lib/utils'; +import { AssetTypeEnum } from '@immich/sdk'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; describe('utils', () => { + describe(getAssetUrl.name, () => { + it('should return thumbnail URL for static images', () => { + const asset = assetFactory.build({ + originalPath: 'image.jpg', + originalMimeType: 'image/jpeg', + type: AssetTypeEnum.Image, + }); + + const url = getAssetUrl({ asset }); + + // Should return a thumbnail URL (contains /thumbnail) + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL for static gifs', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + }); + + const url = getAssetUrl({ asset }); + + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL for static webp images', () => { + const asset = assetFactory.build({ + originalPath: 'image.webp', + originalMimeType: 'image/webp', + type: AssetTypeEnum.Image, + }); + + const url = getAssetUrl({ asset }); + + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + + it('should return original URL for animated gifs', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + + const url = getAssetUrl({ asset }); + + // Should return original URL (contains /original) + expect(url).toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return original URL for animated webp images', () => { + const asset = assetFactory.build({ + originalPath: 'image.webp', + originalMimeType: 'image/webp', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + + const url = getAssetUrl({ asset }); + + expect(url).toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL for static images in shared link even with download and showMetadata permissions', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + }); + const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); + + const url = getAssetUrl({ asset, sharedLink }); + + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + + it('should return original URL for animated images in shared link with download and showMetadata permissions', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); + + const url = getAssetUrl({ asset, sharedLink }); + + expect(url).toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL (not original) for animated images when shared link download permission is false', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] }); + + const url = getAssetUrl({ asset, sharedLink }); + + expect(url).toContain('/thumbnail'); + expect(url).not.toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL (not original) for animated images when shared link showMetadata permission is false', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] }); + + const url = getAssetUrl({ asset, sharedLink }); + + expect(url).toContain('/thumbnail'); + expect(url).not.toContain('/original'); + expect(url).toContain(asset.id); + }); + }); + describe(getReleaseType.name, () => { it('should return "major" for major version changes', () => { expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major'); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 5ae025f59c..b89e1a68bb 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,10 +1,12 @@ import { defaultLang, langs, locales } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; -import { lang } from '$lib/stores/preferences.store'; +import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store'; +import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { AssetJobName, AssetMediaSize, + AssetTypeEnum, MemoryType, QueueName, finishOAuth, @@ -17,6 +19,7 @@ import { linkOAuthAccount, startOAuth, unlinkOAuthAccount, + type AssetResponseDto, type MemoryResponseDto, type PersonResponseDto, type ServerVersionResponseDto, @@ -191,6 +194,40 @@ const createUrl = (path: string, parameters?: Record) => { type AssetUrlOptions = { id: string; cacheKey?: string | null }; +export const getAssetUrl = ({ + asset, + sharedLink, + forceOriginal = false, +}: { + asset: AssetResponseDto | undefined; + sharedLink?: SharedLinkResponseDto; + forceOriginal?: boolean; +}) => { + if (!asset) { + return; + } + const id = asset.id; + const cacheKey = asset.thumbhash; + if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { + return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); + } + const targetSize = targetImageSize(asset, forceOriginal); + return targetSize === 'original' + ? getAssetOriginalUrl({ id, cacheKey }) + : getAssetThumbnailUrl({ id, size: targetSize, cacheKey }); +}; + +const forceUseOriginal = (asset: AssetResponseDto) => { + return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000'); +}; + +export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => { + if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) { + return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize; + } + return AssetMediaSize.Preview; +}; + export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => { if (typeof options === 'string') { options = { id: options }; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index aa96d56aec..c0e43f74b5 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -557,6 +557,14 @@ export const delay = async (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; +export const getNextAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => { + return currentAsset && assets[assets.indexOf(currentAsset) + 1]; +}; + +export const getPreviousAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => { + return currentAsset && assets[assets.indexOf(currentAsset) - 1]; +}; + export const canCopyImageToClipboard = (): boolean => { return !!(navigator.clipboard && globalThis.ClipboardItem); }; diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts index ebc97dfde0..7d42d8c613 100644 --- a/web/src/lib/utils/invocationTracker.ts +++ b/web/src/lib/utils/invocationTracker.ts @@ -50,4 +50,13 @@ export class InvocationTracker { isActive() { return this.invocationsStarted !== this.invocationsEnded; } + + async invoke(invocable: () => Promise) { + const invocation = this.startInvocation(); + try { + return await invocable(); + } finally { + invocation.endInvocation(); + } + } } diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 1a19d3c134..61cd1b8df0 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -1,8 +1,14 @@ const broadcast = new BroadcastChannel('immich'); -export function cancelImageUrl(url: string) { +export function cancelImageUrl(url: string | undefined | null) { + if (!url) { + return; + } broadcast.postMessage({ type: 'cancel', url }); } -export function preloadImageUrl(url: string) { +export function preloadImageUrl(url: string | undefined | null) { + if (!url) { + return; + } broadcast.postMessage({ type: 'preload', url }); } diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index fd443a6470..27dc10be57 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,15 +1,18 @@ {#if featureFlagsManager.value.map} @@ -85,7 +141,7 @@ {#if $showAssetViewer} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} onNext={navigateNext} onPrevious={navigatePrevious} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index b58210187b..0cc30c2c0a 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -22,7 +22,7 @@ import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; - import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; + import type { Viewport } from '$lib/managers/timeline-manager/types'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { lang, locale } from '$lib/stores/preferences.store'; @@ -35,6 +35,7 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, + type AssetResponseDto, getPerson, getTagById, type MetadataSearchDto, @@ -58,7 +59,7 @@ let nextPage = $state(1); let searchResultAlbums: AlbumResponseDto[] = $state([]); - let searchResultAssets: TimelineAsset[] = $state([]); + let searchResultAssets: AssetResponseDto[] = $state([]); let isLoading = $state(true); let scrollY = $state(0); let scrollYHistory = 0; @@ -121,7 +122,7 @@ const onAssetDelete = (assetIds: string[]) => { const assetIdSet = new Set(assetIds); - searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id)); + searchResultAssets = searchResultAssets.filter((asset: AssetResponseDto) => !assetIdSet.has(asset.id)); }; const handleSetVisibility = (assetIds: string[]) => { @@ -130,7 +131,7 @@ }; const handleSelectAll = () => { - assetInteraction.selectAssets(searchResultAssets); + assetInteraction.selectAssets(searchResultAssets.map((asset) => toTimelineAsset(asset))); }; async function onSearchQueryUpdate() { @@ -162,7 +163,7 @@ : await searchAssets({ metadataSearchDto: searchDto }); searchResultAlbums.push(...albums.items); - searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset))); + searchResultAssets.push(...assets.items); nextPage = Number(assets.nextPage) || 0; } catch (error) { diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte index 06f075feb6..15f4b233eb 100644 --- a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -5,10 +5,11 @@ import Portal from '$lib/elements/Portal.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; + import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; import { navigate } from '$lib/utils/navigation'; + import type { AssetResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; - import type { AssetResponseDto } from '@immich/sdk'; interface Props { data: PageData; @@ -65,6 +66,12 @@ const onViewAsset = async (asset: AssetResponseDto) => { await navigate({ targetRoute: 'current', assetId: asset.id }); }; + + const assetCursor = $derived({ + current: $viewingAsset, + nextAsset: getNextAsset(assets, $viewingAsset), + previousAsset: getPreviousAsset(assets, $viewingAsset), + }); @@ -85,7 +92,7 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} {onNext} {onPrevious}