mirror of
https://github.com/immich-app/immich.git
synced 2025-12-29 01:11:52 +03:00
Compare commits
3 Commits
push-lstpr
...
push-qtxrp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45a5d41fdf | ||
|
|
fe29d74a13 | ||
|
|
4836963196 |
@@ -26,6 +26,5 @@ export const makeRandomImage = () => {
|
||||
if (!value) {
|
||||
throw new Error('Ran out of random asset data');
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -752,9 +752,6 @@ importers:
|
||||
'@zoom-image/svelte':
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.8(svelte@5.43.3)
|
||||
async-mutex:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
dom-to-image:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
@@ -5587,9 +5584,6 @@ packages:
|
||||
async-lock@1.4.1:
|
||||
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
||||
|
||||
async-mutex@0.5.0:
|
||||
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
||||
|
||||
async@0.2.10:
|
||||
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
|
||||
|
||||
@@ -11831,10 +11825,12 @@ packages:
|
||||
whatwg-encoding@2.0.0:
|
||||
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
|
||||
engines: {node: '>=12'}
|
||||
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
||||
|
||||
whatwg-encoding@3.1.1:
|
||||
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
||||
|
||||
whatwg-mimetype@3.0.0:
|
||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||
@@ -17911,10 +17907,6 @@ snapshots:
|
||||
|
||||
async-lock@1.4.1: {}
|
||||
|
||||
async-mutex@0.5.0:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
async@0.2.10: {}
|
||||
|
||||
async@3.2.6: {}
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@zoom-image/core": "^0.41.0",
|
||||
"@zoom-image/svelte": "^0.3.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"fabric": "^6.5.4",
|
||||
"geo-coordinates-parser": "^1.7.4",
|
||||
|
||||
@@ -10,17 +10,19 @@
|
||||
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
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';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo, isShowDetail } 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 { 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,
|
||||
@@ -50,11 +52,14 @@
|
||||
import SlideshowBar from './slideshow-bar.svelte';
|
||||
import VideoViewer from './video-wrapper-viewer.svelte';
|
||||
|
||||
type HasAsset = boolean;
|
||||
export type AssetCursor = {
|
||||
current: AssetResponseDto;
|
||||
nextAsset: AssetResponseDto | undefined | null;
|
||||
previousAsset: AssetResponseDto | undefined | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
preloadAssets?: TimelineAsset[];
|
||||
cursor: AssetCursor;
|
||||
showNavigation?: boolean;
|
||||
withStacked?: boolean;
|
||||
isShared?: boolean;
|
||||
@@ -63,16 +68,14 @@
|
||||
preAction?: PreAction | undefined;
|
||||
onAction?: OnAction | undefined;
|
||||
showCloseButton?: boolean;
|
||||
onClose: (asset: AssetResponseDto) => void;
|
||||
onNext: () => Promise<HasAsset>;
|
||||
onPrevious: () => Promise<HasAsset>;
|
||||
onRandom: () => Promise<{ id: string } | undefined>;
|
||||
onClose?: (asset: AssetResponseDto) => void;
|
||||
onNavigateToAsset?: (asset: AssetResponseDto | undefined | null) => Promise<boolean>;
|
||||
onRandom?: () => Promise<{ id: string } | undefined>;
|
||||
copyImage?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
asset = $bindable(),
|
||||
preloadAssets = $bindable([]),
|
||||
cursor,
|
||||
showNavigation = true,
|
||||
withStacked = false,
|
||||
isShared = false,
|
||||
@@ -82,8 +85,7 @@
|
||||
onAction = undefined,
|
||||
showCloseButton,
|
||||
onClose,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onNavigateToAsset,
|
||||
onRandom,
|
||||
copyImage = $bindable(),
|
||||
}: Props = $props();
|
||||
@@ -99,10 +101,13 @@
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
let asset = $derived(cursor.current);
|
||||
let nextAsset = $derived(cursor.nextAsset);
|
||||
let previousAsset = $derived(cursor.previousAsset);
|
||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||
let shouldPlayMotionPhoto = $state(false);
|
||||
let sharedLink = getSharedLink();
|
||||
let enableDetailPanel = asset.hasMetadata;
|
||||
let enableDetailPanel = $derived(asset.hasMetadata);
|
||||
let slideshowStateUnsubscribe: () => void;
|
||||
let shuffleSlideshowUnsubscribe: () => void;
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
@@ -135,7 +140,7 @@
|
||||
|
||||
untrack(() => {
|
||||
if (stack && stack?.assets.length > 1) {
|
||||
preloadAssets.push(toTimelineAsset(stack.assets[1]));
|
||||
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -150,18 +155,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
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 })),
|
||||
);
|
||||
|
||||
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
@@ -225,7 +219,7 @@
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
onClose(asset);
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
@@ -234,7 +228,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';
|
||||
@@ -244,38 +240,42 @@
|
||||
}
|
||||
|
||||
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 if (onNavigateToAsset) {
|
||||
hasNext =
|
||||
order === 'previous'
|
||||
? await onNavigateToAsset(cursor.previousAsset)
|
||||
: await onNavigateToAsset(cursor.nextAsset);
|
||||
} else {
|
||||
hasNext = false;
|
||||
}
|
||||
|
||||
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 } });
|
||||
@@ -378,12 +378,6 @@
|
||||
|
||||
let isFullScreen = $derived(fullscreenElement !== null);
|
||||
|
||||
$effect(() => {
|
||||
if (asset) {
|
||||
previewStackedAsset = undefined;
|
||||
handlePromiseError(refreshStack());
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||
isShowActivity = false;
|
||||
@@ -395,13 +389,51 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
$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 (shouldPlayMotionPhoto && 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>
|
||||
|
||||
@@ -448,7 +480,7 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState != SlideshowState.None}
|
||||
<div class="absolute w-full flex">
|
||||
<div class="absolute w-full flex justify-center">
|
||||
<SlideshowBar
|
||||
{isFullScreen}
|
||||
onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
|
||||
@@ -459,112 +491,99 @@
|
||||
</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
|
||||
asset={previewStackedAsset}
|
||||
{preloadAssets}
|
||||
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 shouldPlayMotionPhoto && asset.livePhotoVideoId}
|
||||
<VideoViewer
|
||||
assetId={asset.livePhotoVideoId}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onVideoEnded={() => (shouldPlayMotionPhoto = 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
|
||||
{asset}
|
||||
{preloadAssets}
|
||||
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={() => (shouldPlayMotionPhoto = 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}
|
||||
onOpenActivityTab={handleOpenActivity}
|
||||
/>
|
||||
</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}
|
||||
onOpenActivityTab={handleOpenActivity}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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,56 +6,61 @@
|
||||
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 { onDestroy, onMount, untrack } 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;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onFree?: (() => void) | null;
|
||||
onBusy?: (() => void) | null;
|
||||
onError?: (() => void) | null;
|
||||
onLoad?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
copyImage?: () => Promise<void>;
|
||||
zoomToggle?: (() => void) | null;
|
||||
}
|
||||
|
||||
let {
|
||||
asset,
|
||||
preloadAssets = undefined,
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
onFree = null,
|
||||
onBusy = null,
|
||||
onError = null,
|
||||
onLoad = null,
|
||||
copyImage = $bindable(),
|
||||
zoomToggle = $bindable(),
|
||||
}: 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 +87,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 +141,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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,36 +164,50 @@
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
onLoad?.();
|
||||
onFree?.();
|
||||
imageLoaded = true;
|
||||
assetFileUrl = imageLoaderUrl;
|
||||
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
||||
};
|
||||
|
||||
const onerror = () => {
|
||||
onError?.();
|
||||
onFree?.();
|
||||
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);
|
||||
if (!imageLoaded && !imageError) {
|
||||
onFree?.();
|
||||
}
|
||||
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 | null;
|
||||
|
||||
$effect(() => {
|
||||
if (!lastUrl) {
|
||||
untrack(() => onBusy?.());
|
||||
}
|
||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||
untrack(() => {
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
onBusy?.();
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -232,19 +220,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 +244,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 +252,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 +284,7 @@
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#broken-asset,
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 { mdiEyeOffOutline } from '@mdi/js';
|
||||
import type { ActionReturn } from 'svelte/action';
|
||||
@@ -60,7 +60,7 @@
|
||||
onComplete?.(false);
|
||||
}
|
||||
return {
|
||||
destroy: () => cancelImageUrl(url),
|
||||
destroy: () => preloadManager.cancelPreloadUrl(url),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
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';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
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 {
|
||||
mdiCardsOutline,
|
||||
@@ -67,7 +67,7 @@
|
||||
let currentMemoryAssetFull = $derived.by(async () =>
|
||||
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 viewerHeight = $state(0);
|
||||
@@ -396,7 +396,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if currentTimelineAssets.some(({ isVideo }) => isVideo)}
|
||||
{#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
|
||||
<div class="w-12.5 dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
@@ -651,8 +651,6 @@
|
||||
bind:this={memoryGallery}
|
||||
>
|
||||
<GalleryViewer
|
||||
onNext={handleNextAsset}
|
||||
onPrevious={handlePreviousAsset}
|
||||
assets={currentTimelineAssets}
|
||||
viewport={galleryViewport}
|
||||
{assetInteraction}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||
const assetInteraction = new AssetInteraction();
|
||||
|
||||
let assets = $derived(sharedLink.assets.map((a) => toTimelineAsset(a)));
|
||||
let assets = $derived(sharedLink.assets);
|
||||
|
||||
dragAndDropFilesStore.subscribe((value) => {
|
||||
if (value.isDragging && value.files.length > 0) {
|
||||
@@ -68,7 +68,7 @@
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
assetInteraction.selectAssets(assets);
|
||||
assetInteraction.selectAssets(assets.map((asset) => toTimelineAsset(asset)));
|
||||
};
|
||||
|
||||
const handleAction = async (action: Action) => {
|
||||
@@ -145,13 +145,9 @@
|
||||
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
{asset}
|
||||
cursor={{ current: asset, nextAsset: null, previousAsset: null }}
|
||||
showCloseButton={false}
|
||||
onAction={handleAction}
|
||||
onPrevious={() => Promise.resolve(false)}
|
||||
onNext={() => Promise.resolve(false)}
|
||||
onRandom={() => Promise.resolve(undefined)}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
{/await}
|
||||
{/await}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
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 type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
@@ -19,15 +21,16 @@
|
||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetVisibility, getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
|
||||
|
||||
interface Props {
|
||||
initialAssetId?: string;
|
||||
assets: TimelineAsset[] | AssetResponseDto[];
|
||||
assets: AssetResponseDto[];
|
||||
assetInteraction: AssetInteraction;
|
||||
disableAssetSelect?: boolean;
|
||||
showArchiveIcon?: boolean;
|
||||
@@ -35,9 +38,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 +55,7 @@
|
||||
onIntersected = undefined,
|
||||
showAssetName = false,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
onPrevious = undefined,
|
||||
onNext = undefined,
|
||||
onRandom = undefined,
|
||||
onNavigateToAsset,
|
||||
onReload = undefined,
|
||||
slidingWindowOffset = 0,
|
||||
pageHeaderOffset = 0,
|
||||
@@ -86,7 +85,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) {
|
||||
@@ -229,7 +228,7 @@
|
||||
isShowDeleteConfirmation = false;
|
||||
await deleteAssets(
|
||||
!(isTrashEnabled && !force),
|
||||
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]),
|
||||
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
|
||||
assetInteraction.selectedAssets,
|
||||
onReload,
|
||||
);
|
||||
@@ -242,7 +241,7 @@
|
||||
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
|
||||
);
|
||||
if (ids) {
|
||||
assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[];
|
||||
assets = assets.filter((asset) => !ids.includes(asset.id));
|
||||
deselectAllAssets();
|
||||
}
|
||||
};
|
||||
@@ -295,48 +294,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 +311,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 +339,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;
|
||||
}
|
||||
@@ -424,6 +373,59 @@
|
||||
selectAssetCandidates(lastAssetMouseEvent);
|
||||
}
|
||||
});
|
||||
|
||||
const getNextAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
|
||||
if (!currentAsset) {
|
||||
return;
|
||||
}
|
||||
const cursor = assets.indexOf(currentAsset);
|
||||
if (cursor < assets.length - 1) {
|
||||
const id = assets[cursor + 1].id;
|
||||
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 = assets.indexOf(currentAsset);
|
||||
if (cursor <= 0) {
|
||||
return;
|
||||
}
|
||||
const id = assets[cursor - 1].id;
|
||||
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>
|
||||
|
||||
<svelte:document
|
||||
@@ -488,10 +490,9 @@
|
||||
<Portal target="body">
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
asset={$viewingAsset}
|
||||
cursor={assetCursor}
|
||||
onAction={handleAction}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onNavigateToAsset={handleNavigateToAsset}
|
||||
onRandom={handleRandom}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<script lang="ts">
|
||||
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 { 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 { navigate } from '$lib/utils/navigation';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
|
||||
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
|
||||
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||
|
||||
interface Props {
|
||||
timelineManager: TimelineManager;
|
||||
@@ -38,44 +45,71 @@
|
||||
person = null,
|
||||
}: Props = $props();
|
||||
|
||||
const handlePrevious = async () => {
|
||||
const release = await mutex.acquire();
|
||||
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
|
||||
|
||||
if (laterAsset) {
|
||||
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
|
||||
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
|
||||
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
|
||||
if (earlierTimelineAsset) {
|
||||
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);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
|
||||
release();
|
||||
return !!laterAsset;
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
const release = await mutex.acquire();
|
||||
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
|
||||
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
|
||||
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
|
||||
|
||||
if (earlierAsset) {
|
||||
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
|
||||
if (laterTimelineAsset) {
|
||||
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);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
};
|
||||
|
||||
release();
|
||||
return !!earlierAsset;
|
||||
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;
|
||||
}
|
||||
let waitForAssetViewerFree = new Promise<void>((resolve) => {
|
||||
eventManager.once('AssetViewerFree', () => resolve());
|
||||
});
|
||||
await navigate({ targetRoute: 'current', assetId: targetAsset.id });
|
||||
await waitForAssetViewerFree;
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleRandom = async () => {
|
||||
const randomAsset = await timelineManager.getRandomAsset();
|
||||
|
||||
if (randomAsset) {
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
|
||||
assetViewingStore.setAsset(asset);
|
||||
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
||||
return asset;
|
||||
return { id: randomAsset.id };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,7 +131,9 @@
|
||||
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
||||
// find the next asset to show or close the viewer
|
||||
// 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
|
||||
timelineManager.removeAssets([action.asset.id]);
|
||||
@@ -163,20 +199,40 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
onDestroy(() => {
|
||||
assetCacheManager.invalidate();
|
||||
});
|
||||
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>
|
||||
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
{withStacked}
|
||||
asset={$viewingAsset}
|
||||
preloadAssets={$preloadAssets}
|
||||
cursor={assetCursor}
|
||||
{isShared}
|
||||
{album}
|
||||
{person}
|
||||
preAction={handlePreAction}
|
||||
onAction={handleAction}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onAction={(action) => {
|
||||
handleAction(action);
|
||||
assetCacheManager.invalidate();
|
||||
}}
|
||||
onNavigateToAsset={handleNavigateToAsset}
|
||||
onRandom={handleRandom}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
@@ -10,7 +11,7 @@
|
||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
@@ -43,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;
|
||||
};
|
||||
|
||||
@@ -102,6 +93,43 @@
|
||||
const handleStack = () => {
|
||||
onStack(assets);
|
||||
};
|
||||
|
||||
const getPreviousAsset = (currentAsset: AssetResponseDto) => {
|
||||
const index = getAssetIndex(currentAsset.id) - 1;
|
||||
if (index < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return assets[index];
|
||||
};
|
||||
|
||||
const getNextAsset = (currentAsset: AssetResponseDto) => {
|
||||
const index = getAssetIndex(currentAsset.id) + 1;
|
||||
if (index >= assets.length) {
|
||||
return undefined;
|
||||
}
|
||||
return assets[index];
|
||||
};
|
||||
|
||||
let assetCursor = $state<AssetCursor>({
|
||||
current: $viewingAsset,
|
||||
previousAsset: undefined,
|
||||
nextAsset: undefined,
|
||||
});
|
||||
|
||||
const loadCloseAssets = (currentAsset: AssetResponseDto) => {
|
||||
assetCursor = {
|
||||
current: currentAsset,
|
||||
nextAsset: getNextAsset(currentAsset),
|
||||
previousAsset: getPreviousAsset(currentAsset),
|
||||
};
|
||||
};
|
||||
|
||||
//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>
|
||||
|
||||
<svelte:document
|
||||
@@ -182,10 +210,9 @@
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<Portal target="body">
|
||||
<AssetViewer
|
||||
asset={$viewingAsset}
|
||||
cursor={assetCursor}
|
||||
showNavigation={assets.length > 1}
|
||||
{onNext}
|
||||
{onPrevious}
|
||||
onNavigateToAsset={handleNavigateToAsset}
|
||||
{onRandom}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
|
||||
60
web/src/lib/managers/AssetCacheManager.svelte.ts
Normal file
60
web/src/lib/managers/AssetCacheManager.svelte.ts
Normal 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();
|
||||
38
web/src/lib/managers/PreloadManager.svelte.ts
Normal file
38
web/src/lib/managers/PreloadManager.svelte.ts
Normal 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 | null) {
|
||||
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 | null) {
|
||||
if (!globalThis.isSecureContext || !asset) {
|
||||
return;
|
||||
}
|
||||
const url = getAssetUrl({ asset });
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
|
||||
cancelPreloadUrl(url: string | undefined | null) {
|
||||
if (!globalThis.isSecureContext) {
|
||||
return;
|
||||
}
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const preloadManager = new PreloadManager();
|
||||
@@ -43,6 +43,8 @@ export type Events = {
|
||||
// confirmed permanently deleted from server
|
||||
UserAdminDeleted: [{ id: string }];
|
||||
|
||||
AssetViewerFree: [];
|
||||
|
||||
SystemConfigUpdate: [SystemConfigDto];
|
||||
|
||||
LibraryCreate: [LibraryResponseDto];
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<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"
|
||||
>
|
||||
<PhotoViewer bind:element={imgElement} {asset} />
|
||||
<PhotoViewer bind:element={imgElement} cursor={{ current: asset, nextAsset: null, previousAsset: null }} />
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
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 { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import { readonly, writable } from 'svelte/store';
|
||||
|
||||
function createAssetViewingStore() {
|
||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||
const preloadAssets = writable<TimelineAsset[]>([]);
|
||||
const viewState = writable<boolean>(false);
|
||||
const viewingAssetMutex = new Mutex();
|
||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => {
|
||||
preloadAssets.set(assetsToPreload);
|
||||
const setAsset = (asset: AssetResponseDto) => {
|
||||
viewingAssetStoreState.set(asset);
|
||||
viewState.set(true);
|
||||
};
|
||||
@@ -30,8 +25,6 @@ function createAssetViewingStore() {
|
||||
|
||||
return {
|
||||
asset: readonly(viewingAssetStoreState),
|
||||
mutex: viewingAssetMutex,
|
||||
preloadAssets: readonly(preloadAssets),
|
||||
isViewing: viewState,
|
||||
gridScrollTarget,
|
||||
setAsset,
|
||||
|
||||
@@ -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(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, () => {
|
||||
it('should return "major" for major version changes', () => {
|
||||
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major');
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { defaultLang, langs, locales } from '$lib/constants';
|
||||
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 {
|
||||
AssetJobName,
|
||||
AssetMediaSize,
|
||||
AssetTypeEnum,
|
||||
MemoryType,
|
||||
QueueName,
|
||||
finishOAuth,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
linkOAuthAccount,
|
||||
startOAuth,
|
||||
unlinkOAuthAccount,
|
||||
type AssetResponseDto,
|
||||
type MemoryResponseDto,
|
||||
type PersonResponseDto,
|
||||
type ServerVersionResponseDto,
|
||||
@@ -191,6 +194,40 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
|
||||
|
||||
type AssetUrlOptions = { id: string; cacheKey?: string | null };
|
||||
|
||||
export const getAssetUrl = ({
|
||||
asset,
|
||||
sharedLink,
|
||||
forceOriginal = false,
|
||||
}: {
|
||||
asset: AssetResponseDto | undefined | null;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
forceOriginal?: boolean;
|
||||
}) => {
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
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) => {
|
||||
if (typeof options === 'string') {
|
||||
options = { id: options };
|
||||
|
||||
@@ -50,4 +50,13 @@ export class InvocationTracker {
|
||||
isActive() {
|
||||
return this.invocationsStarted !== this.invocationsEnded;
|
||||
}
|
||||
|
||||
async invoke<T>(invocable: () => Promise<T>) {
|
||||
const invocation = this.startInvocation();
|
||||
try {
|
||||
return await invocable();
|
||||
} finally {
|
||||
invocation.endInvocation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
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 });
|
||||
}
|
||||
export function preloadImageUrl(url: string) {
|
||||
export function preloadImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'preload', url });
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script lang="ts">
|
||||
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 { AppRoute, timeToLoadTheMap } from '$lib/constants';
|
||||
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 { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { delay } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
@@ -21,7 +24,6 @@
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
|
||||
|
||||
let viewingAssets: string[] = $state([]);
|
||||
let viewingAssetCursor = 0;
|
||||
|
||||
onDestroy(() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
@@ -33,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) {
|
||||
@@ -64,6 +55,59 @@
|
||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||
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>
|
||||
|
||||
{#if featureFlagsManager.value.map}
|
||||
@@ -82,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
|
||||
asset={$viewingAsset}
|
||||
cursor={assetCursor}
|
||||
showNavigation={viewingAssets.length > 1}
|
||||
onNext={navigateNext}
|
||||
onPrevious={navigatePrevious}
|
||||
onNavigateToAsset={handleNavigateToAsset}
|
||||
onRandom={navigateRandom}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
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 { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { lang, locale } from '$lib/stores/preferences.store';
|
||||
@@ -35,6 +35,7 @@
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
getPerson,
|
||||
getTagById,
|
||||
type MetadataSearchDto,
|
||||
@@ -58,7 +59,7 @@
|
||||
|
||||
let nextPage = $state(1);
|
||||
let searchResultAlbums: AlbumResponseDto[] = $state([]);
|
||||
let searchResultAssets: TimelineAsset[] = $state([]);
|
||||
let searchResultAssets: AssetResponseDto[] = $state([]);
|
||||
let isLoading = $state(true);
|
||||
let scrollY = $state(0);
|
||||
let scrollYHistory = 0;
|
||||
@@ -121,7 +122,7 @@
|
||||
|
||||
const onAssetDelete = (assetIds: string[]) => {
|
||||
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[]) => {
|
||||
@@ -130,7 +131,7 @@
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
assetInteraction.selectAssets(searchResultAssets);
|
||||
assetInteraction.selectAssets(searchResultAssets.map((asset) => toTimelineAsset(asset)));
|
||||
};
|
||||
|
||||
async function onSearchQueryUpdate() {
|
||||
@@ -162,7 +163,7 @@
|
||||
: await searchAssets({ metadataSearchDto: searchDto });
|
||||
|
||||
searchResultAlbums.push(...albums.items);
|
||||
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
|
||||
searchResultAssets.push(...assets.items);
|
||||
|
||||
nextPage = Number(assets.nextPage) || 0;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import LargeAssetData from '$lib/components/utilities-page/large-assets/large-asset-data.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { getAssetInfo } from '@immich/sdk';
|
||||
import { untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@@ -19,29 +23,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;
|
||||
};
|
||||
|
||||
@@ -65,6 +57,59 @@
|
||||
const onViewAsset = async (asset: AssetResponseDto) => {
|
||||
await navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
const getNextAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
|
||||
if (!currentAsset) {
|
||||
return;
|
||||
}
|
||||
const cursor = assets.indexOf(currentAsset);
|
||||
if (cursor < assets.length - 1) {
|
||||
const id = assets[cursor + 1].id;
|
||||
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 = assets.indexOf(currentAsset);
|
||||
if (cursor <= 0) {
|
||||
return;
|
||||
}
|
||||
const id = assets[cursor - 1].id;
|
||||
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>
|
||||
|
||||
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
||||
@@ -85,10 +130,9 @@
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<Portal target="body">
|
||||
<AssetViewer
|
||||
asset={$viewingAsset}
|
||||
cursor={assetCursor}
|
||||
onNavigateToAsset={handleNavigateToAsset}
|
||||
showNavigation={assets.length > 1}
|
||||
{onNext}
|
||||
{onPrevious}
|
||||
{onRandom}
|
||||
{onAction}
|
||||
onClose={() => {
|
||||
|
||||
Reference in New Issue
Block a user