merge with base

This commit is contained in:
midzelis 2026-01-07 13:35:10 +00:00
commit c9e2d56318
15 changed files with 273 additions and 256 deletions

View File

@ -3,7 +3,7 @@ import { Page, expect, test } from '@playwright/test';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken on').locator('visible=true');
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;

View File

@ -10,6 +10,7 @@
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 { eventManager } from '$lib/managers/event-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@ -52,8 +53,6 @@
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
type HasAsset = boolean;
export type AssetCursor = {
current: AssetResponseDto;
nextAsset?: AssetResponseDto;
@ -71,9 +70,8 @@
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<{ id: string } | undefined>;
onNavigateToAsset?: (asset: AssetResponseDto | undefined | null) => Promise<boolean>;
onRandom?: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>;
}
@ -88,8 +86,7 @@
onAction,
onUndoDelete,
onClose,
onNext,
onPrevious,
onNavigateToAsset,
onRandom,
copyImage = $bindable(),
}: Props = $props();
@ -106,6 +103,8 @@
const stackSelectedThumbnailSize = 65;
let asset = $derived(cursor.current);
let nextAsset = $derived(cursor.nextAsset);
let previousAsset = $derived(cursor.previousAsset);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
@ -229,14 +228,19 @@
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom();
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else if (onNavigateToAsset) {
hasNext =
order === 'previous'
? await onNavigateToAsset(cursor.previousAsset)
: await onNavigateToAsset(cursor.nextAsset);
} else {
hasNext = order === 'previous' ? await onPrevious() : await onNext();
hasNext = false;
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
@ -383,7 +387,6 @@
await ocrManager.getAssetOcr(asset.id);
}
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
@ -391,6 +394,34 @@
preloadManager.preload(cursor.nextAsset);
preloadManager.preload(cursor.previousAsset);
});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') {
eventManager.emit('AssetViewerFree');
}
});
const viewerKind = $derived.by(() => {
if (previewStackedAsset) {
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
}
if (asset.type === AssetTypeEnum.Image) {
if (assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId) {
return 'LiveVideoViewer';
} else if (
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
) {
return 'ImagePanaramaViewer';
} else if (isShowEditor && selectedEditType === 'crop') {
return 'CropArea';
}
return 'PhotoViewer';
}
return 'VideoViewer';
});
</script>
<OnEvents onAssetReplace={handleAssetReplace} />
@ -427,7 +458,7 @@
{/if}
{#if $slideshowState != SlideshowState.None}
<div class="absolute w-full flex">
<div class="absolute w-full flex justify-center">
<SlideshowBar
{isFullScreen}
assetType={previewStackedAsset?.type ?? asset.type}
@ -439,109 +470,98 @@
</div>
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
<div class="my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
{/if}
<!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if previewStackedAsset}
{#key previewStackedAsset.id}
{#if previewStackedAsset.type === AssetTypeEnum.Image}
<PhotoViewer
bind:zoomToggle
bind:copyImage
cursor={{ ...cursor, current: previewStackedAsset }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else}
<VideoViewer
assetId={previewStackedAsset.id}
cacheKey={previewStackedAsset.thumbhash}
projectionType={previewStackedAsset.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{/key}
{:else}
{#key asset.id}
{#if asset.type === AssetTypeEnum.Image}
{#if assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} />
{:else}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/>
{/if}
{:else}
<VideoViewer
assetId={asset.id}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
assetId={asset.livePhotoVideoId!}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
onFree={() => eventManager.emit('AssetViewerFree')}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
assetId={asset.id}
cacheKey={asset.thumbhash}
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}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite}
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite}
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
{/key}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
</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">
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
@ -110,6 +111,8 @@
viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 });
};
const handleReady = () => eventManager.emit('AssetViewerFree');
let hasChangedResolution: boolean = false;
onMount(() => {
if (!container) {
@ -154,6 +157,7 @@
zoomSpeed: 0.5,
fisheye: false,
});
viewer.addEventListener('ready', handleReady);
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
@ -178,6 +182,7 @@
onDestroy(() => {
if (viewer) {
viewer.removeEventListener('ready', handleReady);
viewer.destroy();
}
boundingBoxesUnsubscribe();

View File

@ -22,7 +22,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@ -34,6 +34,10 @@
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
onPreviousAsset?: (() => void) | null;
onFree?: (() => void) | null;
onBusy?: (() => void) | null;
onError?: (() => void) | null;
onLoad?: (() => void) | null;
onNextAsset?: (() => void) | null;
copyImage?: () => Promise<void>;
zoomToggle?: (() => void) | null;
@ -46,6 +50,10 @@
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
onFree = null,
onBusy = null,
onError = null,
onLoad = null,
copyImage = $bindable(),
zoomToggle = $bindable(),
}: Props = $props();
@ -156,16 +164,23 @@
};
const onload = () => {
onLoad?.();
onFree?.();
imageLoaded = true;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
onError?.();
onFree?.();
imageError = imageLoaded = true;
};
onMount(() => {
return () => {
if (!imageLoaded && !imageError) {
onFree?.();
}
preloadManager.cancelPreloadUrl(imageLoaderUrl);
};
});
@ -180,10 +195,16 @@
let lastUrl: string | undefined;
$effect(() => {
if (!lastUrl) {
untrack(() => onBusy?.());
}
if (lastUrl && lastUrl !== imageLoaderUrl) {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
untrack(() => {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
onBusy?.();
});
}
lastUrl = imageLoaderUrl;
});

View File

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

View File

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

View File

@ -35,9 +35,7 @@
onIntersected?: (() => void) | undefined;
showAssetName?: boolean;
isShowDeleteConfirmation?: boolean;
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
onNavigateToAsset?: (asset: AssetResponseDto | undefined) => Promise<boolean>;
onReload?: (() => void) | undefined;
pageHeaderOffset?: number;
slidingWindowOffset?: number;
@ -54,9 +52,7 @@
onIntersected = undefined,
showAssetName = false,
isShowDeleteConfirmation = $bindable(false),
onPrevious = undefined,
onNext = undefined,
onRandom = undefined,
onNavigateToAsset,
onReload = undefined,
slidingWindowOffset = 0,
pageHeaderOffset = 0,
@ -86,7 +82,7 @@
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
};
let currentIndex = 0;
let currentIndex = $state(0);
if (initialAssetId && assets.length > 0) {
const index = assets.findIndex(({ id }) => id === initialAssetId);
if (index !== -1) {
@ -295,48 +291,15 @@
})(),
);
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> => {
try {
let asset: { id: string } | undefined;
if (onRandom) {
asset = await onRandom();
} else {
if (assets.length > 0) {
const randomIndex = Math.floor(Math.random() * assets.length);
asset = assets[randomIndex];
}
}
if (!asset) {
if (assets.length === 0) {
return;
}
const randomIndex = Math.floor(Math.random() * assets.length);
const asset = assets[randomIndex];
await navigateToAsset(asset);
return asset;
} catch (error) {
@ -345,30 +308,13 @@
}
};
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);
const handleNavigateToAsset = async (target: AssetResponseDto | undefined | null) => {
if (target) {
currentIndex = assets.indexOf(target);
await (onNavigateToAsset ? onNavigateToAsset(target) : navigateToAsset(target));
return true;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_previous_asset'));
return false;
}
return false;
};
const navigateToAsset = async (asset?: { id: string }) => {
@ -390,9 +336,9 @@
if (assets.length === 0) {
await goto(AppRoute.PHOTOS);
} else if (currentIndex === assets.length) {
await handlePrevious();
await handleNavigateToAsset(assetCursor.previousAsset);
} else {
await setAssetId(assets[currentIndex].id);
await handleNavigateToAsset(assetCursor.nextAsset);
}
break;
}
@ -496,8 +442,7 @@
<AssetViewer
cursor={assetCursor}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onNavigateToAsset={handleNavigateToAsset}
onRandom={handleRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

View File

@ -2,8 +2,9 @@
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 { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@ -12,8 +13,8 @@
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
import { onMount, untrack } from 'svelte';
import { getAssetInfo, type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
import { onDestroy, onMount, untrack } from 'svelte';
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
@ -47,7 +48,7 @@
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (earlierTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id });
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id });
if (preload) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getNextAsset(asset, false);
@ -60,7 +61,7 @@
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
if (laterTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id });
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id });
if (preload) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getPreviousAsset(asset, false);
@ -96,8 +97,11 @@
if (!targetAsset) {
return false;
}
let waitForAssetViewerFree = new Promise<void>((resolve) => {
eventManager.once('AssetViewerFree', () => resolve());
});
await navigate({ targetRoute: 'current', assetId: targetAsset.id });
await waitForAssetViewerFree;
return true;
};
@ -195,6 +199,9 @@
}
}
};
onDestroy(() => {
assetCacheManager.invalidate();
});
const handleUndoDelete = async (assets: TimelineAsset[]) => {
timelineManager.upsertAssets(assets);
if (assets.length > 0) {
@ -230,10 +237,12 @@
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onAction={(action) => {
handleAction(action);
assetCacheManager.invalidate();
}}
onUndoDelete={handleUndoDelete}
onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)}
onNext={() => handleNavigateToAsset(assetCursor.nextAsset)}
onNavigateToAsset={handleNavigateToAsset}
onRandom={handleRandom}
onClose={handleClose}
/>

