feat: improve asset-viewer next/prev perf and standardize preloading behavior (#24422)

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2026-01-07 15:17:12 -05:00 committed by GitHub
parent 81f269e2a9
commit 78229baeab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 529 additions and 433 deletions

View file

@ -26,6 +26,5 @@ export const makeRandomImage = () => {
if (!value) { if (!value) {
throw new Error('Ran out of random asset data'); throw new Error('Ran out of random asset data');
} }
return value; return value;
}; };

View file

@ -761,9 +761,6 @@ importers:
'@zoom-image/svelte': '@zoom-image/svelte':
specifier: ^0.3.0 specifier: ^0.3.0
version: 0.3.8(svelte@5.46.1) version: 0.3.8(svelte@5.46.1)
async-mutex:
specifier: ^0.5.0
version: 0.5.0
dom-to-image: dom-to-image:
specifier: ^2.6.0 specifier: ^2.6.0
version: 2.6.0 version: 2.6.0
@ -5620,9 +5617,6 @@ packages:
async-lock@1.4.1: async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
async-mutex@0.5.0:
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
async@0.2.10: async@0.2.10:
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
@ -18001,10 +17995,6 @@ snapshots:
async-lock@1.4.1: {} async-lock@1.4.1: {}
async-mutex@0.5.0:
dependencies:
tslib: 2.8.1
async@0.2.10: {} async@0.2.10: {}
async@3.2.6: {} async@3.2.6: {}

View file

@ -39,7 +39,6 @@
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.41.0", "@zoom-image/core": "^0.41.0",
"@zoom-image/svelte": "^0.3.0", "@zoom-image/svelte": "^0.3.0",
"async-mutex": "^0.5.0",
"dom-to-image": "^2.6.0", "dom-to-image": "^2.6.0",
"fabric": "^6.5.4", "fabric": "^6.5.4",
"geo-coordinates-parser": "^1.7.4", "geo-coordinates-parser": "^1.7.4",

View file

@ -10,18 +10,19 @@
import { activityManager } from '$lib/managers/activity-manager.svelte'; import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket'; import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions'; import type { OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { preloadImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
AssetJobName, AssetJobName,
@ -53,17 +54,22 @@
type HasAsset = boolean; type HasAsset = boolean;
export type AssetCursor = {
current: AssetResponseDto;
nextAsset?: AssetResponseDto;
previousAsset?: AssetResponseDto;
};
interface Props { interface Props {
asset: AssetResponseDto; cursor: AssetCursor;
preloadAssets?: TimelineAsset[];
showNavigation?: boolean; showNavigation?: boolean;
withStacked?: boolean; withStacked?: boolean;
isShared?: boolean; isShared?: boolean;
album?: AlbumResponseDto | null; album?: AlbumResponseDto;
person?: PersonResponseDto | null; person?: PersonResponseDto;
preAction?: PreAction | undefined; preAction?: PreAction;
onAction?: OnAction | undefined; onAction?: OnAction;
onUndoDelete?: OnUndoDelete | undefined; onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void; onClose?: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>; onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>; onPrevious: () => Promise<HasAsset>;
@ -72,16 +78,15 @@
} }
let { let {
asset = $bindable(), cursor,
preloadAssets = $bindable([]),
showNavigation = true, showNavigation = true,
withStacked = false, withStacked = false,
isShared = false, isShared = false,
album = null, album,
person = null, person,
preAction = undefined, preAction,
onAction = undefined, onAction,
onUndoDelete = undefined, onUndoDelete,
onClose, onClose,
onNext, onNext,
onPrevious, onPrevious,
@ -100,6 +105,7 @@
const stackThumbnailSize = 60; const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65; const stackSelectedThumbnailSize = 65;
let asset = $derived(cursor.current);
let appearsInAlbums: AlbumResponseDto[] = $state([]); let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink(); let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state(); let previewStackedAsset: AssetResponseDto | undefined = $state();
@ -131,7 +137,7 @@
untrack(() => { untrack(() => {
if (stack && stack?.assets.length > 1) { if (stack && stack?.assets.length > 1) {
preloadAssets.push(toTimelineAsset(stack.assets[1])); preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
} }
}); });
}; };
@ -146,16 +152,8 @@
} }
}; };
const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
if (assetUpdate.id === asset.id) {
asset = assetUpdate;
}
};
onMount(async () => { onMount(async () => {
unsubscribes.push( unsubscribes.push(
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
slideshowState.subscribe((value) => { slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) { if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset(); slideshowHistory.reset();
@ -208,7 +206,9 @@
}); });
}; };
const navigateAsset = async (order?: 'previous' | 'next', e?: Event) => { const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
if (!order) { if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) { if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
@ -218,7 +218,12 @@
} }
e?.stopPropagation(); e?.stopPropagation();
preloadManager.cancel(asset);
if (tracker.isActive()) {
return;
}
void tracker.invoke(async () => {
let hasNext = false; let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
@ -241,15 +246,9 @@
await handleStopSlideshow(); await handleStopSlideshow();
} }
} }
});
}; };
// const showEditorHandler = () => {
// if (isShowActivity) {
// isShowActivity = false;
// }
// isShowEditor = !isShowEditor;
// };
const handleRunJob = async (name: AssetJobName) => { const handleRunJob = async (name: AssetJobName) => {
try { try {
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } }); await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
@ -362,12 +361,6 @@
let isFullScreen = $derived(fullscreenElement !== null); let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
if (asset) {
previewStackedAsset = undefined;
handlePromiseError(refreshStack());
}
});
$effect(() => { $effect(() => {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) { if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
assetViewerManager.closeActivityPanel(); assetViewerManager.closeActivityPanel();
@ -379,13 +372,24 @@
} }
}); });
// primarily, this is reactive on `asset` const refresh = async () => {
$effect(() => { await refreshStack();
handlePromiseError(handleGetAllAlbums()); await handleGetAllAlbums();
ocrManager.clear(); ocrManager.clear();
if (!sharedLink) { if (!sharedLink) {
handlePromiseError(ocrManager.getAssetOcr(asset.id)); if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
} }
await ocrManager.getAssetOcr(asset.id);
}
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => handlePromiseError(refresh()));
preloadManager.preload(cursor.nextAsset);
preloadManager.preload(cursor.previousAsset);
}); });
</script> </script>
@ -449,8 +453,7 @@
<PhotoViewer <PhotoViewer
bind:zoomToggle bind:zoomToggle
bind:copyImage bind:copyImage
asset={previewStackedAsset} cursor={{ ...cursor, current: previewStackedAsset }}
{preloadAssets}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false} haveFadeTransition={false}
@ -495,8 +498,7 @@
<PhotoViewer <PhotoViewer
bind:zoomToggle bind:zoomToggle
bind:copyImage bind:copyImage
{asset} {cursor}
{preloadAssets}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
{sharedLink} {sharedLink}

View file

@ -1,210 +0,0 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte';
import * as utils from '$lib/utils';
import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
import { render } from '@testing-library/svelte';
import type { MockInstance } from 'vitest';
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserver;
vi.mock('$lib/utils', async (originalImport) => {
const meta = await originalImport<typeof import('$lib/utils')>();
return {
...meta,
getAssetOriginalUrl: vi.fn(),
getAssetThumbnailUrl: vi.fn(),
};
});
describe('PhotoViewer component', () => {
let getAssetOriginalUrlSpy: MockInstance;
let getAssetThumbnailUrlSpy: MockInstance;
beforeAll(() => {
getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl');
getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl');
vi.stubGlobal('cast', {
framework: {
CastState: {
NO_DEVICES_AVAILABLE: 'NO_DEVICES_AVAILABLE',
},
RemotePlayer: vi.fn().mockImplementation(() => ({})),
RemotePlayerEventType: {
ANY_CHANGE: 'anyChanged',
},
RemotePlayerController: vi.fn().mockImplementation(() => ({ addEventListener: vi.fn() })),
CastContext: {
getInstance: vi.fn().mockImplementation(() => ({ setOptions: vi.fn(), addEventListener: vi.fn() })),
},
CastContextEventType: {
SESSION_STATE_CHANGED: 'sessionstatechanged',
CAST_STATE_CHANGED: 'caststatechanged',
},
},
});
vi.stubGlobal('chrome', {
cast: { media: { PlayerState: { IDLE: 'IDLE' } }, AutoJoinPolicy: { ORIGIN_SCOPED: 'origin_scoped' } },
});
});
beforeEach(() => {
Element.prototype.animate = getAnimateMock();
});
afterEach(() => {
vi.resetAllMocks();
});
it('loads the thumbnail', () => {
const asset = assetFactory.build({
originalPath: 'image.jpg',
originalMimeType: 'image/jpeg',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for animated gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('loads the original image for animated webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original animated image when shared link download permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('not loads original animated image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
});

View file

@ -6,32 +6,30 @@
import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants'; import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte'; 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 { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store'; 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 { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store'; import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils'; import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui'; import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } 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';
import type { AssetCursor } from './asset-viewer.svelte';
interface Props { interface Props {
asset: AssetResponseDto; cursor: AssetCursor;
preloadAssets?: TimelineAsset[] | undefined;
element?: HTMLDivElement | undefined; element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean; haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined; sharedLink?: SharedLinkResponseDto | undefined;
@ -42,8 +40,7 @@
} }
let { let {
asset, cursor,
preloadAssets = undefined,
element = $bindable(), element = $bindable(),
haveFadeTransition = true, haveFadeTransition = true,
sharedLink = undefined, sharedLink = undefined,
@ -54,8 +51,8 @@
}: Props = $props(); }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore; const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false); let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false); let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false); let imageError: boolean = $state(false);
@ -82,25 +79,6 @@
let isOcrActive = $derived(ocrManager.showOverlay); 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 () => { copyImage = async () => {
if (!canCopyImageToClipboard() || !$photoViewerImgElement) { if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
return; return;
@ -155,23 +133,11 @@
} }
}; };
// when true, will force loading of the original image const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
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;
});
$effect(() => { $effect(() => {
if (assetFileUrl) { if (imageLoaderUrl) {
void cast(assetFileUrl); void cast(imageLoaderUrl);
} }
}); });
@ -191,7 +157,6 @@
const onload = () => { const onload = () => {
imageLoaded = true; imageLoaded = true;
assetFileUrl = imageLoaderUrl;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original'; originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
}; };
@ -199,27 +164,29 @@
imageError = imageLoaded = true; imageError = imageLoaded = true;
}; };
$effect(() => {
preload(targetImageSize, preloadAssets);
});
onMount(() => { onMount(() => {
if (loader?.complete) {
onload();
}
loader?.addEventListener('load', onload, { passive: true });
loader?.addEventListener('error', onerror, { passive: true });
return () => { return () => {
loader?.removeEventListener('load', onload); preloadManager.cancelPreloadUrl(imageLoaderUrl);
loader?.removeEventListener('error', onerror);
cancelImageUrl(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 containerWidth = $state(0);
let containerHeight = $state(0); let containerHeight = $state(0);
let lastUrl: string | undefined;
$effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
}
lastUrl = imageLoaderUrl;
});
</script> </script>
<svelte:document <svelte:document
@ -232,19 +199,17 @@
]} ]}
/> />
{#if imageError} {#if imageError}
<div class="h-full w-full"> <div id="broken-asset" class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" /> <BrokenAsset class="text-xl h-full w-full" />
</div> </div>
{/if} {/if}
<!-- svelte-ignore a11y_missing_attribute --> <img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<div <div
bind:this={element} bind:this={element}
class="relative h-full select-none" class="relative h-full w-full select-none"
bind:clientWidth={containerWidth} bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight} bind:clientHeight={containerHeight}
> >
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#if !imageLoaded} {#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center"> <div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner /> <LoadingSpinner />
@ -258,7 +223,7 @@
> >
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img <img
src={assetFileUrl} src={imageLoaderUrl}
alt="" alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg" class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false" draggable="false"
@ -266,7 +231,7 @@
{/if} {/if}
<img <img
bind:this={$photoViewerImgElement} bind:this={$photoViewerImgElement}
src={assetFileUrl} src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))} alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain' ? 'object-contain'
@ -298,6 +263,7 @@
visibility: visible; visibility: visible;
} }
} }
#broken-asset,
#spinner { #spinner {
visibility: hidden; visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility; animation: 0s linear 0.4s forwards delayedVisibility;

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js'; import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action'; import type { ActionReturn } from 'svelte/action';
@ -60,7 +60,7 @@
onComplete?.(false); onComplete?.(false);
} }
return { return {
destroy: () => cancelImageUrl(url), destroy: () => preloadManager.cancelPreloadUrl(url),
}; };
} }

View file

@ -32,7 +32,7 @@
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, getAssetInfo } from '@immich/sdk'; import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui'; import { IconButton, toastManager } from '@immich/ui';
import { import {
mdiCardsOutline, mdiCardsOutline,
@ -67,7 +67,7 @@
let currentMemoryAssetFull = $derived.by(async () => let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined, current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
); );
let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []); let currentTimelineAssets = $derived(current?.memory.assets || []);
let isSaved = $derived(current?.memory.isSaved); let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0); let viewerHeight = $state(0);
@ -396,7 +396,7 @@
</p> </p>
</div> </div>
{#if currentTimelineAssets.some(({ isVideo }) => isVideo)} {#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
<div class="w-12.5 dark"> <div class="w-12.5 dark">
<IconButton <IconButton
shape="round" shape="round"

View file

@ -32,7 +32,7 @@
const viewport: Viewport = $state({ width: 0, height: 0 }); const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
let assets = $derived(sharedLink.assets.map((a) => toTimelineAsset(a))); let assets = $derived(sharedLink.assets);
dragAndDropFilesStore.subscribe((value) => { dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) { if (value.isDragging && value.files.length > 0) {
@ -68,7 +68,7 @@
}; };
const handleSelectAll = () => { const handleSelectAll = () => {
assetInteraction.selectAssets(assets); assetInteraction.selectAssets(assets.map((asset) => toTimelineAsset(asset)));
}; };
const handleAction = async (action: Action) => { const handleAction = async (action: Action) => {
@ -145,7 +145,7 @@
{#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
{asset} cursor={{ current: asset }}
onAction={handleAction} onAction={handleAction}
onPrevious={() => Promise.resolve(false)} onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)} onNext={() => Promise.resolve(false)}

View file

@ -13,7 +13,7 @@
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 } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, getNextAsset, getPreviousAsset } 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';
@ -27,7 +27,7 @@
interface Props { interface Props {
initialAssetId?: string; initialAssetId?: string;
assets: TimelineAsset[] | AssetResponseDto[]; assets: AssetResponseDto[];
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
disableAssetSelect?: boolean; disableAssetSelect?: boolean;
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
@ -229,7 +229,7 @@
isShowDeleteConfirmation = false; isShowDeleteConfirmation = false;
await deleteAssets( await deleteAssets(
!(isTrashEnabled && !force), !(isTrashEnabled && !force),
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]), (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
assetInteraction.selectedAssets, assetInteraction.selectedAssets,
onReload, onReload,
); );
@ -242,7 +242,7 @@
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive, assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
); );
if (ids) { if (ids) {
assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[]; assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets(); deselectAllAssets();
} }
}; };
@ -424,6 +424,12 @@
selectAssetCandidates(lastAssetMouseEvent); selectAssetCandidates(lastAssetMouseEvent);
} }
}); });
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
});
</script> </script>
<svelte:document <svelte:document
@ -488,7 +494,7 @@
<Portal target="body"> <Portal target="body">
{#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
asset={$viewingAsset} cursor={assetCursor}
onAction={handleAction} onAction={handleAction}
onPrevious={handlePrevious} onPrevious={handlePrevious}
onNext={handleNext} onNext={handleNext}

View file

@ -7,13 +7,13 @@
import Scrubber from '$lib/components/timeline/Scrubber.svelte'; import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte'; import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte'; import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
@ -49,9 +49,9 @@
withStacked?: boolean; withStacked?: boolean;
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
isShared?: boolean; isShared?: boolean;
album?: AlbumResponseDto | null; album?: AlbumResponseDto;
albumUsers?: UserResponseDto[]; albumUsers?: UserResponseDto[];
person?: PersonResponseDto | null; person?: PersonResponseDto;
isShowDeleteConfirmation?: boolean; isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void; onSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void; onEscape?: () => void;
@ -82,9 +82,9 @@
withStacked = false, withStacked = false,
showArchiveIcon = false, showArchiveIcon = false,
isShared = false, isShared = false,
album = null, album,
albumUsers = [], albumUsers = [],
person = null, person,
isShowDeleteConfirmation = $bindable(false), isShowDeleteConfirmation = $bindable(false),
onSelect = () => {}, onSelect = () => {},
onEscape = () => {}, onEscape = () => {},

View file

@ -1,24 +1,29 @@
<script lang="ts"> <script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
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 { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
import { onMount, untrack } from 'svelte';
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore; let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
interface Props { interface Props {
timelineManager: TimelineManager; timelineManager: TimelineManager;
invisible: boolean; invisible: boolean;
withStacked?: boolean; withStacked?: boolean;
isShared?: boolean; isShared?: boolean;
album?: AlbumResponseDto | null; album?: AlbumResponseDto;
person?: PersonResponseDto | null; person?: PersonResponseDto;
removeAction?: removeAction?:
| AssetAction.UNARCHIVE | AssetAction.UNARCHIVE
@ -35,48 +40,72 @@
removeAction, removeAction,
withStacked = false, withStacked = false,
isShared = false, isShared = false,
album = null, album,
person = null, person,
}: Props = $props(); }: Props = $props();
const handlePrevious = async () => { const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const release = await mutex.acquire(); const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
const laterAsset = await timelineManager.getLaterAsset($viewingAsset); if (earlierTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id });
if (laterAsset) { if (preload) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset); // also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id }); void getNextAsset(asset, false);
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []); }
await navigate({ targetRoute: 'current', assetId: laterAsset.id }); return asset;
} }
release();
return !!laterAsset;
}; };
const handleNext = async () => { const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const release = await mutex.acquire(); const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
if (earlierAsset) { if (laterTimelineAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset); const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id });
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id }); if (preload) {
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []); // also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
await navigate({ targetRoute: 'current', assetId: earlierAsset.id }); void getPreviousAsset(asset, false);
}
return asset;
}
};
let assetCursor = $state<AssetCursor>({
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(() => handlePromiseError(loadCloseAssets($viewingAsset)));
});
const handleNavigateToAsset = async (targetAsset: AssetResponseDto | undefined | null) => {
if (!targetAsset) {
return false;
} }
release(); await navigate({ targetRoute: 'current', assetId: targetAsset.id });
return !!earlierAsset; return true;
}; };
const handleRandom = async () => { const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset(); const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) { if (randomAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id }); await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset; return { id: randomAsset.id };
} }
}; };
@ -98,7 +127,9 @@
case AssetAction.SET_VISIBILITY_TIMELINE: { case AssetAction.SET_VISIBILITY_TIMELINE: {
// 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 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 // delete after find the next one
timelineManager.removeAssets([action.asset.id]); timelineManager.removeAssets([action.asset.id]);
@ -173,21 +204,36 @@
await navigate({ targetRoute: 'current', assetId: restoredAsset.id }); await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
} }
}; };
const onAssetUpdate = ({ asset }: { event: 'upload' | 'update'; 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 })),
];
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
});
</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 }}
<AssetViewer <AssetViewer
{withStacked} {withStacked}
asset={$viewingAsset} cursor={assetCursor}
preloadAssets={$preloadAssets}
{isShared} {isShared}
{album} {album}
{person} {person}
preAction={handlePreAction} preAction={handlePreAction}
onAction={handleAction} onAction={handleAction}
onUndoDelete={handleUndoDelete} onUndoDelete={handleUndoDelete}
onPrevious={handlePrevious} onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)}
onNext={handleNext} onNext={() => handleNavigateToAsset(assetCursor.nextAsset)}
onRandom={handleRandom} onRandom={handleRandom}
onClose={handleClose} onClose={handleClose}
/> />

