feat: redesign asset-viewer previous/next and hide when nav not possible (#24903)

This commit is contained in:
Min Idzelis 2026-01-15 06:55:01 -05:00 committed by GitHub
parent d59ee7d2ae
commit 80a5444bf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 299 additions and 321 deletions

View file

@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { import {
Changes, Changes,
createDefaultTimelineConfig, createDefaultTimelineConfig,
@ -58,6 +58,120 @@ test.describe('asset-viewer', () => {
}); });
test.describe('/photos/:id', () => { 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 }) => { test('Delete photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng); const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);

View file

@ -3,7 +3,7 @@ import { Page, expect, test } from '@playwright/test';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
function imageLocator(page: Page) { 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', () => { test.describe('Photo Viewer', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;

View file

@ -19,6 +19,7 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils'; import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions'; import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker'; import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { SlideshowHistory } from '$lib/utils/slideshow-history';
@ -52,8 +53,6 @@
import SlideshowBar from './slideshow-bar.svelte'; import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte'; import VideoViewer from './video-wrapper-viewer.svelte';
type HasAsset = boolean;
export type AssetCursor = { export type AssetCursor = {
current: AssetResponseDto; current: AssetResponseDto;
nextAsset?: AssetResponseDto; nextAsset?: AssetResponseDto;
@ -72,9 +71,7 @@
onAction?: OnAction; onAction?: OnAction;
onUndoDelete?: OnUndoDelete; onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void; onClose?: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>; onRandom?: () => Promise<{ id: string } | undefined>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>; copyImage?: () => Promise<void>;
} }
@ -90,8 +87,6 @@
onAction, onAction,
onUndoDelete, onUndoDelete,
onClose, onClose,
onNext,
onPrevious,
onRandom, onRandom,
copyImage = $bindable(), copyImage = $bindable(),
}: Props = $props(); }: Props = $props();
@ -108,6 +103,8 @@
const stackSelectedThumbnailSize = 65; const stackSelectedThumbnailSize = 65;
const asset = $derived(cursor.current); const asset = $derived(cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let appearsInAlbums: AlbumResponseDto[] = $state([]); let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink(); let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state(); let previewStackedAsset: AssetResponseDto | undefined = $state();
@ -235,14 +232,15 @@
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) { if (!hasNext) {
const asset = await onRandom(); const asset = await onRandom?.();
if (asset) { if (asset) {
slideshowHistory.queue(asset); slideshowHistory.queue(asset);
hasNext = true; hasNext = true;
} }
} }
} else { } else {
hasNext = order === 'previous' ? await onPrevious() : await onNext(); hasNext =
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
} }
if ($slideshowState === SlideshowState.PlaySlideshow) { if ($slideshowState === SlideshowState.PlaySlideshow) {
@ -383,7 +381,6 @@
await ocrManager.getAssetOcr(asset.id); await ocrManager.getAssetOcr(asset.id);
} }
}; };
$effect(() => { $effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset; asset;
@ -406,6 +403,42 @@
cursor.current = update; 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,
);
</script> </script>
<OnEvents {onAssetReplace} {onAssetUpdate} /> <OnEvents {onAssetReplace} {onAssetUpdate} />
@ -442,7 +475,7 @@
{/if} {/if}
{#if $slideshowState != SlideshowState.None} {#if $slideshowState != SlideshowState.None}
<div class="absolute w-full flex"> <div class="absolute w-full flex justify-center">
<SlideshowBar <SlideshowBar
{isFullScreen} {isFullScreen}
assetType={previewStackedAsset?.type ?? asset.type} assetType={previewStackedAsset?.type ?? asset.type}
@ -454,109 +487,97 @@
</div> </div>
{/if} {/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && previousAsset}
<div class="my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start"> <div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} /> <PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div> </div>
{/if} {/if}
<!-- Asset Viewer --> <!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full"> <div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if previewStackedAsset} {#if viewerKind === 'StackPhotoViewer'}
{#key previewStackedAsset.id} <PhotoViewer
{#if previewStackedAsset.type === AssetTypeEnum.Image} bind:zoomToggle
<PhotoViewer bind:copyImage
bind:zoomToggle cursor={{ ...cursor, current: previewStackedAsset! }}
bind:copyImage onPreviousAsset={() => navigateAsset('previous')}
cursor={{ ...cursor, current: previewStackedAsset }} onNextAsset={() => navigateAsset('next')}
onPreviousAsset={() => navigateAsset('previous')} haveFadeTransition={false}
onNextAsset={() => navigateAsset('next')} {sharedLink}
haveFadeTransition={false} />
{sharedLink} {:else if viewerKind === 'StackVideoViewer'}
/> <VideoViewer
{:else} assetId={previewStackedAsset!.id}
<VideoViewer cacheKey={previewStackedAsset!.thumbhash}
assetId={previewStackedAsset.id} projectionType={previewStackedAsset!.exifInfo?.projectionType}
cacheKey={previewStackedAsset.thumbhash} loopVideo={true}
projectionType={previewStackedAsset.exifInfo?.projectionType} onPreviousAsset={() => navigateAsset('previous')}
loopVideo={true} onNextAsset={() => navigateAsset('next')}
onPreviousAsset={() => navigateAsset('previous')} onClose={closeViewer}
onNextAsset={() => navigateAsset('next')} onVideoEnded={() => navigateAsset()}
onClose={closeViewer} onVideoStarted={handleVideoStarted}
onVideoEnded={() => navigateAsset()} {playOriginalVideo}
onVideoStarted={handleVideoStarted} />
{playOriginalVideo} {:else if viewerKind === 'LiveVideoViewer'}
/> <VideoViewer
{/if} assetId={asset.livePhotoVideoId!}
{/key} cacheKey={asset.thumbhash}
{:else} projectionType={asset.exifInfo?.projectionType}
{#key asset.id} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
{#if asset.type === AssetTypeEnum.Image} onPreviousAsset={() => navigateAsset('previous')}
{#if assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId} onNextAsset={() => navigateAsset('next')}
<VideoViewer onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
assetId={asset.livePhotoVideoId} {playOriginalVideo}
cacheKey={asset.thumbhash} />
projectionType={asset.exifInfo?.projectionType} {:else if viewerKind === 'ImagePanaramaViewer'}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} <ImagePanoramaViewer bind:zoomToggle {asset} />
onPreviousAsset={() => navigateAsset('previous')} {:else if viewerKind === 'CropArea'}
onNextAsset={() => navigateAsset('next')} <CropArea {asset} />
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)} {:else if viewerKind === 'PhotoViewer'}
{playOriginalVideo} <PhotoViewer
/> bind:zoomToggle
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath bind:copyImage
.toLowerCase() {cursor}
.endsWith('.insp'))} onPreviousAsset={() => navigateAsset('previous')}
<ImagePanoramaViewer bind:zoomToggle {asset} /> onNextAsset={() => navigateAsset('next')}
{:else if isShowEditor && editManager.selectedTool?.type === EditToolType.Transform} {sharedLink}
<CropArea {asset} /> haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
{:else} />
<PhotoViewer {:else if viewerKind === 'VideoViewer'}
bind:zoomToggle <VideoViewer
bind:copyImage assetId={asset.id}
{cursor} cacheKey={asset.thumbhash}
onPreviousAsset={() => navigateAsset('previous')} projectionType={asset.exifInfo?.projectionType}
onNextAsset={() => navigateAsset('next')} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
{sharedLink} onPreviousAsset={() => navigateAsset('previous')}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} onNextAsset={() => navigateAsset('next')}
/> onClose={closeViewer}
{/if} onVideoEnded={() => navigateAsset()}
{:else} onVideoStarted={handleVideoStarted}
<VideoViewer {playOriginalVideo}
assetId={asset.id} />
cacheKey={asset.thumbhash} {/if}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => 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 showActivityStatus}
<div class="absolute bottom-0 end-0 mb-20 me-8"> <div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus <ActivityStatus
disabled={!album?.isActivityEnabled} disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked} isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount} numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount} numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite} onFavorite={handleFavorite}
/> />
</div> </div>
{/if} {/if}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData} {#if showOcrButton}
<div class="absolute bottom-0 end-0 mb-6 me-6"> <div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton /> <OcrButton />
</div> </div>
{/if}
{/key}
{/if} {/if}
</div> </div>
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"> <div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} /> <NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div> </div>

View file

@ -22,7 +22,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk'; import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui'; import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@ -164,11 +164,7 @@
imageError = imageLoaded = true; imageError = imageLoaded = true;
}; };
onMount(() => { onDestroy(() => preloadManager.cancelPreloadUrl(imageLoaderUrl));
return () => {
preloadManager.cancelPreloadUrl(imageLoaderUrl);
};
});
let imageLoaderUrl = $derived( let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }), getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
@ -181,9 +177,11 @@
$effect(() => { $effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) { if (lastUrl && lastUrl !== imageLoaderUrl) {
imageLoaded = false; untrack(() => {
originalImageLoaded = false; imageLoaded = false;
imageError = false; originalImageLoaded = false;
imageError = false;
});
} }
lastUrl = imageLoaderUrl; lastUrl = imageLoaderUrl;
}); });

