From 80a5444bf4a80bd614828c65c3a5263001bccb46 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 15 Jan 2026 06:55:01 -0500 Subject: [PATCH] feat: redesign asset-viewer previous/next and hide when nav not possible (#24903) --- .../asset-viewer.parallel-e2e-spec.ts | 116 ++++++++- e2e/src/web/specs/photo-viewer.e2e-spec.ts | 2 +- .../asset-viewer/asset-viewer.svelte | 227 ++++++++++-------- .../asset-viewer/photo-viewer.svelte | 16 +- .../memory-page/memory-viewer.svelte | 4 +- .../individual-shared-viewer.svelte | 8 +- .../gallery-viewer/gallery-viewer.svelte | 126 ++-------- .../timeline/TimelineAssetViewer.svelte | 37 ++- .../duplicates-compare-control.svelte | 21 -- web/src/lib/stores/asset-viewing.store.ts | 1 - web/src/lib/utils/asset-utils.ts | 15 +- .../[[assetId=id]]/+page.svelte | 1 - .../[[assetId=id]]/+page.svelte | 24 +- .../[[assetId=id]]/+page.svelte | 22 -- 14 files changed, 299 insertions(+), 321 deletions(-) diff --git a/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts b/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts index eaf9d0d073..3d65b20c87 100644 --- a/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts +++ b/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import { Changes, createDefaultTimelineConfig, @@ -58,6 +58,120 @@ test.describe('asset-viewer', () => { }); test.describe('/photos/:id', () => { + test('Navigate to next asset via button', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`); + + await page.getByLabel('View next asset').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`); + }); + + test('Navigate to previous asset via button', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`); + + await page.getByLabel('View previous asset').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`); + }); + + test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`); + + await page.keyboard.press('ArrowRight'); + await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`); + }); + + test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`); + + await page.keyboard.press('ArrowLeft'); + await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`); + }); + + test('Navigate forward 5 times via button', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + + for (let i = 1; i <= 5; i++) { + await page.getByLabel('View next asset').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index + i]); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`); + } + }); + + test('Navigate backward 5 times via button', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + + for (let i = 1; i <= 5; i++) { + await page.getByLabel('View previous asset').click(); + await assetViewerUtils.waitForViewerLoad(page, assets[index - i]); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`); + } + }); + + test('Navigate forward then backward via keyboard', async ({ page }) => { + const asset = selectRandom(assets, rng); + const index = assets.indexOf(asset); + await page.goto(`/photos/${asset.id}`); + await assetViewerUtils.waitForViewerLoad(page, asset); + + // Navigate forward 3 times + for (let i = 1; i <= 3; i++) { + await page.keyboard.press('ArrowRight'); + await assetViewerUtils.waitForViewerLoad(page, assets[index + i]); + } + + // Navigate backward 3 times to return to original + for (let i = 2; i >= 0; i--) { + await page.keyboard.press('ArrowLeft'); + await assetViewerUtils.waitForViewerLoad(page, assets[index + i]); + } + + // Verify we're back at the original asset + await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`); + }); + + test('Verify no next button on last asset', async ({ page }) => { + const lastAsset = assets.at(-1)!; + await page.goto(`/photos/${lastAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, lastAsset); + + // Verify next button doesn't exist + await expect(page.getByLabel('View next asset')).toHaveCount(0); + }); + + test('Verify no previous button on first asset', async ({ page }) => { + const firstAsset = assets[0]; + await page.goto(`/photos/${firstAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, firstAsset); + + // Verify previous button doesn't exist + await expect(page.getByLabel('View previous asset')).toHaveCount(0); + }); + test('Delete photo advances to next', async ({ page }) => { const asset = selectRandom(assets, rng); await page.goto(`/photos/${asset.id}`); diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index c8a9b42b2a..3f9bb4237a 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -3,7 +3,7 @@ import { Page, expect, test } from '@playwright/test'; import { utils } from 'src/utils'; function imageLocator(page: Page) { - return page.getByAltText('Image taken on').locator('visible=true'); + return page.getByAltText('Image taken').locator('visible=true'); } test.describe('Photo Viewer', () => { let admin: LoginResponseDto; diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index c6a1efc069..296e2af2d0 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -19,6 +19,7 @@ import { user } from '$lib/stores/user.store'; import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; + import { navigateToAsset } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { InvocationTracker } from '$lib/utils/invocationTracker'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; @@ -52,8 +53,6 @@ import SlideshowBar from './slideshow-bar.svelte'; import VideoViewer from './video-wrapper-viewer.svelte'; - type HasAsset = boolean; - export type AssetCursor = { current: AssetResponseDto; nextAsset?: AssetResponseDto; @@ -72,9 +71,7 @@ onAction?: OnAction; onUndoDelete?: OnUndoDelete; onClose?: (asset: AssetResponseDto) => void; - onNext: () => Promise; - onPrevious: () => Promise; - onRandom: () => Promise<{ id: string } | undefined>; + onRandom?: () => Promise<{ id: string } | undefined>; copyImage?: () => Promise; } @@ -90,8 +87,6 @@ onAction, onUndoDelete, onClose, - onNext, - onPrevious, onRandom, copyImage = $bindable(), }: Props = $props(); @@ -108,6 +103,8 @@ const stackSelectedThumbnailSize = 65; const asset = $derived(cursor.current); + const nextAsset = $derived(cursor.nextAsset); + const previousAsset = $derived(cursor.previousAsset); let appearsInAlbums: AlbumResponseDto[] = $state([]); let sharedLink = getSharedLink(); let previewStackedAsset: AssetResponseDto | undefined = $state(); @@ -235,14 +232,15 @@ if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); if (!hasNext) { - const asset = await onRandom(); + const asset = await onRandom?.(); if (asset) { slideshowHistory.queue(asset); hasNext = true; } } } else { - hasNext = order === 'previous' ? await onPrevious() : await onNext(); + hasNext = + order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); } if ($slideshowState === SlideshowState.PlaySlideshow) { @@ -383,7 +381,6 @@ await ocrManager.getAssetOcr(asset.id); } }; - $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions asset; @@ -406,6 +403,42 @@ cursor.current = update; } }; + + const viewerKind = $derived.by(() => { + if (previewStackedAsset) { + return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; + } + if (asset.type === AssetTypeEnum.Video) { + return 'VideoViewer'; + } + if (assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId) { + return 'LiveVideoViewer'; + } + if ( + asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + ) { + return 'ImagePanaramaViewer'; + } + if (isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) { + return 'CropArea'; + } + return 'PhotoViewer'; + }); + + const showActivityStatus = $derived( + $slideshowState === SlideshowState.None && + isShared && + ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && + !activityManager.isLoading, + ); + + const showOcrButton = $derived( + $slideshowState === SlideshowState.None && + asset.type === AssetTypeEnum.Image && + !isShowEditor && + ocrManager.hasOcrData, + ); @@ -442,7 +475,7 @@ {/if} {#if $slideshowState != SlideshowState.None} -
+
{/if} - {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} -
+ {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && previousAsset} +
navigateAsset('previous')} />
{/if}
- {#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 assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)} - {playOriginalVideo} - /> - {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath - .toLowerCase() - .endsWith('.insp'))} - - {:else if isShowEditor && editManager.selectedTool?.type === EditToolType.Transform} - - {: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={() => (assetViewerManager.isPlayingMotionPhoto = 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} + /> + {: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 showActivityStatus} +
+ +
+ {/if} - {#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData} -
- -
- {/if} - {/key} + {#if showOcrButton} +
+ +
{/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.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index baf46052be..fb61195357 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -22,7 +22,7 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk'; import { LoadingSpinner, toastManager } from '@immich/ui'; - import { onDestroy, onMount } from 'svelte'; + import { onDestroy, untrack } from 'svelte'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -164,11 +164,7 @@ imageError = imageLoaded = true; }; - onMount(() => { - return () => { - preloadManager.cancelPreloadUrl(imageLoaderUrl); - }; - }); + onDestroy(() => preloadManager.cancelPreloadUrl(imageLoaderUrl)); let imageLoaderUrl = $derived( getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }), @@ -181,9 +177,11 @@ $effect(() => { if (lastUrl && lastUrl !== imageLoaderUrl) { - imageLoaded = false; - originalImageLoaded = false; - imageError = false; + untrack(() => { + imageLoaded = false; + originalImageLoaded = false; + imageError = false; + }); } lastUrl = imageLoaderUrl; }); diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index ff1597fedb..46c7fa94b9 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -26,7 +26,7 @@ import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte'; + import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { preferences } from '$lib/stores/user.store'; import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; @@ -651,8 +651,6 @@ bind:this={memoryGallery} > Promise.resolve(false)} - onNext={() => Promise.resolve(false)} - onRandom={() => Promise.resolve(undefined)} - /> + {/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 f80edb20ba..ee54831159 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 @@ -14,7 +14,13 @@ import { showDeleteModal } from '$lib/stores/preferences.store'; import { handlePromiseError } from '$lib/utils'; import { deleteAssets } from '$lib/utils/actions'; - import { archiveAssets, cancelMultiselect, getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; + import { + archiveAssets, + cancelMultiselect, + getNextAsset, + getPreviousAsset, + navigateToAsset, + } 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'; @@ -26,7 +32,6 @@ import { t } from 'svelte-i18n'; type Props = { - initialAssetId?: string; assets: AssetResponseDto[]; assetInteraction: AssetInteraction; disableAssetSelect?: boolean; @@ -34,9 +39,6 @@ viewport: Viewport; onIntersected?: (() => void) | undefined; showAssetName?: boolean; - onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined; - onNext?: (() => Promise<{ id: string } | undefined>) | undefined; - onRandom?: (() => Promise<{ id: string } | undefined>) | undefined; onReload?: (() => void) | undefined; pageHeaderOffset?: number; slidingWindowOffset?: number; @@ -44,7 +46,6 @@ }; let { - initialAssetId = undefined, assets = $bindable(), assetInteraction, disableAssetSelect = false, @@ -52,16 +53,13 @@ viewport, onIntersected = undefined, showAssetName = false, - onPrevious = undefined, - onNext = undefined, - onRandom = undefined, onReload = undefined, slidingWindowOffset = 0, pageHeaderOffset = 0, arrowNavigation = true, }: Props = $props(); - let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore; + let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore; const geometry = $derived( getJustifiedLayoutFromAssets(assets, { @@ -84,14 +82,6 @@ return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top; }; - let currentIndex = 0; - if (initialAssetId && assets.length > 0) { - const index = assets.findIndex(({ id }) => id === initialAssetId); - if (index !== -1) { - currentIndex = index; - } - } - let shiftKeyIsDown = $state(false); let lastAssetMouseEvent: TimelineAsset | null = $state(null); let scrollTop = $state(0); @@ -105,7 +95,8 @@ }); const updateCurrentAsset = (asset: AssetResponseDto) => { - assets[currentIndex] = asset; + const index = assets.findIndex((oldAsset) => oldAsset.id === asset.id); + assets[index] = asset; }; const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0); @@ -124,11 +115,6 @@ } } }); - const viewAssetHandler = async (asset: TimelineAsset) => { - currentIndex = assets.findIndex((a) => a.id == asset.id); - await setAssetId(assets[currentIndex].id); - await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); - }; const selectAllAssets = () => { assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a))); @@ -294,47 +280,13 @@ })(), ); - 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 handleRandom = async (): Promise<{ id: string } | undefined> => { + if (assets.length === 0) { + return; + } 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) { - return; - } + const randomIndex = Math.floor(Math.random() * assets.length); + const asset = assets[randomIndex]; await navigateToAsset(asset); return asset; @@ -344,39 +296,6 @@ } }; - 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); - return true; - } catch (error) { - handleError(error, $t('errors.cannot_navigate_previous_asset')); - return false; - } - }; - - const navigateToAsset = async (asset?: { id: string }) => { - if (asset && asset.id !== $viewingAsset.id) { - await setAssetId(asset.id); - await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); - } - }; - const handleAction = async (action: Action) => { switch (action.type) { case AssetAction.ARCHIVE: @@ -387,11 +306,12 @@ 1, ); if (assets.length === 0) { - await goto(AppRoute.PHOTOS); - } else if (currentIndex === assets.length) { - await handlePrevious(); - } else { - await setAssetId(assets[currentIndex].id); + return await goto(AppRoute.PHOTOS); + } + if (assetCursor.nextAsset) { + await navigateToAsset(assetCursor.nextAsset); + } else if (assetCursor.previousAsset) { + await navigateToAsset(assetCursor.previousAsset); } break; } @@ -454,7 +374,7 @@ handleSelectAssets(currentAsset); return; } - void viewAssetHandler(currentAsset); + void navigateToAsset(asset); }} onSelect={() => handleSelectAssets(currentAsset)} onMouseEvent={() => assetMouseEventHandler(currentAsset)} @@ -485,8 +405,6 @@ { diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 0dd555754b..06ff61d180 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -10,6 +10,7 @@ import { websocketEvents } from '$lib/stores/websocket'; import { handlePromiseError } from '$lib/utils'; import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; + import { navigateToAsset } from '$lib/utils/asset-utils'; import { navigate } from '$lib/utils/navigation'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk'; @@ -24,7 +25,6 @@ isShared?: boolean; album?: AlbumResponseDto; person?: PersonResponseDto; - removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null; } @@ -41,7 +41,7 @@ const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => { const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset); if (earlierTimelineAsset) { - const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id }); + const asset = await assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id }); if (preload) { // also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete void getNextAsset(asset, false); @@ -52,9 +52,8 @@ const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => { const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset); - if (laterTimelineAsset) { - const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id }); + const asset = await assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id }); if (preload) { // also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete void getPreviousAsset(asset, false); @@ -86,15 +85,6 @@ untrack(() => handlePromiseError(loadCloseAssets($viewingAsset))); }); - const handleNavigateToAsset = async (targetAsset: AssetResponseDto | undefined | null) => { - if (!targetAsset) { - return false; - } - - await navigate({ targetRoute: 'current', assetId: targetAsset.id }); - return true; - }; - const handleRandom = async () => { const randomAsset = await timelineManager.getRandomAsset(); if (randomAsset) { @@ -124,8 +114,8 @@ // find the next asset to show or close the viewer // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (await handleNavigateToAsset(assetCursor?.nextAsset)) || - (await handleNavigateToAsset(assetCursor?.previousAsset)) || + (await navigateToAsset(assetCursor?.nextAsset)) || + (await navigateToAsset(assetCursor?.previousAsset)) || (await handleClose(action.asset)); break; @@ -197,18 +187,17 @@ await navigate({ targetRoute: 'current', assetId: restoredAsset.id }); } }; - onDestroy(() => { - assetCacheManager.invalidate(); - }); - const onAssetUpdate = ({ asset }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => { + + const handleUpdateOrUpload = (asset: AssetResponseDto) => { if (asset.id === assetCursor.current.id) { void loadCloseAssets(asset); } }; + onMount(() => { const unsubscribes = [ - websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => onAssetUpdate({ event: 'upload', asset })), - websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => onAssetUpdate({ event: 'update', asset })), + websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)), + websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)), ]; return () => { for (const unsubscribe of unsubscribes) { @@ -216,6 +205,10 @@ } }; }); + + onDestroy(() => { + assetCacheManager.invalidate(); + }); {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} @@ -234,8 +227,6 @@ assetCacheManager.invalidate(); }} onUndoDelete={handleUndoDelete} - onPrevious={() => 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 16155d44c0..aba2dc01f4 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 @@ -23,7 +23,6 @@ let { assets, onResolve, onStack }: Props = $props(); const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore; - const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id); // eslint-disable-next-line svelte/no-unnecessary-state-wrap let selectedAssetIds = $state(new SvelteSet()); @@ -44,24 +43,6 @@ assetViewingStore.showAssetViewer(false); }); - const onNext = async () => { - const index = getAssetIndex($viewingAsset.id) + 1; - if (index >= assets.length) { - return false; - } - await onViewAsset(assets[index]); - return true; - }; - - const onPrevious = async () => { - const index = getAssetIndex($viewingAsset.id) - 1; - if (index < 0) { - return false; - } - await onViewAsset(assets[index]); - return true; - }; - const onRandom = async () => { if (assets.length <= 0) { return; @@ -191,8 +172,6 @@ 1} - {onNext} - {onPrevious} {onRandom} onClose={() => { assetViewingStore.showAssetViewer(false); diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index 00e0224a0e..3cd2cd9579 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -5,7 +5,6 @@ import { readonly, writable } from 'svelte/store'; function createAssetViewingStore() { const viewingAssetStoreState = writable(); - const viewState = writable(false); const gridScrollTarget = writable(); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 64a6607558..da3145cd9a 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -508,11 +508,13 @@ export const delay = async (ms: number) => { }; export const getNextAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => { - return currentAsset && assets[assets.indexOf(currentAsset) + 1]; + const index = currentAsset ? assets.findIndex((a) => a.id === currentAsset.id) : -1; + return index >= 0 ? assets[index + 1] : undefined; }; export const getPreviousAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => { - return currentAsset && assets[assets.indexOf(currentAsset) - 1]; + const index = currentAsset ? assets.findIndex((a) => a.id === currentAsset.id) : -1; + return index >= 0 ? assets[index - 1] : undefined; }; export const canCopyImageToClipboard = (): boolean => { @@ -547,3 +549,12 @@ export const copyImageToClipboard = async (source: HTMLImageElement) => { // do not await, so the Safari clipboard write happens in the context of the user gesture await navigator.clipboard.write([new ClipboardItem({ ['image/png']: imgToBlob(source) })]); }; + +export const navigateToAsset = async (targetAsset: AssetResponseDto | undefined | null) => { + if (!targetAsset) { + return false; + } + + await navigate({ targetRoute: 'current', assetId: targetAsset.id }); + return true; +}; diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2e73eb6876..a87f104b77 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -103,7 +103,6 @@ {#if data.pathAssets && data.pathAssets.length > 0}
{ assetViewingStore.showAssetViewer(false); @@ -36,28 +35,9 @@ 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; - } - return false; - } - - async function navigatePrevious() { - if (viewingAssetCursor > 0) { - await setAssetId(viewingAssets[--viewingAssetCursor]); - await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); - return true; - } - return false; - } - async function navigateRandom() { if (viewingAssets.length <= 0) { return undefined; @@ -138,13 +118,11 @@
- {#if $showAssetViewer} + {#if $showAssetViewer && assetCursor.current} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} - onNext={navigateNext} - onPrevious={navigatePrevious} onRandom={navigateRandom} onClose={() => { assetViewingStore.showAssetViewer(false); 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 15f4b233eb..f668e6e970 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 @@ -20,32 +20,12 @@ let assets = $derived(data.assets); let asset = $derived(data.asset); const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore; - const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id); - $effect(() => { if (asset) { setAsset(asset); } }); - const onNext = async () => { - const index = getAssetIndex($viewingAsset.id) + 1; - if (index >= assets.length) { - return false; - } - await onViewAsset(assets[index]); - return true; - }; - - const onPrevious = async () => { - const index = getAssetIndex($viewingAsset.id) - 1; - if (index < 0) { - return false; - } - await onViewAsset(assets[index]); - return true; - }; - const onRandom = async () => { if (assets.length <= 0) { return undefined; @@ -94,8 +74,6 @@ 1} - {onNext} - {onPrevious} {onRandom} {onAction} onClose={() => {