diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index b657f34ece..a2a36bf188 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -10,7 +10,8 @@ import { AppRoute, AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import { eventManager } from '$lib/managers/event-manager.svelte'; + 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'; @@ -18,9 +19,11 @@ 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 { 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, @@ -50,11 +53,10 @@ import SlideshowBar from './slideshow-bar.svelte'; import VideoViewer from './video-wrapper-viewer.svelte'; - type HasAsset = boolean; - interface Props { asset: AssetResponseDto; - preloadAssets?: TimelineAsset[]; + nextAsset?: AssetResponseDto; + previousAsset?: AssetResponseDto; showNavigation?: boolean; withStacked?: boolean; isShared?: boolean; @@ -63,16 +65,16 @@ preAction?: PreAction | undefined; onAction?: OnAction | undefined; showCloseButton?: boolean; - onClose: (asset: AssetResponseDto) => void; - onNext: () => Promise; - onPrevious: () => Promise; - onRandom: () => Promise<{ id: string } | undefined>; + onClose?: (asset: AssetResponseDto) => void; + onNavigateToAsset?: (asset: AssetResponseDto | undefined) => Promise; + onRandom?: () => Promise<{ id: string } | undefined>; copyImage?: () => Promise; } let { - asset = $bindable(), - preloadAssets = $bindable([]), + asset, + nextAsset, + previousAsset, showNavigation = true, withStacked = false, isShared = false, @@ -82,8 +84,7 @@ onAction = undefined, showCloseButton, onClose, - onNext, - onPrevious, + onNavigateToAsset, onRandom, copyImage = $bindable(), }: Props = $props(); @@ -135,7 +136,7 @@ untrack(() => { if (stack && stack?.assets.length > 1) { - preloadAssets.push(toTimelineAsset(stack.assets[1])); + preloadImageUrl(getAssetUrl({ asset: stack.assets[1] })); } }); }; @@ -225,7 +226,7 @@ }; const closeViewer = () => { - onClose(asset); + onClose?.(asset); }; const closeEditor = () => { @@ -234,7 +235,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'; @@ -244,38 +247,39 @@ } 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 if (onNavigateToAsset) { + hasNext = order === 'previous' ? await onNavigateToAsset(previousAsset) : await onNavigateToAsset(nextAsset); + } else { + hasNext = false; + } + + 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 } }); @@ -378,12 +382,6 @@ let isFullScreen = $derived(fullscreenElement !== null); - $effect(() => { - if (asset) { - previewStackedAsset = undefined; - handlePromiseError(refreshStack()); - } - }); $effect(() => { if (album && !album.isActivityEnabled && activityManager.commentCount === 0) { isShowActivity = false; @@ -395,13 +393,57 @@ } }); - // primarily, this is reactive on `asset` + let lastAsset: AssetResponseDto | undefined; $effect(() => { - handlePromiseError(handleGetAllAlbums()); - ocrManager.clear(); - if (!sharedLink) { - handlePromiseError(ocrManager.getAssetOcr(asset.id)); + const refresh = async () => { + await refreshStack(); + await handleGetAllAlbums(); + ocrManager.clear(); + if (!sharedLink) { + if (previewStackedAsset) { + await ocrManager.getAssetOcr(previewStackedAsset.id); + } + await ocrManager.getAssetOcr(asset.id); + } + }; + handlePromiseError(refresh()); + if (lastAsset?.id !== asset.id) { + preloadManager.cancel(lastAsset); } + lastAsset = asset; + }); + + $effect(() => { + preloadManager.preload(nextAsset); + preloadManager.preload(previousAsset); + }); + + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + asset.id; + if (viewerKind !== 'PhotoViewer') { + eventManager.emit('AssetViewerFree'); + } + }); + + const viewerKind = $derived.by(() => { + if (previewStackedAsset) { + return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; + } + if (asset.type === AssetTypeEnum.Image) { + if (shouldPlayMotionPhoto && asset.livePhotoVideoId) { + return 'LiveVideoViewer'; + } else if ( + asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + ) { + return 'ImagePanaramaViewer'; + } else if (isShowEditor && selectedEditType === 'crop') { + return 'CropArea'; + } + return 'PhotoViewer'; + } + return 'VideoViewer'; }); @@ -459,7 +501,7 @@ {/if} - {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} + {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && previousAsset}
navigateAsset('previous')} />
@@ -467,104 +509,91 @@
- {#if previewStackedAsset} - {#key previewStackedAsset.id} - {#if previewStackedAsset.type === AssetTypeEnum.Image} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - haveFadeTransition={false} - {sharedLink} - /> - {:else} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - onClose={closeViewer} - onVideoEnded={() => navigateAsset()} - onVideoStarted={handleVideoStarted} - {playOriginalVideo} - /> - {/if} - {/key} - {:else} - {#key asset.id} - {#if asset.type === AssetTypeEnum.Image} - {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - onVideoEnded={() => (shouldPlayMotionPhoto = false)} - {playOriginalVideo} - /> - {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath - .toLowerCase() - .endsWith('.insp'))} - - {:else if isShowEditor && selectedEditType === 'crop'} - - {:else} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - {sharedLink} - haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} - /> - {/if} - {:else} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - onClose={closeViewer} - onVideoEnded={() => navigateAsset()} - onVideoStarted={handleVideoStarted} - {playOriginalVideo} - /> - {/if} + {#if viewerKind === 'StackPhotoViewer'} + navigateAsset('previous')} + onNextAsset={() => navigateAsset('next')} + haveFadeTransition={false} + {sharedLink} + /> + {:else if viewerKind === 'StackVideoViewer'} + navigateAsset('previous')} + onNextAsset={() => navigateAsset('next')} + onClose={closeViewer} + onVideoEnded={() => navigateAsset()} + onVideoStarted={handleVideoStarted} + {playOriginalVideo} + /> + {:else if viewerKind === 'LiveVideoViewer'} + navigateAsset('previous')} + onNextAsset={() => navigateAsset('next')} + onVideoEnded={() => (shouldPlayMotionPhoto = false)} + {playOriginalVideo} + /> + {:else if viewerKind === 'ImagePanaramaViewer'} + + {:else if viewerKind === 'CropArea'} + + {:else if viewerKind === 'PhotoViewer'} + navigateAsset('previous')} + onNextAsset={() => navigateAsset('next')} + {sharedLink} + haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} + onFree={() => eventManager.emit('AssetViewerFree')} + /> + {:else if viewerKind === 'VideoViewer'} + navigateAsset('previous')} + onNextAsset={() => navigateAsset('next')} + onClose={closeViewer} + onVideoEnded={() => navigateAsset()} + onVideoStarted={handleVideoStarted} + {playOriginalVideo} + /> + {/if} - {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading} -
- -
- {/if} + {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading} +
+ +
+ {/if} - {#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData} -
- -
- {/if} - {/key} + {#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData} +
+ +
{/if}
- {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} + {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && nextAsset}
navigateAsset('next')} />
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts index fd1a40e4db..8d214c4186 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -1,7 +1,7 @@ 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 { 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'; @@ -19,18 +19,15 @@ vi.mock('$lib/utils', async (originalImport) => { const meta = await originalImport(); return { ...meta, - getAssetOriginalUrl: vi.fn(), - getAssetThumbnailUrl: vi.fn(), + getAssetUrl: vi.fn(), }; }); describe('PhotoViewer component', () => { - let getAssetOriginalUrlSpy: MockInstance; - let getAssetThumbnailUrlSpy: MockInstance; + let getAssetUrlSpy: MockInstance; beforeAll(() => { - getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl'); - getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl'); + getAssetUrlSpy = vi.spyOn(utils, 'getAssetUrl'); vi.stubGlobal('cast', { framework: { @@ -72,12 +69,11 @@ describe('PhotoViewer component', () => { }); render(PhotoViewer, { asset }); - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, + expect(getAssetUrlSpy).toBeCalledWith({ + asset, + sharedLink: undefined, + forceOriginal: false, }); - expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); it('loads the thumbnail image for static gifs', () => { @@ -88,12 +84,11 @@ describe('PhotoViewer component', () => { }); render(PhotoViewer, { asset }); - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, + expect(getAssetUrlSpy).toBeCalledWith({ + asset, + sharedLink: undefined, + forceOriginal: false, }); - expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); it('loads the thumbnail image for static webp images', () => { @@ -104,12 +99,11 @@ describe('PhotoViewer component', () => { }); render(PhotoViewer, { asset }); - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, + expect(getAssetUrlSpy).toBeCalledWith({ + asset, + sharedLink: undefined, + forceOriginal: false, }); - expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); it('loads the original image for animated gifs', () => { @@ -121,8 +115,11 @@ describe('PhotoViewer component', () => { }); render(PhotoViewer, { asset }); - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); + expect(getAssetUrlSpy).toBeCalledWith({ + asset, + sharedLink: undefined, + forceOriginal: false, + }); }); it('loads the original image for animated webp images', () => { @@ -134,8 +131,11 @@ describe('PhotoViewer component', () => { }); render(PhotoViewer, { asset }); - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); + expect(getAssetUrlSpy).toBeCalledWith({ + asset, + sharedLink: undefined, + forceOriginal: false, + }); }); it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => { @@ -147,13 +147,11 @@ describe('PhotoViewer component', () => { 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(getAssetUrlSpy).toBeCalledWith({ + asset, + sharedLink, + forceOriginal: false, }); - - expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => { @@ -166,8 +164,11 @@ describe('PhotoViewer component', () => { 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 }); + expect(getAssetUrlSpy).toBeCalledWith({ + asset, + sharedLink, + forceOriginal: false, + }); }); it('not loads original animated image when shared link download permission is false', () => { @@ -180,13 +181,11 @@ describe('PhotoViewer component', () => { 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(getAssetUrlSpy).toBeCalledWith({ + asset, + sharedLink, + forceOriginal: false, }); - - expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); it('not loads original animated image when shared link showMetadata permission is false', () => { @@ -199,12 +198,10 @@ describe('PhotoViewer component', () => { 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(getAssetUrlSpy).toBeCalledWith({ + asset, + sharedLink, + forceOriginal: false, }); - - 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..0403c96d4e 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -6,56 +6,59 @@ 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 AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { LoadingSpinner, toastManager } from '@immich/ui'; - import { onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount, untrack } from 'svelte'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; interface Props { asset: AssetResponseDto; - preloadAssets?: TimelineAsset[] | undefined; element?: HTMLDivElement | undefined; haveFadeTransition?: boolean; sharedLink?: SharedLinkResponseDto | undefined; onPreviousAsset?: (() => void) | null; onNextAsset?: (() => void) | null; + onLoad?: (() => void) | null; + onError?: (() => void) | null; + onBusy?: (() => void) | null; + onFree?: (() => void) | null; copyImage?: () => Promise; zoomToggle?: (() => void) | null; } let { asset, - preloadAssets = undefined, element = $bindable(), haveFadeTransition = true, sharedLink = undefined, onPreviousAsset = null, onNextAsset = null, + onLoad, + onError, + onBusy, + onFree, copyImage = $bindable(), zoomToggle = $bindable(), }: Props = $props(); const { slideshowState, slideshowLook } = slideshowStore; - let assetFileUrl: string = $state(''); let imageLoaded: boolean = $state(false); let originalImageLoaded: boolean = $state(false); let imageError: boolean = $state(false); @@ -82,25 +85,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 +139,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); } }); @@ -190,36 +162,50 @@ }; const onload = () => { + onLoad?.(); + onFree?.(); imageLoaded = true; - assetFileUrl = imageLoaderUrl; originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original'; }; const onerror = () => { + onError?.(); + onFree?.(); 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); + if (!imageLoaded && !imageError) { + onFree?.(); + } + 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) { + untrack(() => onBusy?.()); + } + if (lastUrl && lastUrl !== imageLoaderUrl) { + untrack(() => { + imageLoaded = false; + originalImageLoaded = false; + imageError = false; + onBusy?.(); + }); + } + lastUrl = imageLoaderUrl; + }); {/if} - - +
- {#if !imageLoaded}
@@ -258,7 +242,7 @@ > {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} 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) => { @@ -144,15 +144,7 @@ {:else if assets.length === 1} {#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)} - onRandom={() => Promise.resolve(undefined)} - onClose={() => {}} - /> + {/await} {/await} {/if} 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..5bd7ad0f40 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 @@ -27,7 +27,7 @@ interface Props { initialAssetId?: string; - assets: TimelineAsset[] | AssetResponseDto[]; + assets: AssetResponseDto[]; assetInteraction: AssetInteraction; disableAssetSelect?: boolean; showArchiveIcon?: boolean; @@ -35,9 +35,7 @@ onIntersected?: (() => void) | undefined; showAssetName?: boolean; isShowDeleteConfirmation?: boolean; - onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined; - onNext?: (() => Promise<{ id: string } | undefined>) | undefined; - onRandom?: (() => Promise<{ id: string } | undefined>) | undefined; + onNavigateToAsset?: (asset: AssetResponseDto | undefined) => Promise; onReload?: (() => void) | undefined; pageHeaderOffset?: number; slidingWindowOffset?: number; @@ -54,9 +52,7 @@ onIntersected = undefined, showAssetName = false, isShowDeleteConfirmation = $bindable(false), - onPrevious = undefined, - onNext = undefined, - onRandom = undefined, + onNavigateToAsset, onReload = undefined, slidingWindowOffset = 0, pageHeaderOffset = 0, @@ -86,7 +82,7 @@ return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top; }; - let currentIndex = 0; + let currentIndex = $state(0); if (initialAssetId && assets.length > 0) { const index = assets.findIndex(({ id }) => id === initialAssetId); if (index !== -1) { @@ -229,7 +225,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 +238,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(); } }; @@ -295,48 +291,29 @@ })(), ); - const handleNext = async (): Promise => { - try { - let asset: { id: string } | undefined; - if (onNext) { - asset = await onNext(); - } else { - if (currentIndex >= assets.length - 1) { - return false; - } - - currentIndex = currentIndex + 1; - asset = currentIndex < assets.length ? assets[currentIndex] : undefined; - } - - if (!asset) { - return false; - } - - await navigateToAsset(asset); - return true; - } catch (error) { - handleError(error, $t('errors.cannot_navigate_next_asset')); - return false; + const nextAsset = $derived.by(() => { + if (currentIndex >= assets.length - 1) { + return undefined; } - }; + return currentIndex + 1 < assets.length ? assets[currentIndex + 1] : undefined; + }); + + const previousAsset = $derived.by(() => { + if (currentIndex <= 0) { + return undefined; + } + return currentIndex - 1 >= 0 ? assets[currentIndex - 1] : undefined; + }); const handleRandom = async (): Promise<{ id: string } | undefined> => { try { - let asset: { id: string } | undefined; - if (onRandom) { - asset = await onRandom(); - } else { - if (assets.length > 0) { - const randomIndex = Math.floor(Math.random() * assets.length); - asset = assets[randomIndex]; - } - } - - if (!asset) { + if (assets.length === 0) { return; } + const randomIndex = Math.floor(Math.random() * assets.length); + const asset = assets[randomIndex]; + await navigateToAsset(asset); return asset; } catch (error) { @@ -345,30 +322,13 @@ } }; - const handlePrevious = async (): Promise => { - try { - let asset: { id: string } | undefined; - if (onPrevious) { - asset = await onPrevious(); - } else { - if (currentIndex <= 0) { - return false; - } - - currentIndex = currentIndex - 1; - asset = currentIndex >= 0 ? assets[currentIndex] : undefined; - } - - if (!asset) { - return false; - } - - await navigateToAsset(asset); + const handleNavigateToAsset = async (target: AssetResponseDto | undefined) => { + if (target) { + currentIndex = assets.indexOf(target); + await (onNavigateToAsset ? onNavigateToAsset(target) : navigateToAsset(target)); return true; - } catch (error) { - handleError(error, $t('errors.cannot_navigate_previous_asset')); - return false; } + return false; }; const navigateToAsset = async (asset?: { id: string }) => { @@ -390,9 +350,9 @@ if (assets.length === 0) { await goto(AppRoute.PHOTOS); } else if (currentIndex === assets.length) { - await handlePrevious(); + await handleNavigateToAsset(previousAsset); } else { - await setAssetId(assets[currentIndex].id); + await handleNavigateToAsset(nextAsset); } break; } @@ -489,9 +449,10 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} { assetViewingStore.showAssetViewer(false); diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index a121bd1938..ad757c75a9 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -2,14 +2,16 @@ import type { Action } from '$lib/components/asset-viewer/actions/action'; import { AssetAction } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import { eventManager } from '$lib/managers/event-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { navigate } from '$lib/utils/navigation'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; + import { getAssetInfo, type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk'; + import { untrack } from 'svelte'; - let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore; + let { asset: viewingAsset, gridScrollTarget } = assetViewingStore; interface Props { timelineManager: TimelineManager; @@ -38,44 +40,65 @@ person = null, }: Props = $props(); - const handlePrevious = async () => { - const release = await mutex.acquire(); - const laterAsset = await timelineManager.getLaterAsset($viewingAsset); - - if (laterAsset) { - const preloadAsset = await timelineManager.getLaterAsset(laterAsset); - const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id }); - assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []); - await navigate({ targetRoute: 'current', assetId: laterAsset.id }); + const getNextAsset = async (currentAsset: AssetResponseDto) => { + const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset); + if (earlierTimelineAsset) { + const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id }); + return asset; } - - release(); - return !!laterAsset; }; - const handleNext = async () => { - const release = await mutex.acquire(); - const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset); - - if (earlierAsset) { - const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset); - const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id }); - assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []); - await navigate({ targetRoute: 'current', assetId: earlierAsset.id }); + const getPreviousAsset = async (currentAsset: AssetResponseDto) => { + const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset); + if (laterTimelineAsset) { + const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id }); + return asset; } + }; - release(); - return !!earlierAsset; + let assetCursor = $state<{ + previousAsset: AssetResponseDto | undefined; + current: AssetResponseDto; + nextAsset: AssetResponseDto | undefined; + }>({ + current: $viewingAsset, + previousAsset: undefined, + nextAsset: undefined, + }); + + const loadCloseAssets = async (currentAsset: AssetResponseDto) => { + const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]); + assetCursor = { + current: currentAsset, + nextAsset, + previousAsset, + }; + }; + + //TODO: replace this with async derived in svelte 6 + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + $viewingAsset; + untrack(() => void loadCloseAssets($viewingAsset)); + }); + + const handleNavigateToAsset = async (targetAsset: AssetResponseDto | undefined) => { + if (!targetAsset) { + return false; + } + let waitForAssetViewerFree = new Promise((resolve) => { + eventManager.once('AssetViewerFree', () => resolve()); + }); + await navigate({ targetRoute: 'current', assetId: targetAsset.id }); + await waitForAssetViewerFree; + return true; }; const handleRandom = async () => { const randomAsset = await timelineManager.getRandomAsset(); - if (randomAsset) { - const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id }); - assetViewingStore.setAsset(asset); await navigate({ targetRoute: 'current', assetId: randomAsset.id }); - return asset; + return { id: randomAsset.id }; } }; @@ -97,7 +120,9 @@ case AssetAction.SET_VISIBILITY_TIMELINE: { // find the next asset to show or close the viewer // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset)); + (await handleNavigateToAsset(assetCursor?.nextAsset)) || + (await handleNavigateToAsset(assetCursor?.previousAsset)) || + (await handleClose(action.asset)); // delete after find the next one timelineManager.removeAssets([action.asset.id]); @@ -168,15 +193,15 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 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 3509f07fb0..183ab3f95e 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 @@ -10,7 +10,7 @@ import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui'; import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js'; - import { onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount, untrack } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; @@ -43,24 +43,55 @@ assetViewingStore.showAssetViewer(false); }); - const onNext = async () => { - const index = getAssetIndex($viewingAsset.id) + 1; - if (index >= assets.length) { + const handleNavigateToAsset = async (asset: AssetResponseDto | undefined) => { + if (!asset) { return false; } - await onViewAsset(assets[index]); + await onViewAsset(asset); return true; }; - const onPrevious = async () => { - const index = getAssetIndex($viewingAsset.id) - 1; + const getPreviousAsset = (currentAsset: AssetResponseDto) => { + const index = getAssetIndex(currentAsset.id) - 1; if (index < 0) { - return false; + return undefined; } - await onViewAsset(assets[index]); - return true; + return assets[index]; }; + const getNextAsset = (currentAsset: AssetResponseDto) => { + const index = getAssetIndex(currentAsset.id) + 1; + if (index >= assets.length) { + return undefined; + } + return assets[index]; + }; + + let assetCursor = $state<{ + previousAsset: AssetResponseDto | undefined; + current: AssetResponseDto; + nextAsset: AssetResponseDto | undefined; + }>({ + current: $viewingAsset, + previousAsset: undefined, + nextAsset: undefined, + }); + + const loadCloseAssets = (currentAsset: AssetResponseDto) => { + assetCursor = { + current: currentAsset, + nextAsset: getNextAsset(currentAsset), + previousAsset: getPreviousAsset(currentAsset), + }; + }; + + //TODO: replace this with async derived in svelte 6 + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + $viewingAsset; + untrack(() => void loadCloseAssets($viewingAsset)); + }); + const onRandom = async () => { if (assets.length <= 0) { return; @@ -182,10 +213,11 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} - {onNext} - {onPrevious} + onNavigateToAsset={handleNavigateToAsset} {onRandom} onClose={() => { assetViewingStore.showAssetViewer(false); diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 66a2db8787..c7cfdbf4fe 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -43,6 +43,8 @@ export type Events = { LibraryDelete: [{ id: string }]; ReleaseEvent: [ReleaseEvent]; + + AssetViewerFree: []; }; type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void; diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index b6c43480ef..b0dc30dc6e 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -346,7 +346,7 @@ export class TimelineManager extends VirtualScrollManager { async findMonthGroupForAsset(asset: AssetDescriptor | AssetResponseDto) { if (!this.isInitialized) { - await this.initTask.waitUntilCompletion(); + await this.initTask.waitUntilExecution(); } const { id } = asset; diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index 99ee1b8c46..3cd2cd9579 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -1,19 +1,14 @@ 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 +25,6 @@ function createAssetViewingStore() { return { asset: readonly(viewingAssetStoreState), - mutex: viewingAssetMutex, - preloadAssets: readonly(preloadAssets), isViewing: viewState, gridScrollTarget, setAsset, 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/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index fd443a6470..6988177866 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 @@ -3,13 +3,15 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import { AppRoute, timeToLoadTheMap } from '$lib/constants'; import Portal from '$lib/elements/Portal.svelte'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; import { delay } from '$lib/utils/asset-utils'; import { navigate } from '$lib/utils/navigation'; + import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { LoadingSpinner } from '@immich/ui'; - import { onDestroy } from 'svelte'; + import { onDestroy, untrack } from 'svelte'; import type { PageData } from './$types'; interface Props { @@ -21,7 +23,6 @@ let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore; let viewingAssets: string[] = $state([]); - let viewingAssetCursor = 0; onDestroy(() => { assetViewingStore.showAssetViewer(false); @@ -33,27 +34,66 @@ async function onViewAssets(assetIds: string[]) { viewingAssets = assetIds; - viewingAssetCursor = 0; await setAssetId(assetIds[0]); } - async function navigateNext() { - if (viewingAssetCursor < viewingAssets.length - 1) { - await setAssetId(viewingAssets[++viewingAssetCursor]); - await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); - return true; + const handleNavigateToAsset = async (currentAsset: AssetResponseDto | undefined) => { + if (!currentAsset) { + return false; } - return false; - } + await navigate({ targetRoute: 'current', assetId: currentAsset.id }); + return true; + }; - async function navigatePrevious() { - if (viewingAssetCursor > 0) { - await setAssetId(viewingAssets[--viewingAssetCursor]); - await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); - return true; + const getNextAsset = async (currentAsset: AssetResponseDto | undefined) => { + if (!currentAsset) { + return; } - return false; - } + const cursor = viewingAssets.indexOf(currentAsset.id); + if (cursor < viewingAssets.length - 1) { + const id = viewingAssets[cursor + 1]; + const asset = await getAssetInfo({ ...authManager.params, id }); + return asset; + } + }; + + const getPreviousAsset = async (currentAsset: AssetResponseDto | undefined) => { + if (!currentAsset) { + return; + } + const cursor = viewingAssets.indexOf(currentAsset.id); + if (cursor > 0) { + const id = viewingAssets[cursor - 1]; + const asset = await getAssetInfo({ ...authManager.params, id }); + return asset; + } + }; + + let assetCursor = $state<{ + previousAsset: AssetResponseDto | undefined; + current: AssetResponseDto | undefined; + nextAsset: AssetResponseDto | undefined; + }>({ + current: $viewingAsset, + previousAsset: undefined, + nextAsset: undefined, + }); + + const loadCloseAssets = async (currentAsset: AssetResponseDto | undefined) => { + const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]); + assetCursor = { + current: currentAsset, + nextAsset, + previousAsset, + }; + }; + + //TODO: replace this with async derived in svelte 6 + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + $viewingAsset; + untrack(() => void loadCloseAssets($viewingAsset)); + }); async function navigateRandom() { if (viewingAssets.length <= 0) { @@ -82,13 +122,14 @@
- {#if $showAssetViewer} + {#if $showAssetViewer && assetCursor.current} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} - onNext={navigateNext} - onPrevious={navigatePrevious} + onNavigateToAsset={handleNavigateToAsset} onRandom={navigateRandom} onClose={() => { assetViewingStore.showAssetViewer(false); 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..168732a4b4 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 @@ -6,9 +6,9 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/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; @@ -27,21 +27,27 @@ } }); - const onNext = async () => { - const index = getAssetIndex($viewingAsset.id) + 1; - if (index >= assets.length) { - return false; + const nextAsset = $derived.by(() => { + const currentIndex = getAssetIndex($viewingAsset.id); + if (currentIndex >= assets.length - 1) { + return undefined; } - await onViewAsset(assets[index]); - return true; - }; + return currentIndex + 1 < assets.length ? assets[currentIndex + 1] : undefined; + }); - const onPrevious = async () => { - const index = getAssetIndex($viewingAsset.id) - 1; - if (index < 0) { + const previousAsset = $derived.by(() => { + const currentIndex = getAssetIndex($viewingAsset.id); + if (currentIndex <= 0) { + return undefined; + } + return currentIndex - 1 >= 0 ? assets[currentIndex - 1] : undefined; + }); + + const handleNavigateToAsset = async (asset: AssetResponseDto | undefined) => { + if (!asset) { return false; } - await onViewAsset(assets[index]); + await onViewAsset(asset); return true; }; @@ -86,9 +92,10 @@ 1} - {onNext} - {onPrevious} {onRandom} {onAction} onClose={() => {