View File

@ -44,21 +44,11 @@
assetViewingStore.showAssetViewer(false);
});
const onNext = async () => {
const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) {
const handleNavigateToAsset = async (asset: AssetResponseDto | undefined | null) => {
if (!asset) {
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]);
await onViewAsset(asset);
return true;
};
@ -191,8 +181,7 @@
<AssetViewer
cursor={assetCursor}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
onNavigateToAsset={handleNavigateToAsset}
{onRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

View File

@ -0,0 +1,60 @@
import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk';
const defaultSerializer = <K>(params: K) => JSON.stringify(params);
class AsyncCache<V> {
#cache = new Map<string, V>();
async getOrFetch<K>(
params: K,
fetcher: (params: K) => Promise<V>,
keySerializer: (params: K) => string = defaultSerializer,
updateCache: boolean,
): Promise<V> {
const cacheKey = keySerializer(params);
const cached = this.#cache.get(cacheKey);
if (cached) {
return cached;
}
const value = await fetcher(params);
if (value && updateCache) {
this.#cache.set(cacheKey, value);
}
return value;
}
clear() {
this.#cache.clear();
}
}
class AssetCacheManager {
#assetCache = new AsyncCache<AssetResponseDto>();
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) {
return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache);
}
async getAssetOcr(id: string) {
return this.#ocrCache.getOrFetch({ id }, getAssetOcr, (params) => params.id, true);
}
clearAssetCache() {
this.#assetCache.clear();
}
clearOcrCache() {
this.#ocrCache.clear();
}
invalidate() {
this.clearAssetCache();
this.clearOcrCache();
}
}
export const assetCacheManager = new AssetCacheManager();

View File

@ -43,6 +43,8 @@ export type Events = {
// confirmed permanently deleted from server
UserAdminDeleted: [{ id: string }];
AssetViewerFree: [];
SystemConfigUpdate: [SystemConfigDto];
LibraryCreate: [LibraryResponseDto];

View File

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

View File

@ -1,8 +1,8 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { RouteId } from '$app/types';
import { AppRoute } from '$lib/constants';
import { getAssetInfo } from '@immich/sdk';
import type { NavigationTarget } from '@sveltejs/kit';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { get } from 'svelte/store';
export type AssetGridRouteSearchParams = {
@ -20,11 +20,12 @@ export const isAlbumsRoute = (route?: string | null) => !!route?.startsWith('/(u
export const isPeopleRoute = (route?: string | null) => !!route?.startsWith('/(user)/people/[personId]');
export const isLockedFolderRoute = (route?: string | null) => !!route?.startsWith('/(user)/locked');
export const isAssetViewerRoute = (target?: NavigationTarget | null) =>
!!(target?.route.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export const isAssetViewerRoute = (
target?: { route?: { id?: RouteId | null }; params?: Record<string, string> | null } | null,
) => !!(target?.route?.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export function getAssetInfoFromParam({ assetId, slug, key }: { assetId?: string; key?: string; slug?: string }) {
return assetId ? getAssetInfo({ id: assetId, slug, key }) : undefined;
return assetId ? assetCacheManager.getAsset({ id: assetId, slug, key }, false) : undefined;
}
function currentUrlWithoutAsset() {

View File

@ -24,7 +24,6 @@
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let viewingAssets: string[] = $state([]);
let viewingAssetCursor = 0;
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
@ -36,27 +35,16 @@
async function onViewAssets(assetIds: string[]) {
viewingAssets = assetIds;
viewingAssetCursor = 0;
await setAssetId(assetIds[0]);
}
async function navigateNext() {
if (viewingAssetCursor < viewingAssets.length - 1) {
await setAssetId(viewingAssets[++viewingAssetCursor]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return true;
const handleNavigateToAsset = async (currentAsset: AssetResponseDto | undefined | null) => {
if (!currentAsset) {
return false;
}
return false;
}
async function navigatePrevious() {
if (viewingAssetCursor > 0) {
await setAssetId(viewingAssets[--viewingAssetCursor]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return true;
}
return false;
}
await navigate({ targetRoute: 'current', assetId: currentAsset.id });
return true;
};
async function navigateRandom() {
if (viewingAssets.length <= 0) {
@ -138,13 +126,12 @@
</div>
</UserPageLayout>
<Portal target="body">
{#if $showAssetViewer}
{#if $showAssetViewer && assetCursor.current}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={assetCursor}
showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
onNavigateToAsset={handleNavigateToAsset}
onRandom={navigateRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

View File

@ -20,29 +20,17 @@
let assets = $derived(data.assets);
let asset = $derived(data.asset);
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
$effect(() => {
if (asset) {
setAsset(asset);
}
});
const onNext = async () => {
const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) {
const handleNavigateToAsset = async (asset: AssetResponseDto | undefined | null) => {
if (!asset) {
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]);
await onViewAsset(asset);
return true;
};
@ -93,9 +81,8 @@
<Portal target="body">
<AssetViewer
cursor={assetCursor}
onNavigateToAsset={handleNavigateToAsset}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
{onRandom}
{onAction}
onClose={() => {