mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
feat: improve asset-viewer next/prev perf and standardize preloading behavior (#24422)
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
81f269e2a9
commit
78229baeab
24 changed files with 529 additions and 433 deletions
|
|
@ -26,6 +26,5 @@ export const makeRandomImage = () => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error('Ran out of random asset data');
|
throw new Error('Ran out of random asset data');
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -761,9 +761,6 @@ importers:
|
||||||
'@zoom-image/svelte':
|
'@zoom-image/svelte':
|
||||||
specifier: ^0.3.0
|
specifier: ^0.3.0
|
||||||
version: 0.3.8(svelte@5.46.1)
|
version: 0.3.8(svelte@5.46.1)
|
||||||
async-mutex:
|
|
||||||
specifier: ^0.5.0
|
|
||||||
version: 0.5.0
|
|
||||||
dom-to-image:
|
dom-to-image:
|
||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
|
|
@ -5620,9 +5617,6 @@ packages:
|
||||||
async-lock@1.4.1:
|
async-lock@1.4.1:
|
||||||
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
||||||
|
|
||||||
async-mutex@0.5.0:
|
|
||||||
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
|
||||||
|
|
||||||
async@0.2.10:
|
async@0.2.10:
|
||||||
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
|
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
|
||||||
|
|
||||||
|
|
@ -18001,10 +17995,6 @@ snapshots:
|
||||||
|
|
||||||
async-lock@1.4.1: {}
|
async-lock@1.4.1: {}
|
||||||
|
|
||||||
async-mutex@0.5.0:
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
async@0.2.10: {}
|
async@0.2.10: {}
|
||||||
|
|
||||||
async@3.2.6: {}
|
async@3.2.6: {}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@
|
||||||
"@types/geojson": "^7946.0.16",
|
"@types/geojson": "^7946.0.16",
|
||||||
"@zoom-image/core": "^0.41.0",
|
"@zoom-image/core": "^0.41.0",
|
||||||
"@zoom-image/svelte": "^0.3.0",
|
"@zoom-image/svelte": "^0.3.0",
|
||||||
"async-mutex": "^0.5.0",
|
|
||||||
"dom-to-image": "^2.6.0",
|
"dom-to-image": "^2.6.0",
|
||||||
"fabric": "^6.5.4",
|
"fabric": "^6.5.4",
|
||||||
"geo-coordinates-parser": "^1.7.4",
|
"geo-coordinates-parser": "^1.7.4",
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,19 @@
|
||||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||||
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
|
||||||
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
|
|
||||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||||
|
import { preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
|
|
@ -53,17 +54,22 @@
|
||||||
|
|
||||||
type HasAsset = boolean;
|
type HasAsset = boolean;
|
||||||
|
|
||||||
|
export type AssetCursor = {
|
||||||
|
current: AssetResponseDto;
|
||||||
|
nextAsset?: AssetResponseDto;
|
||||||
|
previousAsset?: AssetResponseDto;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
cursor: AssetCursor;
|
||||||
preloadAssets?: TimelineAsset[];
|
|
||||||
showNavigation?: boolean;
|
showNavigation?: boolean;
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
album?: AlbumResponseDto | null;
|
album?: AlbumResponseDto;
|
||||||
person?: PersonResponseDto | null;
|
person?: PersonResponseDto;
|
||||||
preAction?: PreAction | undefined;
|
preAction?: PreAction;
|
||||||
onAction?: OnAction | undefined;
|
onAction?: OnAction;
|
||||||
onUndoDelete?: OnUndoDelete | undefined;
|
onUndoDelete?: OnUndoDelete;
|
||||||
onClose?: (asset: AssetResponseDto) => void;
|
onClose?: (asset: AssetResponseDto) => void;
|
||||||
onNext: () => Promise<HasAsset>;
|
onNext: () => Promise<HasAsset>;
|
||||||
onPrevious: () => Promise<HasAsset>;
|
onPrevious: () => Promise<HasAsset>;
|
||||||
|
|
@ -72,16 +78,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
asset = $bindable(),
|
cursor,
|
||||||
preloadAssets = $bindable([]),
|
|
||||||
showNavigation = true,
|
showNavigation = true,
|
||||||
withStacked = false,
|
withStacked = false,
|
||||||
isShared = false,
|
isShared = false,
|
||||||
album = null,
|
album,
|
||||||
person = null,
|
person,
|
||||||
preAction = undefined,
|
preAction,
|
||||||
onAction = undefined,
|
onAction,
|
||||||
onUndoDelete = undefined,
|
onUndoDelete,
|
||||||
onClose,
|
onClose,
|
||||||
onNext,
|
onNext,
|
||||||
onPrevious,
|
onPrevious,
|
||||||
|
|
@ -100,6 +105,7 @@
|
||||||
const stackThumbnailSize = 60;
|
const stackThumbnailSize = 60;
|
||||||
const stackSelectedThumbnailSize = 65;
|
const stackSelectedThumbnailSize = 65;
|
||||||
|
|
||||||
|
let asset = $derived(cursor.current);
|
||||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||||
let sharedLink = getSharedLink();
|
let sharedLink = getSharedLink();
|
||||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||||
|
|
@ -131,7 +137,7 @@
|
||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (stack && stack?.assets.length > 1) {
|
if (stack && stack?.assets.length > 1) {
|
||||||
preloadAssets.push(toTimelineAsset(stack.assets[1]));
|
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -146,16 +152,8 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
|
|
||||||
if (assetUpdate.id === asset.id) {
|
|
||||||
asset = assetUpdate;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
unsubscribes.push(
|
unsubscribes.push(
|
||||||
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
|
|
||||||
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
|
|
||||||
slideshowState.subscribe((value) => {
|
slideshowState.subscribe((value) => {
|
||||||
if (value === SlideshowState.PlaySlideshow) {
|
if (value === SlideshowState.PlaySlideshow) {
|
||||||
slideshowHistory.reset();
|
slideshowHistory.reset();
|
||||||
|
|
@ -208,7 +206,9 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateAsset = async (order?: 'previous' | 'next', e?: Event) => {
|
const tracker = new InvocationTracker();
|
||||||
|
|
||||||
|
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||||
|
|
@ -218,38 +218,37 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
e?.stopPropagation();
|
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) {
|
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||||
if (!hasNext) {
|
if (!hasNext) {
|
||||||
const asset = await onRandom();
|
const asset = await onRandom();
|
||||||
if (asset) {
|
if (asset) {
|
||||||
slideshowHistory.queue(asset);
|
slideshowHistory.queue(asset);
|
||||||
hasNext = true;
|
hasNext = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasNext = order === 'previous' ? await onPrevious() : await onNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||||
|
if (hasNext) {
|
||||||
|
$restartSlideshowProgress = true;
|
||||||
|
} else {
|
||||||
|
await handleStopSlideshow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
hasNext = order === 'previous' ? await onPrevious() : await onNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
|
||||||
if (hasNext) {
|
|
||||||
$restartSlideshowProgress = true;
|
|
||||||
} else {
|
|
||||||
await handleStopSlideshow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// const showEditorHandler = () => {
|
|
||||||
// if (isShowActivity) {
|
|
||||||
// isShowActivity = false;
|
|
||||||
// }
|
|
||||||
// isShowEditor = !isShowEditor;
|
|
||||||
// };
|
|
||||||
|
|
||||||
const handleRunJob = async (name: AssetJobName) => {
|
const handleRunJob = async (name: AssetJobName) => {
|
||||||
try {
|
try {
|
||||||
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
|
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
|
||||||
|
|
@ -362,12 +361,6 @@
|
||||||
|
|
||||||
let isFullScreen = $derived(fullscreenElement !== null);
|
let isFullScreen = $derived(fullscreenElement !== null);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (asset) {
|
|
||||||
previewStackedAsset = undefined;
|
|
||||||
handlePromiseError(refreshStack());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
|
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||||
assetViewerManager.closeActivityPanel();
|
assetViewerManager.closeActivityPanel();
|
||||||
|
|
@ -379,13 +372,24 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// primarily, this is reactive on `asset`
|
const refresh = async () => {
|
||||||
$effect(() => {
|
await refreshStack();
|
||||||
handlePromiseError(handleGetAllAlbums());
|
await handleGetAllAlbums();
|
||||||
ocrManager.clear();
|
ocrManager.clear();
|
||||||
if (!sharedLink) {
|
if (!sharedLink) {
|
||||||
handlePromiseError(ocrManager.getAssetOcr(asset.id));
|
if (previewStackedAsset) {
|
||||||
|
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||||
|
}
|
||||||
|
await ocrManager.getAssetOcr(asset.id);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
asset;
|
||||||
|
untrack(() => handlePromiseError(refresh()));
|
||||||
|
preloadManager.preload(cursor.nextAsset);
|
||||||
|
preloadManager.preload(cursor.previousAsset);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -449,8 +453,7 @@
|
||||||
<PhotoViewer
|
<PhotoViewer
|
||||||
bind:zoomToggle
|
bind:zoomToggle
|
||||||
bind:copyImage
|
bind:copyImage
|
||||||
asset={previewStackedAsset}
|
cursor={{ ...cursor, current: previewStackedAsset }}
|
||||||
{preloadAssets}
|
|
||||||
onPreviousAsset={() => navigateAsset('previous')}
|
onPreviousAsset={() => navigateAsset('previous')}
|
||||||
onNextAsset={() => navigateAsset('next')}
|
onNextAsset={() => navigateAsset('next')}
|
||||||
haveFadeTransition={false}
|
haveFadeTransition={false}
|
||||||
|
|
@ -495,8 +498,7 @@
|
||||||
<PhotoViewer
|
<PhotoViewer
|
||||||
bind:zoomToggle
|
bind:zoomToggle
|
||||||
bind:copyImage
|
bind:copyImage
|
||||||
{asset}
|
{cursor}
|
||||||
{preloadAssets}
|
|
||||||
onPreviousAsset={() => navigateAsset('previous')}
|
onPreviousAsset={() => navigateAsset('previous')}
|
||||||
onNextAsset={() => navigateAsset('next')}
|
onNextAsset={() => navigateAsset('next')}
|
||||||
{sharedLink}
|
{sharedLink}
|
||||||
|
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
|
|
||||||
import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte';
|
|
||||||
import * as utils from '$lib/utils';
|
|
||||||
import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk';
|
|
||||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
|
||||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
|
||||||
import { render } from '@testing-library/svelte';
|
|
||||||
import type { MockInstance } from 'vitest';
|
|
||||||
|
|
||||||
class ResizeObserver {
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
globalThis.ResizeObserver = ResizeObserver;
|
|
||||||
|
|
||||||
vi.mock('$lib/utils', async (originalImport) => {
|
|
||||||
const meta = await originalImport<typeof import('$lib/utils')>();
|
|
||||||
return {
|
|
||||||
...meta,
|
|
||||||
getAssetOriginalUrl: vi.fn(),
|
|
||||||
getAssetThumbnailUrl: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PhotoViewer component', () => {
|
|
||||||
let getAssetOriginalUrlSpy: MockInstance;
|
|
||||||
let getAssetThumbnailUrlSpy: MockInstance;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl');
|
|
||||||
getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl');
|
|
||||||
|
|
||||||
vi.stubGlobal('cast', {
|
|
||||||
framework: {
|
|
||||||
CastState: {
|
|
||||||
NO_DEVICES_AVAILABLE: 'NO_DEVICES_AVAILABLE',
|
|
||||||
},
|
|
||||||
RemotePlayer: vi.fn().mockImplementation(() => ({})),
|
|
||||||
RemotePlayerEventType: {
|
|
||||||
ANY_CHANGE: 'anyChanged',
|
|
||||||
},
|
|
||||||
RemotePlayerController: vi.fn().mockImplementation(() => ({ addEventListener: vi.fn() })),
|
|
||||||
CastContext: {
|
|
||||||
getInstance: vi.fn().mockImplementation(() => ({ setOptions: vi.fn(), addEventListener: vi.fn() })),
|
|
||||||
},
|
|
||||||
CastContextEventType: {
|
|
||||||
SESSION_STATE_CHANGED: 'sessionstatechanged',
|
|
||||||
CAST_STATE_CHANGED: 'caststatechanged',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
vi.stubGlobal('chrome', {
|
|
||||||
cast: { media: { PlayerState: { IDLE: 'IDLE' } }, AutoJoinPolicy: { ORIGIN_SCOPED: 'origin_scoped' } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
Element.prototype.animate = getAnimateMock();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads the thumbnail', () => {
|
|
||||||
const asset = assetFactory.build({
|
|
||||||
originalPath: 'image.jpg',
|
|
||||||
originalMimeType: 'image/jpeg',
|
|
||||||
type: AssetTypeEnum.Image,
|
|
||||||
});
|
|
||||||
render(PhotoViewer, { asset });
|
|
||||||
|
|
||||||
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
|
|
||||||
id: asset.id,
|
|
||||||
size: AssetMediaSize.Preview,
|
|
||||||
cacheKey: asset.thumbhash,
|
|
||||||
});
|
|
||||||
expect(getAssetOriginalUrlSpy).not.toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads the thumbnail image for static gifs', () => {
|
|
||||||
const asset = assetFactory.build({
|
|
||||||
originalPath: 'image.gif',
|
|
||||||
originalMimeType: 'image/gif',
|
|
||||||
type: AssetTypeEnum.Image,
|
|
||||||
});
|
|
||||||
render(PhotoViewer, { asset });
|
|
||||||
|
|
||||||
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
|
|
||||||
id: asset.id,
|
|
||||||
size: AssetMediaSize.Preview,
|
|
||||||
cacheKey: asset.thumbhash,
|
|
||||||
});
|
|
||||||
expect(getAssetOriginalUrlSpy).not.toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads the thumbnail image for static webp images', () => {
|
|
||||||
const asset = assetFactory.build({
|
|
||||||
originalPath: 'image.webp',
|
|
||||||
originalMimeType: 'image/webp',
|
|
||||||
type: AssetTypeEnum.Image,
|
|
||||||
});
|
|
||||||
render(PhotoViewer, { asset });
|
|
||||||
|
|
||||||
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
|
|
||||||
id: asset.id,
|
|
||||||
size: AssetMediaSize.Preview,
|
|
||||||
cacheKey: asset.thumbhash,
|
|
||||||
});
|
|
||||||
expect(getAssetOriginalUrlSpy).not.toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads the original image for animated gifs', () => {
|
|
||||||
const asset = assetFactory.build({
|
|
||||||
originalPath: 'image.gif',
|
|
||||||
originalMimeType: 'image/gif',
|
|
||||||
type: AssetTypeEnum.Image,
|
|
||||||
duration: '2.0',
|
|
||||||
});
|
|
||||||
render(PhotoViewer, { asset });
|
|
||||||
|
|
||||||
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
|
|
||||||
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads the original image for animated webp images', () => {
|
|
||||||
const asset = assetFactory.build({
|
|
||||||
originalPath: 'image.webp',
|
|
||||||
originalMimeType: 'image/webp',
|
|
||||||
type: AssetTypeEnum.Image,
|
|
||||||
duration: '2.0',
|
|
||||||
});
|
|
||||||
render(PhotoViewer, { asset });
|
|
||||||
|
|
||||||
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
|
|
||||||
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => {
|
|
||||||
const asset = assetFactory.build({
|
|
||||||
originalPath: 'image.gif',
|
|
||||||
originalMimeType: 'image/gif',
|
|
||||||
type: AssetTypeEnum.Image,
|
|
||||||
});
|
|
||||||
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
|
|
||||||
render(PhotoViewer, { asset, sharedLink });
|
|
||||||
|
|
||||||
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
|
|
||||||
id: asset.id,
|
|
||||||
size: AssetMediaSize.Preview,
|
|
||||||
cacheKey: asset.thumbhash,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getAssetOriginalUrlSpy).not.toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => {
|
|
||||||
const asset = assetFactory.build({
|
|
||||||
originalPath: 'image.gif',
|
|
||||||
originalMimeType: 'image/gif',
|
|
||||||
type: AssetTypeEnum.Image,
|
|
||||||
duration: '2.0',
|
|
||||||
});
|
|
||||||
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
|
|
||||||
render(PhotoViewer, { asset, sharedLink });
|
|
||||||
|
|
||||||
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
|
|
||||||
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('not loads original animated image when shared link download permission is false', () => {
|
|
||||||
const asset = assetFactory.build({
|
|
||||||
originalPath: 'image.gif',
|
|
||||||
originalMimeType: 'image/gif',
|
|
||||||
type: AssetTypeEnum.Image,
|
|
||||||
duration: '2.0',
|
|
||||||
});
|
|
||||||
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
|
|
||||||
render(PhotoViewer, { asset, sharedLink });
|
|
||||||
|
|
||||||
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
|
|
||||||
id: asset.id,
|
|
||||||
size: AssetMediaSize.Preview,
|
|
||||||
cacheKey: asset.thumbhash,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getAssetOriginalUrlSpy).not.toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('not loads original animated image when shared link showMetadata permission is false', () => {
|
|
||||||
const asset = assetFactory.build({
|
|
||||||
originalPath: 'image.gif',
|
|
||||||
originalMimeType: 'image/gif',
|
|
||||||
type: AssetTypeEnum.Image,
|
|
||||||
duration: '2.0',
|
|
||||||
});
|
|
||||||
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
|
|
||||||
render(PhotoViewer, { asset, sharedLink });
|
|
||||||
|
|
||||||
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
|
|
||||||
id: asset.id,
|
|
||||||
size: AssetMediaSize.Preview,
|
|
||||||
cacheKey: asset.thumbhash,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getAssetOriginalUrlSpy).not.toBeCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -6,32 +6,30 @@
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
import { assetViewerFadeDuration } from '$lib/constants';
|
import { assetViewerFadeDuration } from '$lib/constants';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
|
||||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||||
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
|
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||||
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner, toastManager } from '@immich/ui';
|
import { LoadingSpinner, toastManager } from '@immich/ui';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
import type { AssetCursor } from './asset-viewer.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
cursor: AssetCursor;
|
||||||
preloadAssets?: TimelineAsset[] | undefined;
|
|
||||||
element?: HTMLDivElement | undefined;
|
element?: HTMLDivElement | undefined;
|
||||||
haveFadeTransition?: boolean;
|
haveFadeTransition?: boolean;
|
||||||
sharedLink?: SharedLinkResponseDto | undefined;
|
sharedLink?: SharedLinkResponseDto | undefined;
|
||||||
|
|
@ -42,8 +40,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
asset,
|
cursor,
|
||||||
preloadAssets = undefined,
|
|
||||||
element = $bindable(),
|
element = $bindable(),
|
||||||
haveFadeTransition = true,
|
haveFadeTransition = true,
|
||||||
sharedLink = undefined,
|
sharedLink = undefined,
|
||||||
|
|
@ -54,8 +51,8 @@
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const { slideshowState, slideshowLook } = slideshowStore;
|
const { slideshowState, slideshowLook } = slideshowStore;
|
||||||
|
const asset = $derived(cursor.current);
|
||||||
|
|
||||||
let assetFileUrl: string = $state('');
|
|
||||||
let imageLoaded: boolean = $state(false);
|
let imageLoaded: boolean = $state(false);
|
||||||
let originalImageLoaded: boolean = $state(false);
|
let originalImageLoaded: boolean = $state(false);
|
||||||
let imageError: boolean = $state(false);
|
let imageError: boolean = $state(false);
|
||||||
|
|
@ -82,25 +79,6 @@
|
||||||
|
|
||||||
let isOcrActive = $derived(ocrManager.showOverlay);
|
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||||
|
|
||||||
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
|
|
||||||
for (const preloadAsset of preloadAssets || []) {
|
|
||||||
if (preloadAsset.isImage) {
|
|
||||||
let img = new Image();
|
|
||||||
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
|
|
||||||
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
|
|
||||||
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetSize === 'original'
|
|
||||||
? getAssetOriginalUrl({ id, cacheKey })
|
|
||||||
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
|
|
||||||
};
|
|
||||||
|
|
||||||
copyImage = async () => {
|
copyImage = async () => {
|
||||||
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
|
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -155,23 +133,11 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// when true, will force loading of the original image
|
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
|
||||||
let forceUseOriginal: boolean = $derived(
|
|
||||||
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
|
|
||||||
$photoZoomState.currentZoom > 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const targetImageSize = $derived.by(() => {
|
|
||||||
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
|
|
||||||
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
|
|
||||||
}
|
|
||||||
|
|
||||||
return AssetMediaSize.Preview;
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (assetFileUrl) {
|
if (imageLoaderUrl) {
|
||||||
void cast(assetFileUrl);
|
void cast(imageLoaderUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -191,7 +157,6 @@
|
||||||
|
|
||||||
const onload = () => {
|
const onload = () => {
|
||||||
imageLoaded = true;
|
imageLoaded = true;
|
||||||
assetFileUrl = imageLoaderUrl;
|
|
||||||
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -199,27 +164,29 @@
|
||||||
imageError = imageLoaded = true;
|
imageError = imageLoaded = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
preload(targetImageSize, preloadAssets);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (loader?.complete) {
|
|
||||||
onload();
|
|
||||||
}
|
|
||||||
loader?.addEventListener('load', onload, { passive: true });
|
|
||||||
loader?.addEventListener('error', onerror, { passive: true });
|
|
||||||
return () => {
|
return () => {
|
||||||
loader?.removeEventListener('load', onload);
|
preloadManager.cancelPreloadUrl(imageLoaderUrl);
|
||||||
loader?.removeEventListener('error', onerror);
|
|
||||||
cancelImageUrl(imageLoaderUrl);
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash));
|
let imageLoaderUrl = $derived(
|
||||||
|
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
|
||||||
|
);
|
||||||
|
|
||||||
let containerWidth = $state(0);
|
let containerWidth = $state(0);
|
||||||
let containerHeight = $state(0);
|
let containerHeight = $state(0);
|
||||||
|
|
||||||
|
let lastUrl: string | undefined;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||||
|
imageLoaded = false;
|
||||||
|
originalImageLoaded = false;
|
||||||
|
imageError = false;
|
||||||
|
}
|
||||||
|
lastUrl = imageLoaderUrl;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
|
|
@ -232,19 +199,17 @@
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{#if imageError}
|
{#if imageError}
|
||||||
<div class="h-full w-full">
|
<div id="broken-asset" class="h-full w-full">
|
||||||
<BrokenAsset class="text-xl h-full w-full" />
|
<BrokenAsset class="text-xl h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- svelte-ignore a11y_missing_attribute -->
|
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
|
||||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
class="relative h-full select-none"
|
class="relative h-full w-full select-none"
|
||||||
bind:clientWidth={containerWidth}
|
bind:clientWidth={containerWidth}
|
||||||
bind:clientHeight={containerHeight}
|
bind:clientHeight={containerHeight}
|
||||||
>
|
>
|
||||||
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
|
|
||||||
{#if !imageLoaded}
|
{#if !imageLoaded}
|
||||||
<div id="spinner" class="flex h-full items-center justify-center">
|
<div id="spinner" class="flex h-full items-center justify-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
|
|
@ -258,7 +223,7 @@
|
||||||
>
|
>
|
||||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||||
<img
|
<img
|
||||||
src={assetFileUrl}
|
src={imageLoaderUrl}
|
||||||
alt=""
|
alt=""
|
||||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|
@ -266,7 +231,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
<img
|
<img
|
||||||
bind:this={$photoViewerImgElement}
|
bind:this={$photoViewerImgElement}
|
||||||
src={assetFileUrl}
|
src={imageLoaderUrl}
|
||||||
alt={$getAltText(toTimelineAsset(asset))}
|
alt={$getAltText(toTimelineAsset(asset))}
|
||||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||||
? 'object-contain'
|
? 'object-contain'
|
||||||
|
|
@ -298,6 +263,7 @@
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#broken-asset,
|
||||||
#spinner {
|
#spinner {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import { mdiEyeOffOutline } from '@mdi/js';
|
import { mdiEyeOffOutline } from '@mdi/js';
|
||||||
import type { ActionReturn } from 'svelte/action';
|
import type { ActionReturn } from 'svelte/action';
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
onComplete?.(false);
|
onComplete?.(false);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
destroy: () => cancelImageUrl(url),
|
destroy: () => preloadManager.cancelPreloadUrl(url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
|
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
||||||
import { IconButton, toastManager } from '@immich/ui';
|
import { IconButton, toastManager } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiCardsOutline,
|
mdiCardsOutline,
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
let currentMemoryAssetFull = $derived.by(async () =>
|
let currentMemoryAssetFull = $derived.by(async () =>
|
||||||
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
|
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
|
||||||
);
|
);
|
||||||
let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []);
|
let currentTimelineAssets = $derived(current?.memory.assets || []);
|
||||||
|
|
||||||
let isSaved = $derived(current?.memory.isSaved);
|
let isSaved = $derived(current?.memory.isSaved);
|
||||||
let viewerHeight = $state(0);
|
let viewerHeight = $state(0);
|
||||||
|
|
@ -396,7 +396,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if currentTimelineAssets.some(({ isVideo }) => isVideo)}
|
{#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
|
||||||
<div class="w-12.5 dark">
|
<div class="w-12.5 dark">
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
let assets = $derived(sharedLink.assets.map((a) => toTimelineAsset(a)));
|
let assets = $derived(sharedLink.assets);
|
||||||
|
|
||||||
dragAndDropFilesStore.subscribe((value) => {
|
dragAndDropFilesStore.subscribe((value) => {
|
||||||
if (value.isDragging && value.files.length > 0) {
|
if (value.isDragging && value.files.length > 0) {
|
||||||
|
|
@ -68,7 +68,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
assetInteraction.selectAssets(assets);
|
assetInteraction.selectAssets(assets.map((asset) => toTimelineAsset(asset)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = async (action: Action) => {
|
const handleAction = async (action: Action) => {
|
||||||
|
|
@ -145,7 +145,7 @@
|
||||||
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
|
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
|
||||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||||
<AssetViewer
|
<AssetViewer
|
||||||
{asset}
|
cursor={{ current: asset }}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
onPrevious={() => Promise.resolve(false)}
|
onPrevious={() => Promise.resolve(false)}
|
||||||
onNext={() => Promise.resolve(false)}
|
onNext={() => Promise.resolve(false)}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { deleteAssets } from '$lib/utils/actions';
|
import { deleteAssets } from '$lib/utils/actions';
|
||||||
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
|
import { archiveAssets, cancelMultiselect, getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
|
||||||
import { moveFocus } from '$lib/utils/focus-util';
|
import { moveFocus } from '$lib/utils/focus-util';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialAssetId?: string;
|
initialAssetId?: string;
|
||||||
assets: TimelineAsset[] | AssetResponseDto[];
|
assets: AssetResponseDto[];
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
disableAssetSelect?: boolean;
|
disableAssetSelect?: boolean;
|
||||||
showArchiveIcon?: boolean;
|
showArchiveIcon?: boolean;
|
||||||
|
|
@ -229,7 +229,7 @@
|
||||||
isShowDeleteConfirmation = false;
|
isShowDeleteConfirmation = false;
|
||||||
await deleteAssets(
|
await deleteAssets(
|
||||||
!(isTrashEnabled && !force),
|
!(isTrashEnabled && !force),
|
||||||
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]),
|
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
|
||||||
assetInteraction.selectedAssets,
|
assetInteraction.selectedAssets,
|
||||||
onReload,
|
onReload,
|
||||||
);
|
);
|
||||||
|
|
@ -242,7 +242,7 @@
|
||||||
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
|
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
|
||||||
);
|
);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[];
|
assets = assets.filter((asset) => !ids.includes(asset.id));
|
||||||
deselectAllAssets();
|
deselectAllAssets();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -424,6 +424,12 @@
|
||||||
selectAssetCandidates(lastAssetMouseEvent);
|
selectAssetCandidates(lastAssetMouseEvent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const assetCursor = $derived({
|
||||||
|
current: $viewingAsset,
|
||||||
|
nextAsset: getNextAsset(assets, $viewingAsset),
|
||||||
|
previousAsset: getPreviousAsset(assets, $viewingAsset),
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
|
|
@ -488,7 +494,7 @@
|
||||||
<Portal target="body">
|
<Portal target="body">
|
||||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||||
<AssetViewer
|
<AssetViewer
|
||||||
asset={$viewingAsset}
|
cursor={assetCursor}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
onPrevious={handlePrevious}
|
onPrevious={handlePrevious}
|
||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,13 @@
|
||||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||||
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
||||||
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
||||||
|
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
|
||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||||
|
|
@ -49,9 +49,9 @@
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
showArchiveIcon?: boolean;
|
showArchiveIcon?: boolean;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
album?: AlbumResponseDto | null;
|
album?: AlbumResponseDto;
|
||||||
albumUsers?: UserResponseDto[];
|
albumUsers?: UserResponseDto[];
|
||||||
person?: PersonResponseDto | null;
|
person?: PersonResponseDto;
|
||||||
isShowDeleteConfirmation?: boolean;
|
isShowDeleteConfirmation?: boolean;
|
||||||
onSelect?: (asset: TimelineAsset) => void;
|
onSelect?: (asset: TimelineAsset) => void;
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
|
|
@ -82,9 +82,9 @@
|
||||||
withStacked = false,
|
withStacked = false,
|
||||||
showArchiveIcon = false,
|
showArchiveIcon = false,
|
||||||
isShared = false,
|
isShared = false,
|
||||||
album = null,
|
album,
|
||||||
albumUsers = [],
|
albumUsers = [],
|
||||||
person = null,
|
person,
|
||||||
isShowDeleteConfirmation = $bindable(false),
|
isShowDeleteConfirmation = $bindable(false),
|
||||||
onSelect = () => {},
|
onSelect = () => {},
|
||||||
onEscape = () => {},
|
onEscape = () => {},
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,29 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||||
|
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
|
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
|
||||||
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
|
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
timelineManager: TimelineManager;
|
timelineManager: TimelineManager;
|
||||||
invisible: boolean;
|
invisible: boolean;
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
album?: AlbumResponseDto | null;
|
album?: AlbumResponseDto;
|
||||||
person?: PersonResponseDto | null;
|
person?: PersonResponseDto;
|
||||||
|
|
||||||
removeAction?:
|
removeAction?:
|
||||||
| AssetAction.UNARCHIVE
|
| AssetAction.UNARCHIVE
|
||||||
|
|
@ -35,48 +40,72 @@
|
||||||
removeAction,
|
removeAction,
|
||||||
withStacked = false,
|
withStacked = false,
|
||||||
isShared = false,
|
isShared = false,
|
||||||
album = null,
|
album,
|
||||||
person = null,
|
person,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const handlePrevious = async () => {
|
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
|
||||||
const release = await mutex.acquire();
|
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
|
||||||
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
|
if (earlierTimelineAsset) {
|
||||||
|
const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id });
|
||||||
if (laterAsset) {
|
if (preload) {
|
||||||
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
|
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
|
||||||
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
|
void getNextAsset(asset, false);
|
||||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
}
|
||||||
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
release();
|
|
||||||
return !!laterAsset;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
|
||||||
const release = await mutex.acquire();
|
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
|
||||||
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
|
|
||||||
|
|
||||||
if (earlierAsset) {
|
if (laterTimelineAsset) {
|
||||||
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
|
const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id });
|
||||||
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
|
if (preload) {
|
||||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
|
||||||
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
|
void getPreviousAsset(asset, false);
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let assetCursor = $state<AssetCursor>({
|
||||||
|
current: $viewingAsset,
|
||||||
|
previousAsset: undefined,
|
||||||
|
nextAsset: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
|
||||||
|
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
|
||||||
|
|
||||||
|
assetCursor = {
|
||||||
|
current: currentAsset,
|
||||||
|
nextAsset,
|
||||||
|
previousAsset,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
//TODO: replace this with async derived in svelte 6
|
||||||
|
$effect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
$viewingAsset;
|
||||||
|
untrack(() => handlePromiseError(loadCloseAssets($viewingAsset)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNavigateToAsset = async (targetAsset: AssetResponseDto | undefined | null) => {
|
||||||
|
if (!targetAsset) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
release();
|
await navigate({ targetRoute: 'current', assetId: targetAsset.id });
|
||||||
return !!earlierAsset;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRandom = async () => {
|
const handleRandom = async () => {
|
||||||
const randomAsset = await timelineManager.getRandomAsset();
|
const randomAsset = await timelineManager.getRandomAsset();
|
||||||
|
|
||||||
if (randomAsset) {
|
if (randomAsset) {
|
||||||
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
|
|
||||||
assetViewingStore.setAsset(asset);
|
|
||||||
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
||||||
return asset;
|
return { id: randomAsset.id };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -98,7 +127,9 @@
|
||||||
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
||||||
// find the next asset to show or close the viewer
|
// find the next asset to show or close the viewer
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
|
(await handleNavigateToAsset(assetCursor?.nextAsset)) ||
|
||||||
|
(await handleNavigateToAsset(assetCursor?.previousAsset)) ||
|
||||||
|
(await handleClose(action.asset));
|
||||||
|
|
||||||
// delete after find the next one
|
// delete after find the next one
|
||||||
timelineManager.removeAssets([action.asset.id]);
|
timelineManager.removeAssets([action.asset.id]);
|
||||||
|
|
@ -173,21 +204,36 @@
|
||||||
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
|
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const onAssetUpdate = ({ asset }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
|
||||||
|
if (asset.id === assetCursor.current.id) {
|
||||||
|
void loadCloseAssets(asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onMount(() => {
|
||||||
|
const unsubscribes = [
|
||||||
|
websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => onAssetUpdate({ event: 'upload', asset })),
|
||||||
|
websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => onAssetUpdate({ event: 'update', asset })),
|
||||||
|
];
|
||||||
|
return () => {
|
||||||
|
for (const unsubscribe of unsubscribes) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||||
<AssetViewer
|
<AssetViewer
|
||||||
{withStacked}
|
{withStacked}
|
||||||
asset={$viewingAsset}
|
cursor={assetCursor}
|
||||||
preloadAssets={$preloadAssets}
|
|
||||||
{isShared}
|
{isShared}
|
||||||
{album}
|
{album}
|
||||||
{person}
|
{person}
|
||||||
preAction={handlePreAction}
|
preAction={handlePreAction}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
onUndoDelete={handleUndoDelete}
|
onUndoDelete={handleUndoDelete}
|
||||||
onPrevious={handlePrevious}
|
onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)}
|
||||||
onNext={handleNext}
|
onNext={() => handleNavigateToAsset(assetCursor.nextAsset)}
|
||||||
onRandom={handleRandom}
|
onRandom={handleRandom}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
|
||||||
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
|
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||||
|
|
@ -102,6 +103,12 @@
|
||||||
const handleStack = () => {
|
const handleStack = () => {
|
||||||
onStack(assets);
|
onStack(assets);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assetCursor = $derived({
|
||||||
|
current: $viewingAsset,
|
||||||
|
nextAsset: getNextAsset(assets, $viewingAsset),
|
||||||
|
previousAsset: getPreviousAsset(assets, $viewingAsset),
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
|
|
@ -182,7 +189,7 @@
|
||||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||||
<Portal target="body">
|
<Portal target="body">
|
||||||
<AssetViewer
|
<AssetViewer
|
||||||
asset={$viewingAsset}
|
cursor={assetCursor}
|
||||||
showNavigation={assets.length > 1}
|
showNavigation={assets.length > 1}
|
||||||
{onNext}
|
{onNext}
|
||||||
{onPrevious}
|
{onPrevious}
|
||||||
|
|
|
||||||
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) {
|
||||||
|
if (globalThis.isSecureContext) {
|
||||||
|
preloadImageUrl(getAssetUrl({ asset }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!asset || asset.type !== AssetTypeEnum.Image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = new Image();
|
||||||
|
const url = getAssetUrl({ asset });
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(asset: AssetResponseDto | undefined) {
|
||||||
|
if (!globalThis.isSecureContext || !asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = getAssetUrl({ asset });
|
||||||
|
cancelImageUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPreloadUrl(url: string | undefined) {
|
||||||
|
if (!globalThis.isSecureContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelImageUrl(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const preloadManager = new PreloadManager();
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
<div
|
<div
|
||||||
class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
|
class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
|
||||||
>
|
>
|
||||||
<PhotoViewer bind:element={imgElement} {asset} />
|
<PhotoViewer bind:element={imgElement} cursor={{ current: asset }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { Mutex } from 'async-mutex';
|
|
||||||
import { readonly, writable } from 'svelte/store';
|
import { readonly, writable } from 'svelte/store';
|
||||||
|
|
||||||
function createAssetViewingStore() {
|
function createAssetViewingStore() {
|
||||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||||
const preloadAssets = writable<TimelineAsset[]>([]);
|
|
||||||
const viewState = writable<boolean>(false);
|
const viewState = writable<boolean>(false);
|
||||||
const viewingAssetMutex = new Mutex();
|
|
||||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||||
|
|
||||||
const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => {
|
const setAsset = (asset: AssetResponseDto) => {
|
||||||
preloadAssets.set(assetsToPreload);
|
|
||||||
viewingAssetStoreState.set(asset);
|
viewingAssetStoreState.set(asset);
|
||||||
viewState.set(true);
|
viewState.set(true);
|
||||||
};
|
};
|
||||||
|
|
@ -30,8 +26,6 @@ function createAssetViewingStore() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
asset: readonly(viewingAssetStoreState),
|
asset: readonly(viewingAssetStoreState),
|
||||||
mutex: viewingAssetMutex,
|
|
||||||
preloadAssets: readonly(preloadAssets),
|
|
||||||
isViewing: viewState,
|
isViewing: viewState,
|
||||||
gridScrollTarget,
|
gridScrollTarget,
|
||||||
setAsset,
|
setAsset,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,141 @@
|
||||||
import { getReleaseType } from '$lib/utils';
|
import { getAssetUrl, getReleaseType } from '$lib/utils';
|
||||||
|
import { AssetTypeEnum } from '@immich/sdk';
|
||||||
|
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||||
|
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||||
|
|
||||||
describe('utils', () => {
|
describe('utils', () => {
|
||||||
|
describe(getAssetUrl.name, () => {
|
||||||
|
it('should return thumbnail URL for static images', () => {
|
||||||
|
const asset = assetFactory.build({
|
||||||
|
originalPath: 'image.jpg',
|
||||||
|
originalMimeType: 'image/jpeg',
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = getAssetUrl({ asset });
|
||||||
|
|
||||||
|
// Should return a thumbnail URL (contains /thumbnail)
|
||||||
|
expect(url).toContain('/thumbnail');
|
||||||
|
expect(url).toContain(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return thumbnail URL for static gifs', () => {
|
||||||
|
const asset = assetFactory.build({
|
||||||
|
originalPath: 'image.gif',
|
||||||
|
originalMimeType: 'image/gif',
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = getAssetUrl({ asset });
|
||||||
|
|
||||||
|
expect(url).toContain('/thumbnail');
|
||||||
|
expect(url).toContain(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return thumbnail URL for static webp images', () => {
|
||||||
|
const asset = assetFactory.build({
|
||||||
|
originalPath: 'image.webp',
|
||||||
|
originalMimeType: 'image/webp',
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = getAssetUrl({ asset });
|
||||||
|
|
||||||
|
expect(url).toContain('/thumbnail');
|
||||||
|
expect(url).toContain(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original URL for animated gifs', () => {
|
||||||
|
const asset = assetFactory.build({
|
||||||
|
originalPath: 'image.gif',
|
||||||
|
originalMimeType: 'image/gif',
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
duration: '2.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = getAssetUrl({ asset });
|
||||||
|
|
||||||
|
// Should return original URL (contains /original)
|
||||||
|
expect(url).toContain('/original');
|
||||||
|
expect(url).toContain(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original URL for animated webp images', () => {
|
||||||
|
const asset = assetFactory.build({
|
||||||
|
originalPath: 'image.webp',
|
||||||
|
originalMimeType: 'image/webp',
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
duration: '2.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = getAssetUrl({ asset });
|
||||||
|
|
||||||
|
expect(url).toContain('/original');
|
||||||
|
expect(url).toContain(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return thumbnail URL for static images in shared link even with download and showMetadata permissions', () => {
|
||||||
|
const asset = assetFactory.build({
|
||||||
|
originalPath: 'image.gif',
|
||||||
|
originalMimeType: 'image/gif',
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
});
|
||||||
|
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
|
||||||
|
|
||||||
|
const url = getAssetUrl({ asset, sharedLink });
|
||||||
|
|
||||||
|
expect(url).toContain('/thumbnail');
|
||||||
|
expect(url).toContain(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original URL for animated images in shared link with download and showMetadata permissions', () => {
|
||||||
|
const asset = assetFactory.build({
|
||||||
|
originalPath: 'image.gif',
|
||||||
|
originalMimeType: 'image/gif',
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
duration: '2.0',
|
||||||
|
});
|
||||||
|
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
|
||||||
|
|
||||||
|
const url = getAssetUrl({ asset, sharedLink });
|
||||||
|
|
||||||
|
expect(url).toContain('/original');
|
||||||
|
expect(url).toContain(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return thumbnail URL (not original) for animated images when shared link download permission is false', () => {
|
||||||
|
const asset = assetFactory.build({
|
||||||
|
originalPath: 'image.gif',
|
||||||
|
originalMimeType: 'image/gif',
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
duration: '2.0',
|
||||||
|
});
|
||||||
|
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
|
||||||
|
|
||||||
|
const url = getAssetUrl({ asset, sharedLink });
|
||||||
|
|
||||||
|
expect(url).toContain('/thumbnail');
|
||||||
|
expect(url).not.toContain('/original');
|
||||||
|
expect(url).toContain(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return thumbnail URL (not original) for animated images when shared link showMetadata permission is false', () => {
|
||||||
|
const asset = assetFactory.build({
|
||||||
|
originalPath: 'image.gif',
|
||||||
|
originalMimeType: 'image/gif',
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
duration: '2.0',
|
||||||
|
});
|
||||||
|
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
|
||||||
|
|
||||||
|
const url = getAssetUrl({ asset, sharedLink });
|
||||||
|
|
||||||
|
expect(url).toContain('/thumbnail');
|
||||||
|
expect(url).not.toContain('/original');
|
||||||
|
expect(url).toContain(asset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe(getReleaseType.name, () => {
|
describe(getReleaseType.name, () => {
|
||||||
it('should return "major" for major version changes', () => {
|
it('should return "major" for major version changes', () => {
|
||||||
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major');
|
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major');
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { defaultLang, langs, locales } from '$lib/constants';
|
import { defaultLang, langs, locales } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { lang } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||||
|
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
AssetMediaSize,
|
AssetMediaSize,
|
||||||
|
AssetTypeEnum,
|
||||||
MemoryType,
|
MemoryType,
|
||||||
QueueName,
|
QueueName,
|
||||||
finishOAuth,
|
finishOAuth,
|
||||||
|
|
@ -17,6 +19,7 @@ import {
|
||||||
linkOAuthAccount,
|
linkOAuthAccount,
|
||||||
startOAuth,
|
startOAuth,
|
||||||
unlinkOAuthAccount,
|
unlinkOAuthAccount,
|
||||||
|
type AssetResponseDto,
|
||||||
type MemoryResponseDto,
|
type MemoryResponseDto,
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
type ServerVersionResponseDto,
|
type ServerVersionResponseDto,
|
||||||
|
|
@ -191,6 +194,40 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
|
||||||
|
|
||||||
type AssetUrlOptions = { id: string; cacheKey?: string | null };
|
type AssetUrlOptions = { id: string; cacheKey?: string | null };
|
||||||
|
|
||||||
|
export const getAssetUrl = ({
|
||||||
|
asset,
|
||||||
|
sharedLink,
|
||||||
|
forceOriginal = false,
|
||||||
|
}: {
|
||||||
|
asset: AssetResponseDto | undefined;
|
||||||
|
sharedLink?: SharedLinkResponseDto;
|
||||||
|
forceOriginal?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = asset.id;
|
||||||
|
const cacheKey = asset.thumbhash;
|
||||||
|
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
|
||||||
|
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
|
||||||
|
}
|
||||||
|
const targetSize = targetImageSize(asset, forceOriginal);
|
||||||
|
return targetSize === 'original'
|
||||||
|
? getAssetOriginalUrl({ id, cacheKey })
|
||||||
|
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
|
||||||
|
};
|
||||||
|
|
||||||
|
const forceUseOriginal = (asset: AssetResponseDto) => {
|
||||||
|
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => {
|
||||||
|
if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) {
|
||||||
|
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
|
||||||
|
}
|
||||||
|
return AssetMediaSize.Preview;
|
||||||
|
};
|
||||||
|
|
||||||
export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
|
export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
|
||||||
if (typeof options === 'string') {
|
if (typeof options === 'string') {
|
||||||
options = { id: options };
|
options = { id: options };
|
||||||
|
|
|
||||||
|
|
@ -557,6 +557,14 @@ export const delay = async (ms: number) => {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getNextAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => {
|
||||||
|
return currentAsset && assets[assets.indexOf(currentAsset) + 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPreviousAsset = (assets: AssetResponseDto[], currentAsset: AssetResponseDto | undefined) => {
|
||||||
|
return currentAsset && assets[assets.indexOf(currentAsset) - 1];
|
||||||
|
};
|
||||||
|
|
||||||
export const canCopyImageToClipboard = (): boolean => {
|
export const canCopyImageToClipboard = (): boolean => {
|
||||||
return !!(navigator.clipboard && globalThis.ClipboardItem);
|
return !!(navigator.clipboard && globalThis.ClipboardItem);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -50,4 +50,13 @@ export class InvocationTracker {
|
||||||
isActive() {
|
isActive() {
|
||||||
return this.invocationsStarted !== this.invocationsEnded;
|
return this.invocationsStarted !== this.invocationsEnded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async invoke<T>(invocable: () => Promise<T>) {
|
||||||
|
const invocation = this.startInvocation();
|
||||||
|
try {
|
||||||
|
return await invocable();
|
||||||
|
} finally {
|
||||||
|
invocation.endInvocation();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
const broadcast = new BroadcastChannel('immich');
|
const broadcast = new BroadcastChannel('immich');
|
||||||
|
|
||||||
export function cancelImageUrl(url: string) {
|
export function cancelImageUrl(url: string | undefined | null) {
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
broadcast.postMessage({ type: 'cancel', url });
|
broadcast.postMessage({ type: 'cancel', url });
|
||||||
}
|
}
|
||||||
export function preloadImageUrl(url: string) {
|
export function preloadImageUrl(url: string | undefined | null) {
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
broadcast.postMessage({ type: 'preload', url });
|
broadcast.postMessage({ type: 'preload', url });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import { AppRoute, timeToLoadTheMap } from '$lib/constants';
|
import { AppRoute, timeToLoadTheMap } from '$lib/constants';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { delay } from '$lib/utils/asset-utils';
|
import { delay } from '$lib/utils/asset-utils';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
|
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy, untrack } from 'svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -64,6 +67,59 @@
|
||||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getNextAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
|
||||||
|
if (!currentAsset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cursor = viewingAssets.indexOf(currentAsset.id);
|
||||||
|
if (cursor < viewingAssets.length - 1) {
|
||||||
|
const id = viewingAssets[cursor + 1];
|
||||||
|
const asset = await getAssetInfo({ ...authManager.params, id });
|
||||||
|
if (preload) {
|
||||||
|
void getNextAsset(asset, false);
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreviousAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
|
||||||
|
if (!currentAsset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cursor = viewingAssets.indexOf(currentAsset.id);
|
||||||
|
if (cursor <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = viewingAssets[cursor - 1];
|
||||||
|
const asset = await getAssetInfo({ ...authManager.params, id });
|
||||||
|
if (preload) {
|
||||||
|
void getPreviousAsset(asset, false);
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
|
};
|
||||||
|
|
||||||
|
let assetCursor = $state<AssetCursor>({
|
||||||
|
current: $viewingAsset,
|
||||||
|
previousAsset: undefined,
|
||||||
|
nextAsset: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
|
||||||
|
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
|
||||||
|
assetCursor = {
|
||||||
|
current: currentAsset,
|
||||||
|
nextAsset,
|
||||||
|
previousAsset,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
//TODO: replace this with async derived in svelte 6
|
||||||
|
$effect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
$viewingAsset;
|
||||||
|
untrack(() => void loadCloseAssets($viewingAsset));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if featureFlagsManager.value.map}
|
{#if featureFlagsManager.value.map}
|
||||||
|
|
@ -85,7 +141,7 @@
|
||||||
{#if $showAssetViewer}
|
{#if $showAssetViewer}
|
||||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||||
<AssetViewer
|
<AssetViewer
|
||||||
asset={$viewingAsset}
|
cursor={assetCursor}
|
||||||
showNavigation={viewingAssets.length > 1}
|
showNavigation={viewingAssets.length > 1}
|
||||||
onNext={navigateNext}
|
onNext={navigateNext}
|
||||||
onPrevious={navigatePrevious}
|
onPrevious={navigatePrevious}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { lang, locale } from '$lib/stores/preferences.store';
|
import { lang, locale } from '$lib/stores/preferences.store';
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
|
type AssetResponseDto,
|
||||||
getPerson,
|
getPerson,
|
||||||
getTagById,
|
getTagById,
|
||||||
type MetadataSearchDto,
|
type MetadataSearchDto,
|
||||||
|
|
@ -58,7 +59,7 @@
|
||||||
|
|
||||||
let nextPage = $state(1);
|
let nextPage = $state(1);
|
||||||
let searchResultAlbums: AlbumResponseDto[] = $state([]);
|
let searchResultAlbums: AlbumResponseDto[] = $state([]);
|
||||||
let searchResultAssets: TimelineAsset[] = $state([]);
|
let searchResultAssets: AssetResponseDto[] = $state([]);
|
||||||
let isLoading = $state(true);
|
let isLoading = $state(true);
|
||||||
let scrollY = $state(0);
|
let scrollY = $state(0);
|
||||||
let scrollYHistory = 0;
|
let scrollYHistory = 0;
|
||||||
|
|
@ -121,7 +122,7 @@
|
||||||
|
|
||||||
const onAssetDelete = (assetIds: string[]) => {
|
const onAssetDelete = (assetIds: string[]) => {
|
||||||
const assetIdSet = new Set(assetIds);
|
const assetIdSet = new Set(assetIds);
|
||||||
searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id));
|
searchResultAssets = searchResultAssets.filter((asset: AssetResponseDto) => !assetIdSet.has(asset.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetVisibility = (assetIds: string[]) => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
|
|
@ -130,7 +131,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
assetInteraction.selectAssets(searchResultAssets);
|
assetInteraction.selectAssets(searchResultAssets.map((asset) => toTimelineAsset(asset)));
|
||||||
};
|
};
|
||||||
|
|
||||||
async function onSearchQueryUpdate() {
|
async function onSearchQueryUpdate() {
|
||||||
|
|
@ -162,7 +163,7 @@
|
||||||
: await searchAssets({ metadataSearchDto: searchDto });
|
: await searchAssets({ metadataSearchDto: searchDto });
|
||||||
|
|
||||||
searchResultAlbums.push(...albums.items);
|
searchResultAlbums.push(...albums.items);
|
||||||
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
|
searchResultAssets.push(...assets.items);
|
||||||
|
|
||||||
nextPage = Number(assets.nextPage) || 0;
|
nextPage = Number(assets.nextPage) || 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
|
|
@ -65,6 +66,12 @@
|
||||||
const onViewAsset = async (asset: AssetResponseDto) => {
|
const onViewAsset = async (asset: AssetResponseDto) => {
|
||||||
await navigate({ targetRoute: 'current', assetId: asset.id });
|
await navigate({ targetRoute: 'current', assetId: asset.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assetCursor = $derived({
|
||||||
|
current: $viewingAsset,
|
||||||
|
nextAsset: getNextAsset(assets, $viewingAsset),
|
||||||
|
previousAsset: getPreviousAsset(assets, $viewingAsset),
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
||||||
|
|
@ -85,7 +92,7 @@
|
||||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||||
<Portal target="body">
|
<Portal target="body">
|
||||||
<AssetViewer
|
<AssetViewer
|
||||||
asset={$viewingAsset}
|
cursor={assetCursor}
|
||||||
showNavigation={assets.length > 1}
|
showNavigation={assets.length > 1}
|
||||||
{onNext}
|
{onNext}
|
||||||
{onPrevious}
|
{onPrevious}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue