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
24 changed files with 529 additions and 433 deletions
@@ -10,18 +10,19 @@
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { preloadImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
@@ -53,17 +54,22 @@
type HasAsset = boolean;
export type AssetCursor = {
current: AssetResponseDto;
nextAsset?: AssetResponseDto;
previousAsset?: AssetResponseDto;
};
interface Props {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[];
cursor: AssetCursor;
showNavigation?: boolean;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
preAction?: PreAction | undefined;
onAction?: OnAction | undefined;
onUndoDelete?: OnUndoDelete | undefined;
album?: AlbumResponseDto;
person?: PersonResponseDto;
preAction?: PreAction;
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
@@ -72,16 +78,15 @@
}
let {
asset = $bindable(),
preloadAssets = $bindable([]),
cursor,
showNavigation = true,
withStacked = false,
isShared = false,
album = null,
person = null,
preAction = undefined,
onAction = undefined,
onUndoDelete = undefined,
album,
person,
preAction,
onAction,
onUndoDelete,
onClose,
onNext,
onPrevious,
@@ -100,6 +105,7 @@
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
let asset = $derived(cursor.current);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
@@ -131,7 +137,7 @@
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadAssets.push(toTimelineAsset(stack.assets[1]));
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
}
});
};
@@ -146,16 +152,8 @@
}
};
const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
if (assetUpdate.id === asset.id) {
asset = assetUpdate;
}
};
onMount(async () => {
unsubscribes.push(
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
@@ -208,7 +206,9 @@
});
};
const navigateAsset = async (order?: 'previous' | 'next', e?: Event) => {
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
@@ -218,38 +218,37 @@
}
e?.stopPropagation();
preloadManager.cancel(asset);
if (tracker.isActive()) {
return;
}
let hasNext = false;
void tracker.invoke(async () => {
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else {
hasNext = order === 'previous' ? await onPrevious() : await onNext();
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}
} else {
hasNext = order === 'previous' ? await onPrevious() : await onNext();
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}
});
};
// const showEditorHandler = () => {
// if (isShowActivity) {
// isShowActivity = false;
// }
// isShowEditor = !isShowEditor;
// };
const handleRunJob = async (name: AssetJobName) => {
try {
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
@@ -362,12 +361,6 @@
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
if (asset) {
previewStackedAsset = undefined;
handlePromiseError(refreshStack());
}
});
$effect(() => {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
assetViewerManager.closeActivityPanel();
@@ -379,13 +372,24 @@
}
});
// primarily, this is reactive on `asset`
$effect(() => {
handlePromiseError(handleGetAllAlbums());
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
ocrManager.clear();
if (!sharedLink) {
handlePromiseError(ocrManager.getAssetOcr(asset.id));
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
}
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => handlePromiseError(refresh()));
preloadManager.preload(cursor.nextAsset);
preloadManager.preload(cursor.previousAsset);
});
</script>
@@ -449,8 +453,7 @@
<PhotoViewer
bind:zoomToggle
bind:copyImage
asset={previewStackedAsset}
{preloadAssets}
cursor={{ ...cursor, current: previewStackedAsset }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
@@ -495,8 +498,7 @@
<PhotoViewer
bind:zoomToggle
bind:copyImage
{asset}
{preloadAssets}
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
@@ -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();
});
});
@@ -6,32 +6,30 @@
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { AssetCursor } from './asset-viewer.svelte';
interface Props {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[] | undefined;
cursor: AssetCursor;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
@@ -42,8 +40,7 @@
}
let {
asset,
preloadAssets = undefined,
cursor,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
@@ -54,8 +51,8 @@
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
@@ -82,25 +79,6 @@
let isOcrActive = $derived(ocrManager.showOverlay);
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.isImage) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
}
}
};
const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
copyImage = async () => {
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
return;
@@ -155,23 +133,11 @@
}
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
$photoZoomState.currentZoom > 1,
);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
});
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
$effect(() => {
if (assetFileUrl) {
void cast(assetFileUrl);
if (imageLoaderUrl) {
void cast(imageLoaderUrl);
}
});
@@ -191,7 +157,6 @@
const onload = () => {
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
@@ -199,27 +164,29 @@
imageError = imageLoaded = true;
};
$effect(() => {
preload(targetImageSize, preloadAssets);
});
onMount(() => {
if (loader?.complete) {
onload();
}
loader?.addEventListener('load', onload, { passive: true });
loader?.addEventListener('error', onerror, { passive: true });
return () => {
loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror);
cancelImageUrl(imageLoaderUrl);
preloadManager.cancelPreloadUrl(imageLoaderUrl);
};
});
let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
);
let containerWidth = $state(0);
let containerHeight = $state(0);
let lastUrl: string | undefined;
$effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
}
lastUrl = imageLoaderUrl;
});
</script>
<svelte:document
@@ -232,19 +199,17 @@
]}
/>
{#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" />
</div>
{/if}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
<div
bind:this={element}
class="relative h-full select-none"
class="relative h-full w-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
@@ -258,7 +223,7 @@
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
src={imageLoaderUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
@@ -266,7 +231,7 @@
{/if}
<img
bind:this={$photoViewerImgElement}
src={assetFileUrl}
src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
@@ -298,6 +263,7 @@
visibility: visible;
}
}
#broken-asset,
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;