View file

@ -26,7 +26,7 @@
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; 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 { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
@ -651,8 +651,6 @@
bind:this={memoryGallery} bind:this={memoryGallery}
> >
<GalleryViewer <GalleryViewer
onNext={handleNextAsset}
onPrevious={handlePreviousAsset}
assets={currentTimelineAssets} assets={currentTimelineAssets}
viewport={galleryViewport} viewport={galleryViewport}
{assetInteraction} {assetInteraction}

View file

@ -144,13 +144,7 @@
{:else if assets.length === 1} {:else if assets.length === 1}
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer <AssetViewer cursor={{ current: asset }} onAction={handleAction} />
cursor={{ current: asset }}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)}
/>
{/await} {/await}
{/await} {/await}
{/if} {/if}

View file

@ -14,7 +14,13 @@
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions'; 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 { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
@ -26,7 +32,6 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
initialAssetId?: string;
assets: AssetResponseDto[]; assets: AssetResponseDto[];
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
disableAssetSelect?: boolean; disableAssetSelect?: boolean;
@ -34,9 +39,6 @@
viewport: Viewport; viewport: Viewport;
onIntersected?: (() => void) | undefined; onIntersected?: (() => void) | undefined;
showAssetName?: boolean; showAssetName?: boolean;
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
onReload?: (() => void) | undefined; onReload?: (() => void) | undefined;
pageHeaderOffset?: number; pageHeaderOffset?: number;
slidingWindowOffset?: number; slidingWindowOffset?: number;
@ -44,7 +46,6 @@
}; };
let { let {
initialAssetId = undefined,
assets = $bindable(), assets = $bindable(),
assetInteraction, assetInteraction,
disableAssetSelect = false, disableAssetSelect = false,
@ -52,16 +53,13 @@
viewport, viewport,
onIntersected = undefined, onIntersected = undefined,
showAssetName = false, showAssetName = false,
onPrevious = undefined,
onNext = undefined,
onRandom = undefined,
onReload = undefined, onReload = undefined,
slidingWindowOffset = 0, slidingWindowOffset = 0,
pageHeaderOffset = 0, pageHeaderOffset = 0,
arrowNavigation = true, arrowNavigation = true,
}: Props = $props(); }: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore; let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
const geometry = $derived( const geometry = $derived(
getJustifiedLayoutFromAssets(assets, { getJustifiedLayoutFromAssets(assets, {
@ -84,14 +82,6 @@
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top; 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 shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null); let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let scrollTop = $state(0); let scrollTop = $state(0);
@ -105,7 +95,8 @@
}); });
const updateCurrentAsset = (asset: AssetResponseDto) => { 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); 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 = () => { const selectAllAssets = () => {
assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a))); assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
@ -294,47 +280,13 @@
})(), })(),
); );
const handleNext = async (): Promise<boolean> => {
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> => { const handleRandom = async (): Promise<{ id: string } | undefined> => {
if (assets.length === 0) {
return;
}
try { try {
let asset: { id: string } | undefined; const randomIndex = Math.floor(Math.random() * assets.length);
if (onRandom) { const asset = assets[randomIndex];
asset = await onRandom();
} else {
if (assets.length > 0) {
const randomIndex = Math.floor(Math.random() * assets.length);
asset = assets[randomIndex];
}
}
if (!asset) {
return;
}
await navigateToAsset(asset); await navigateToAsset(asset);
return asset; return asset;
@ -344,39 +296,6 @@
} }
}; };
const handlePrevious = async (): Promise<boolean> => {
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) => { const handleAction = async (action: Action) => {
switch (action.type) { switch (action.type) {
case AssetAction.ARCHIVE: case AssetAction.ARCHIVE:
@ -387,11 +306,12 @@
1, 1,
); );
if (assets.length === 0) { if (assets.length === 0) {
await goto(AppRoute.PHOTOS); return await goto(AppRoute.PHOTOS);
} else if (currentIndex === assets.length) { }
await handlePrevious(); if (assetCursor.nextAsset) {
} else { await navigateToAsset(assetCursor.nextAsset);
await setAssetId(assets[currentIndex].id); } else if (assetCursor.previousAsset) {
await navigateToAsset(assetCursor.previousAsset);
} }
break; break;
} }
@ -454,7 +374,7 @@
handleSelectAssets(currentAsset); handleSelectAssets(currentAsset);
return; return;
} }
void viewAssetHandler(currentAsset); void navigateToAsset(asset);
}} }}
onSelect={() => handleSelectAssets(currentAsset)} onSelect={() => handleSelectAssets(currentAsset)}
onMouseEvent={() => assetMouseEventHandler(currentAsset)} onMouseEvent={() => assetMouseEventHandler(currentAsset)}
@ -485,8 +405,6 @@
<AssetViewer <AssetViewer
cursor={assetCursor} cursor={assetCursor}
onAction={handleAction} onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom} onRandom={handleRandom}
onAssetChange={updateCurrentAsset} onAssetChange={updateCurrentAsset}
onClose={() => { onClose={() => {

View file

@ -10,6 +10,7 @@
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk'; import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
@ -24,7 +25,6 @@
isShared?: boolean; isShared?: boolean;
album?: AlbumResponseDto; album?: AlbumResponseDto;
person?: PersonResponseDto; person?: PersonResponseDto;
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null; removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null;
} }
@ -41,7 +41,7 @@
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => { const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset); const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (earlierTimelineAsset) { if (earlierTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id }); const asset = await assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id });
if (preload) { if (preload) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete // also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getNextAsset(asset, false); void getNextAsset(asset, false);
@ -52,9 +52,8 @@
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => { const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset); const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
if (laterTimelineAsset) { if (laterTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id }); const asset = await assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id });
if (preload) { if (preload) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete // also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getPreviousAsset(asset, false); void getPreviousAsset(asset, false);
@ -86,15 +85,6 @@
untrack(() => handlePromiseError(loadCloseAssets($viewingAsset))); 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 handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset(); const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) { if (randomAsset) {
@ -124,8 +114,8 @@
// find the next asset to show or close the viewer // find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNavigateToAsset(assetCursor?.nextAsset)) || (await navigateToAsset(assetCursor?.nextAsset)) ||
(await handleNavigateToAsset(assetCursor?.previousAsset)) || (await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset)); (await handleClose(action.asset));
break; break;
@ -197,18 +187,17 @@
await navigate({ targetRoute: 'current', assetId: restoredAsset.id }); await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
} }
}; };
onDestroy(() => {
assetCacheManager.invalidate(); const handleUpdateOrUpload = (asset: AssetResponseDto) => {
});
const onAssetUpdate = ({ asset }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
if (asset.id === assetCursor.current.id) { if (asset.id === assetCursor.current.id) {
void loadCloseAssets(asset); void loadCloseAssets(asset);
} }
}; };
onMount(() => { onMount(() => {
const unsubscribes = [ const unsubscribes = [
websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => onAssetUpdate({ event: 'upload', asset })), websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)),
websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => onAssetUpdate({ event: 'update', asset })), websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)),
]; ];
return () => { return () => {
for (const unsubscribe of unsubscribes) { for (const unsubscribe of unsubscribes) {
@ -216,6 +205,10 @@
} }
}; };
}); });
onDestroy(() => {
assetCacheManager.invalidate();
});
</script> </script>
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
@ -234,8 +227,6 @@
assetCacheManager.invalidate(); assetCacheManager.invalidate();
}} }}
onUndoDelete={handleUndoDelete} onUndoDelete={handleUndoDelete}
onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)}
onNext={() => handleNavigateToAsset(assetCursor.nextAsset)}
onRandom={handleRandom} onRandom={handleRandom}
onClose={handleClose} onClose={handleClose}
/> />