View file

@ -5,6 +5,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
@ -102,6 +103,12 @@
const handleStack = () => { const handleStack = () => {
onStack(assets); onStack(assets);
}; };
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
});
</script> </script>
<svelte:document <svelte:document
@ -182,7 +189,7 @@
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body"> <Portal target="body">
<AssetViewer <AssetViewer
asset={$viewingAsset} cursor={assetCursor}
showNavigation={assets.length > 1} showNavigation={assets.length > 1}
{onNext} {onNext}
{onPrevious} {onPrevious}

View file

@ -0,0 +1,38 @@
import { getAssetUrl } from '$lib/utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
class PreloadManager {
preload(asset: AssetResponseDto | undefined) {
if (globalThis.isSecureContext) {
preloadImageUrl(getAssetUrl({ asset }));
return;
}
if (!asset || asset.type !== AssetTypeEnum.Image) {
return;
}
const img = new Image();
const url = getAssetUrl({ asset });
if (!url) {
return;
}
img.src = url;
}
cancel(asset: AssetResponseDto | undefined) {
if (!globalThis.isSecureContext || !asset) {
return;
}
const url = getAssetUrl({ asset });
cancelImageUrl(url);
}
cancelPreloadUrl(url: string | undefined) {
if (!globalThis.isSecureContext) {
return;
}
cancelImageUrl(url);
}
}
export const preloadManager = new PreloadManager();

View file

@ -85,7 +85,7 @@
<div <div
class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary" class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
> >
<PhotoViewer bind:element={imgElement} {asset} /> <PhotoViewer bind:element={imgElement} cursor={{ current: asset }} />
</div> </div>
</div> </div>
</ModalBody> </ModalBody>

View file

@ -1,19 +1,15 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; 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 { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Mutex } from 'async-mutex';
import { readonly, writable } from 'svelte/store'; import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() { function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>(); const viewingAssetStoreState = writable<AssetResponseDto>();
const preloadAssets = writable<TimelineAsset[]>([]);
const viewState = writable<boolean>(false); const viewState = writable<boolean>(false);
const viewingAssetMutex = new Mutex();
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>(); const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => { const setAsset = (asset: AssetResponseDto) => {
preloadAssets.set(assetsToPreload);
viewingAssetStoreState.set(asset); viewingAssetStoreState.set(asset);
viewState.set(true); viewState.set(true);
}; };
@ -30,8 +26,6 @@ function createAssetViewingStore() {
return { return {
asset: readonly(viewingAssetStoreState), asset: readonly(viewingAssetStoreState),
mutex: viewingAssetMutex,
preloadAssets: readonly(preloadAssets),
isViewing: viewState, isViewing: viewState,
gridScrollTarget, gridScrollTarget,
setAsset, setAsset,

View file

@ -1,6 +1,141 @@
import { getReleaseType } from '$lib/utils'; import { getAssetUrl, getReleaseType } from '$lib/utils';
import { AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
describe('utils', () => { describe('utils', () => {
describe(getAssetUrl.name, () => {
it('should return thumbnail URL for static images', () => {
const asset = assetFactory.build({
originalPath: 'image.jpg',
originalMimeType: 'image/jpeg',
type: AssetTypeEnum.Image,
});
const url = getAssetUrl({ asset });
// Should return a thumbnail URL (contains /thumbnail)
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL for static gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const url = getAssetUrl({ asset });
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL for static webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
});
const url = getAssetUrl({ asset });
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return original URL for animated gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const url = getAssetUrl({ asset });
// Should return original URL (contains /original)
expect(url).toContain('/original');
expect(url).toContain(asset.id);
});
it('should return original URL for animated webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const url = getAssetUrl({ asset });
expect(url).toContain('/original');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL for static images in shared link even with download and showMetadata permissions', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return original URL for animated images in shared link with download and showMetadata permissions', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/original');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL (not original) for animated images when shared link download permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/thumbnail');
expect(url).not.toContain('/original');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL (not original) for animated images when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/thumbnail');
expect(url).not.toContain('/original');
expect(url).toContain(asset.id);
});
});
describe(getReleaseType.name, () => { describe(getReleaseType.name, () => {
it('should return "major" for major version changes', () => { it('should return "major" for major version changes', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major'); expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major');

View file

@ -1,10 +1,12 @@
import { defaultLang, langs, locales } from '$lib/constants'; import { defaultLang, langs, locales } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { lang } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { import {
AssetJobName, AssetJobName,
AssetMediaSize, AssetMediaSize,
AssetTypeEnum,
MemoryType, MemoryType,
QueueName, QueueName,
finishOAuth, finishOAuth,
@ -17,6 +19,7 @@ import {
linkOAuthAccount, linkOAuthAccount,
startOAuth, startOAuth,
unlinkOAuthAccount, unlinkOAuthAccount,
type AssetResponseDto,
type MemoryResponseDto, type MemoryResponseDto,
type PersonResponseDto, type PersonResponseDto,
type ServerVersionResponseDto, type ServerVersionResponseDto,
@ -191,6 +194,40 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
type AssetUrlOptions = { id: string; cacheKey?: string | null }; type AssetUrlOptions = { id: string; cacheKey?: string | null };
export const getAssetUrl = ({
asset,
sharedLink,
forceOriginal = false,
}: {
asset: AssetResponseDto | undefined;
sharedLink?: SharedLinkResponseDto;
forceOriginal?: boolean;
}) => {
if (!asset) {
return;
}
const id = asset.id;
const cacheKey = asset.thumbhash;
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
const targetSize = targetImageSize(asset, forceOriginal);
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
const forceUseOriginal = (asset: AssetResponseDto) => {
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
};
export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => {
if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
};
export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => { export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
if (typeof options === 'string') { if (typeof options === 'string') {
options = { id: options }; options = { id: options };

View file

@ -557,6 +557,14 @@ export const delay = async (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
}; };
export const getNextAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => {
return currentAsset && assets[assets.indexOf(currentAsset) + 1];
};
export const getPreviousAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => {
return currentAsset && assets[assets.indexOf(currentAsset) - 1];
};
export const canCopyImageToClipboard = (): boolean => { export const canCopyImageToClipboard = (): boolean => {
return !!(navigator.clipboard && globalThis.ClipboardItem); return !!(navigator.clipboard && globalThis.ClipboardItem);
}; };

View file

@ -50,4 +50,13 @@ export class InvocationTracker {
isActive() { isActive() {
return this.invocationsStarted !== this.invocationsEnded; return this.invocationsStarted !== this.invocationsEnded;
} }
async invoke<T>(invocable: () => Promise<T>) {
const invocation = this.startInvocation();
try {
return await invocable();
} finally {
invocation.endInvocation();
}
}
} }

View file

@ -1,8 +1,14 @@
const broadcast = new BroadcastChannel('immich'); const broadcast = new BroadcastChannel('immich');
export function cancelImageUrl(url: string) { export function cancelImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'cancel', url }); broadcast.postMessage({ type: 'cancel', url });
} }
export function preloadImageUrl(url: string) { export function preloadImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'preload', url }); broadcast.postMessage({ type: 'preload', url });
} }

View file

@ -1,15 +1,18 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { AppRoute, timeToLoadTheMap } from '$lib/constants'; import { AppRoute, timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte'; 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 { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils'; import { delay } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui'; import { LoadingSpinner } from '@immich/ui';
import { onDestroy } from 'svelte'; import { onDestroy, untrack } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
interface Props { interface Props {
@ -64,6 +67,59 @@
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return asset; return asset;
} }
const getNextAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor < viewingAssets.length - 1) {
const id = viewingAssets[cursor + 1];
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getNextAsset(asset, false);
}
return asset;
}
};
const getPreviousAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor <= 0) {
return;
}
const id = viewingAssets[cursor - 1];
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getPreviousAsset(asset, false);
}
return asset;
};
let assetCursor = $state<AssetCursor>({
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));
});
</script> </script>
{#if featureFlagsManager.value.map} {#if featureFlagsManager.value.map}
@ -85,7 +141,7 @@
{#if $showAssetViewer} {#if $showAssetViewer}
{#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
asset={$viewingAsset} cursor={assetCursor}
showNavigation={viewingAssets.length > 1} showNavigation={viewingAssets.length > 1}
onNext={navigateNext} onNext={navigateNext}
onPrevious={navigatePrevious} onPrevious={navigatePrevious}

View file

@ -22,7 +22,7 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; 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 { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store'; import { lang, locale } from '$lib/stores/preferences.store';
@ -35,6 +35,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
type AlbumResponseDto, type AlbumResponseDto,
type AssetResponseDto,
getPerson, getPerson,
getTagById, getTagById,
type MetadataSearchDto, type MetadataSearchDto,
@ -58,7 +59,7 @@
let nextPage = $state(1); let nextPage = $state(1);
let searchResultAlbums: AlbumResponseDto[] = $state([]); let searchResultAlbums: AlbumResponseDto[] = $state([]);
let searchResultAssets: TimelineAsset[] = $state([]); let searchResultAssets: AssetResponseDto[] = $state([]);
let isLoading = $state(true); let isLoading = $state(true);
let scrollY = $state(0); let scrollY = $state(0);
let scrollYHistory = 0; let scrollYHistory = 0;
@ -121,7 +122,7 @@
const onAssetDelete = (assetIds: string[]) => { const onAssetDelete = (assetIds: string[]) => {
const assetIdSet = new Set(assetIds); 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[]) => { const handleSetVisibility = (assetIds: string[]) => {
@ -130,7 +131,7 @@
}; };
const handleSelectAll = () => { const handleSelectAll = () => {
assetInteraction.selectAssets(searchResultAssets); assetInteraction.selectAssets(searchResultAssets.map((asset) => toTimelineAsset(asset)));
}; };
async function onSearchQueryUpdate() { async function onSearchQueryUpdate() {
@ -162,7 +163,7 @@
: await searchAssets({ metadataSearchDto: searchDto }); : await searchAssets({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items); searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset))); searchResultAssets.push(...assets.items);
nextPage = Number(assets.nextPage) || 0; nextPage = Number(assets.nextPage) || 0;
} catch (error) { } catch (error) {

View file

@ -5,10 +5,11 @@
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import type { AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { AssetResponseDto } from '@immich/sdk';
interface Props { interface Props {
data: PageData; data: PageData;
@ -65,6 +66,12 @@
const onViewAsset = async (asset: AssetResponseDto) => { const onViewAsset = async (asset: AssetResponseDto) => {
await navigate({ targetRoute: 'current', assetId: asset.id }); await navigate({ targetRoute: 'current', assetId: asset.id });
}; };
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
});
</script> </script>
<UserPageLayout title={data.meta.title} scrollbar={true}> <UserPageLayout title={data.meta.title} scrollbar={true}>
@ -85,7 +92,7 @@
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body"> <Portal target="body">
<AssetViewer <AssetViewer
asset={$viewingAsset} cursor={assetCursor}
showNavigation={assets.length > 1} showNavigation={assets.length > 1}
{onNext} {onNext}
{onPrevious} {onPrevious}