View file

@ -23,7 +23,6 @@
let { assets, onResolve, onStack }: Props = $props(); let { assets, onResolve, onStack }: Props = $props();
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore; 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 // eslint-disable-next-line svelte/no-unnecessary-state-wrap
let selectedAssetIds = $state(new SvelteSet<string>()); let selectedAssetIds = $state(new SvelteSet<string>());
@ -44,24 +43,6 @@
assetViewingStore.showAssetViewer(false); 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 () => { const onRandom = async () => {
if (assets.length <= 0) { if (assets.length <= 0) {
return; return;
@ -191,8 +172,6 @@
<AssetViewer <AssetViewer
cursor={assetCursor} cursor={assetCursor}
showNavigation={assets.length > 1} showNavigation={assets.length > 1}
{onNext}
{onPrevious}
{onRandom} {onRandom}
onClose={() => { onClose={() => {
assetViewingStore.showAssetViewer(false); assetViewingStore.showAssetViewer(false);

View file

@ -5,7 +5,6 @@ import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() { function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>(); const viewingAssetStoreState = writable<AssetResponseDto>();
const viewState = writable<boolean>(false); const viewState = writable<boolean>(false);
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>(); const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();

View file

@ -508,11 +508,13 @@ export const delay = async (ms: number) => {
}; };
export const getNextAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => { 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) => { 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 => { 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 // 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) })]); 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;
};

View file

@ -103,7 +103,6 @@
{#if data.pathAssets && data.pathAssets.length > 0} {#if data.pathAssets && data.pathAssets.length > 0}
<div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2"> <div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2">
<GalleryViewer <GalleryViewer
initialAssetId={data.asset?.id}
assets={data.pathAssets} assets={data.pathAssets}
{assetInteraction} {assetInteraction}
{viewport} {viewport}

View file

@ -24,7 +24,6 @@
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore; let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let viewingAssets: string[] = $state([]); let viewingAssets: string[] = $state([]);
let viewingAssetCursor = 0;
onDestroy(() => { onDestroy(() => {
assetViewingStore.showAssetViewer(false); assetViewingStore.showAssetViewer(false);
@ -36,28 +35,9 @@
async function onViewAssets(assetIds: string[]) { async function onViewAssets(assetIds: string[]) {
viewingAssets = assetIds; viewingAssets = assetIds;
viewingAssetCursor = 0;
await setAssetId(assetIds[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() { async function navigateRandom() {
if (viewingAssets.length <= 0) { if (viewingAssets.length <= 0) {
return undefined; return undefined;
@ -138,13 +118,11 @@
</div> </div>
</UserPageLayout> </UserPageLayout>
<Portal target="body"> <Portal target="body">
{#if $showAssetViewer} {#if $showAssetViewer && assetCursor.current}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer <AssetViewer
cursor={assetCursor} cursor={assetCursor}
showNavigation={viewingAssets.length > 1} showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
onRandom={navigateRandom} onRandom={navigateRandom}
onClose={() => { onClose={() => {
assetViewingStore.showAssetViewer(false); assetViewingStore.showAssetViewer(false);

View file

@ -20,32 +20,12 @@
let assets = $derived(data.assets); let assets = $derived(data.assets);
let asset = $derived(data.asset); let asset = $derived(data.asset);
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore; const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
$effect(() => { $effect(() => {
if (asset) { if (asset) {
setAsset(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 () => { const onRandom = async () => {
if (assets.length <= 0) { if (assets.length <= 0) {
return undefined; return undefined;
@ -94,8 +74,6 @@
<AssetViewer <AssetViewer
cursor={assetCursor} cursor={assetCursor}
showNavigation={assets.length > 1} showNavigation={assets.length > 1}
{onNext}
{onPrevious}
{onRandom} {onRandom}
{onAction} {onAction}
onClose={() => { onClose={() => {