diff --git a/e2e/src/generators.ts b/e2e/src/generators.ts index c87427ceab568..5e4895d7080ed 100644 --- a/e2e/src/generators.ts +++ b/e2e/src/generators.ts @@ -26,6 +26,5 @@ export const makeRandomImage = () => { if (!value) { throw new Error('Ran out of random asset data'); } - return value; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91285d67843e6..d4ffc00639d49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -726,8 +726,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.52.0 - version: 0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) + specifier: ^0.53.3 + version: 0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -761,9 +761,6 @@ importers: '@zoom-image/svelte': specifier: ^0.3.0 version: 0.3.8(svelte@5.46.1) - async-mutex: - specifier: ^0.5.0 - version: 0.5.0 dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -3078,8 +3075,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.52.0': - resolution: {integrity: sha512-ECQIE5qYNpe7Q5+hifIGUDaRQXBkPOp9dvZaHELWWzAGIhbwG+mUYwMpUgU2TO7fV5u8XU6nHyBuC055zApiWQ==} + '@immich/ui@0.53.3': + resolution: {integrity: sha512-Ax7ctU9KIZgET58+PoMQnf1XDOIH76Xa341TXDfLwF96F3fQZ/v4TA7Ycb6hmTwIYGU9arIgqGqQDbuuNxc2vA==} peerDependencies: svelte: ^5.0.0 @@ -5620,9 +5617,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==} @@ -15084,7 +15078,7 @@ snapshots: dependencies: svelte: 5.46.1 - '@immich/ui@0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)': + '@immich/ui@0.53.3(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)': dependencies: '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1) '@internationalized/date': 3.10.0 @@ -18001,10 +17995,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: {} diff --git a/web/package.json b/web/package.json index 52d06bb519b4d..ef48a8a92ffaf 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.52.0", + "@immich/ui": "^0.53.3", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", @@ -39,7 +39,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", diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte index e0e7e1eff759c..ae8d1199e08d7 100644 --- a/web/src/lib/components/ActionButton.svelte +++ b/web/src/lib/components/ActionButton.svelte @@ -1,4 +1,5 @@ -{#if action.$if?.() ?? true} +{#if icon && isEnabled(action)} onAction(action)} /> {/if} diff --git a/web/src/lib/components/ActionMenuItem.svelte b/web/src/lib/components/ActionMenuItem.svelte new file mode 100644 index 0000000000000..d50d50bf0b60a --- /dev/null +++ b/web/src/lib/components/ActionMenuItem.svelte @@ -0,0 +1,16 @@ + + +{#if icon && isEnabled(action)} + onAction(action)} /> +{/if} diff --git a/web/src/lib/components/TableButton.svelte b/web/src/lib/components/TableButton.svelte index 844c4c0bf82d5..619d2f6c27486 100644 --- a/web/src/lib/components/TableButton.svelte +++ b/web/src/lib/components/TableButton.svelte @@ -10,6 +10,6 @@ const { title, icon, onAction } = $derived(action); -{#if action.$if?.() ?? true} +{#if icon && (action.$if?.() ?? true)} onAction(action)} /> {/if} diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index df61b5d073999..19cc5afa8d540 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -5,8 +5,6 @@ import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackRespon type ActionMap = { [AssetAction.ARCHIVE]: { asset: TimelineAsset }; [AssetAction.UNARCHIVE]: { asset: TimelineAsset }; - [AssetAction.FAVORITE]: { asset: TimelineAsset }; - [AssetAction.UNFAVORITE]: { asset: TimelineAsset }; [AssetAction.TRASH]: { asset: TimelineAsset }; [AssetAction.DELETE]: { asset: TimelineAsset }; [AssetAction.RESTORE]: { asset: TimelineAsset }; diff --git a/web/src/lib/components/asset-viewer/actions/download-action.svelte b/web/src/lib/components/asset-viewer/actions/download-action.svelte deleted file mode 100644 index f790569703850..0000000000000 --- a/web/src/lib/components/asset-viewer/actions/download-action.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - -{#if !menuItem} - -{:else} - -{/if} diff --git a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte b/web/src/lib/components/asset-viewer/actions/favorite-action.svelte deleted file mode 100644 index ba23570d363cf..0000000000000 --- a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - - - - diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 38ab066c82d79..60bde6e114172 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -2,13 +2,12 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import ActionButton from '$lib/components/ActionButton.svelte'; + import ActionMenuItem from '$lib/components/ActionMenuItem.svelte'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; - import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte'; - import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte'; import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte'; @@ -28,7 +27,7 @@ import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { user } from '$lib/stores/user.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; - import { getAssetJobName, getSharedLink } from '$lib/utils'; + import { getAssetJobName, withoutIcons } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { toTimelineAsset } from '$lib/utils/timeline-util'; @@ -97,14 +96,13 @@ setPlayOriginalVideo, }: Props = $props(); - const sharedLink = getSharedLink(); let isOwner = $derived($user && asset.ownerId === $user?.id); - let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline); let isLocked = $derived(asset.visibility === AssetVisibility.Locked); let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); const Close: ActionItem = { title: $t('go_back'), + type: $t('assets'), icon: mdiArrowLeft, $if: () => !!onClose, onAction: () => onClose?.(), @@ -113,7 +111,8 @@ const { Cast } = $derived(getGlobalActions($t)); - const { Share, Offline, PlayMotionPhoto, StopMotionPhoto, Info } = $derived(getAssetActions($t, asset)); + const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } = + $derived(getAssetActions($t, asset)); // $: showEditorButton = // isOwner && @@ -128,7 +127,7 @@
{/if} - {#if !isOwner && showDownloadButton} - - {/if} - + + + {#if isOwner} - {/if} @@ -185,9 +182,8 @@ {#if showSlideshow && !isLocked} {/if} - {#if showDownloadButton} - - {/if} + + {#if !isLocked} {#if asset.isTrashed} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 03571bbebfe4c..27fe0f8c7470c 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -10,18 +10,19 @@ import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; - import { websocketEvents } from '$lib/stores/websocket'; - import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils'; + import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; import { handleError } from '$lib/utils/handle-error'; + import { InvocationTracker } from '$lib/utils/invocationTracker'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; + import { preloadImageUrl } from '$lib/utils/sw-messaging'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetJobName, @@ -53,17 +54,22 @@ type HasAsset = boolean; + export type AssetCursor = { + current: AssetResponseDto; + nextAsset?: AssetResponseDto; + previousAsset?: AssetResponseDto; + }; + interface Props { - asset: AssetResponseDto; - preloadAssets?: TimelineAsset[]; + cursor: AssetCursor; showNavigation?: boolean; withStacked?: boolean; isShared?: boolean; - album?: AlbumResponseDto | null; - person?: PersonResponseDto | null; - preAction?: PreAction | undefined; - onAction?: OnAction | undefined; - onUndoDelete?: OnUndoDelete | undefined; + album?: AlbumResponseDto; + person?: PersonResponseDto; + preAction?: PreAction; + onAction?: OnAction; + onUndoDelete?: OnUndoDelete; onClose?: (asset: AssetResponseDto) => void; onNext: () => Promise; onPrevious: () => Promise; @@ -72,16 +78,15 @@ } let { - asset = $bindable(), - preloadAssets = $bindable([]), + cursor, showNavigation = true, withStacked = false, isShared = false, - album = null, - person = null, - preAction = undefined, - onAction = undefined, - onUndoDelete = undefined, + album, + person, + preAction, + onAction, + onUndoDelete, onClose, onNext, onPrevious, @@ -100,6 +105,7 @@ const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; + let asset = $derived(cursor.current); let appearsInAlbums: AlbumResponseDto[] = $state([]); let sharedLink = getSharedLink(); let previewStackedAsset: AssetResponseDto | undefined = $state(); @@ -131,7 +137,7 @@ untrack(() => { if (stack && stack?.assets.length > 1) { - preloadAssets.push(toTimelineAsset(stack.assets[1])); + preloadImageUrl(getAssetUrl({ asset: stack.assets[1] })); } }); }; @@ -146,16 +152,8 @@ } }; - const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => { - if (assetUpdate.id === asset.id) { - asset = assetUpdate; - } - }; - onMount(async () => { unsubscribes.push( - websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })), - websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })), slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) { slideshowHistory.reset(); @@ -208,7 +206,9 @@ }); }; - const navigateAsset = async (order?: 'previous' | 'next', e?: Event) => { + const tracker = new InvocationTracker(); + + const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -218,38 +218,37 @@ } e?.stopPropagation(); + preloadManager.cancel(asset); + if (tracker.isActive()) { + return; + } - let hasNext = false; - - 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; + 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; + } } + } else { + hasNext = order === 'previous' ? await onPrevious() : await onNext(); } - } else { - hasNext = order === 'previous' ? await onPrevious() : await onNext(); - } - if ($slideshowState === SlideshowState.PlaySlideshow) { - if (hasNext) { - $restartSlideshowProgress = true; - } else { - await handleStopSlideshow(); + 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 } }); @@ -351,23 +350,8 @@ selectedEditType = type; }; - const handleAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => { - if (oldAssetId !== asset.id) { - return; - } - - await new Promise((promise) => setTimeout(promise, 500)); - await goto(`${AppRoute.PHOTOS}/${newAssetId}`); - }; - let isFullScreen = $derived(fullscreenElement !== null); - $effect(() => { - if (asset) { - previewStackedAsset = undefined; - handlePromiseError(refreshStack()); - } - }); $effect(() => { if (album && !album.isActivityEnabled && activityManager.commentCount === 0) { assetViewerManager.closeActivityPanel(); @@ -379,17 +363,43 @@ } }); - // 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); }); + + const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => { + if (oldAssetId !== asset.id) { + return; + } + + await new Promise((promise) => setTimeout(promise, 500)); + await goto(`${AppRoute.PHOTOS}/${newAssetId}`); + }; + + const onAssetUpdate = (update: AssetResponseDto) => { + if (asset.id === update.id) { + asset = update; + } + }; - + @@ -449,8 +459,7 @@ navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} haveFadeTransition={false} @@ -495,8 +504,7 @@ navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} {sharedLink} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts deleted file mode 100644 index fd1a40e4db04a..0000000000000 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ /dev/null @@ -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(); - 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(); - }); -}); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 2607f6de792a4..baf46052be8f2 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -6,32 +6,30 @@ import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import { assetViewerFadeDuration } from '$lib/constants'; import { castManager } from '$lib/managers/cast-manager.svelte'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { boundingBoxesArray } from '$lib/stores/people.store'; - import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; - import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; + import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils'; + import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; - import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; + import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk'; import { LoadingSpinner, toastManager } from '@immich/ui'; import { onDestroy, onMount } from 'svelte'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; + import type { AssetCursor } from './asset-viewer.svelte'; interface Props { - asset: AssetResponseDto; - preloadAssets?: TimelineAsset[] | undefined; + cursor: AssetCursor; element?: HTMLDivElement | undefined; haveFadeTransition?: boolean; sharedLink?: SharedLinkResponseDto | undefined; @@ -42,8 +40,7 @@ } let { - asset, - preloadAssets = undefined, + cursor, element = $bindable(), haveFadeTransition = true, sharedLink = undefined, @@ -54,8 +51,8 @@ }: Props = $props(); const { slideshowState, slideshowLook } = slideshowStore; + const asset = $derived(cursor.current); - let assetFileUrl: string = $state(''); let imageLoaded: boolean = $state(false); let originalImageLoaded: boolean = $state(false); let imageError: boolean = $state(false); @@ -82,25 +79,6 @@ let isOcrActive = $derived(ocrManager.showOverlay); - const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => { - for (const preloadAsset of preloadAssets || []) { - if (preloadAsset.isImage) { - let img = new Image(); - img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash); - } - } - }; - - const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => { - if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { - return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); - } - - return targetSize === 'original' - ? getAssetOriginalUrl({ id, cacheKey }) - : getAssetThumbnailUrl({ id, size: targetSize, cacheKey }); - }; - copyImage = async () => { if (!canCopyImageToClipboard() || !$photoViewerImgElement) { return; @@ -155,23 +133,11 @@ } }; - // when true, will force loading of the original image - let forceUseOriginal: boolean = $derived( - (asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) || - $photoZoomState.currentZoom > 1, - ); - - const targetImageSize = $derived.by(() => { - if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) { - return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize; - } - - return AssetMediaSize.Preview; - }); + const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1)); $effect(() => { - if (assetFileUrl) { - void cast(assetFileUrl); + if (imageLoaderUrl) { + void cast(imageLoaderUrl); } }); @@ -191,7 +157,6 @@ const onload = () => { imageLoaded = true; - assetFileUrl = imageLoaderUrl; originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original'; }; @@ -199,27 +164,29 @@ imageError = imageLoaded = true; }; - $effect(() => { - preload(targetImageSize, preloadAssets); - }); - onMount(() => { - if (loader?.complete) { - onload(); - } - loader?.addEventListener('load', onload, { passive: true }); - loader?.addEventListener('error', onerror, { passive: true }); return () => { - loader?.removeEventListener('load', onload); - loader?.removeEventListener('error', onerror); - cancelImageUrl(imageLoaderUrl); + preloadManager.cancelPreloadUrl(imageLoaderUrl); }; }); - let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash)); + let imageLoaderUrl = $derived( + getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }), + ); let containerWidth = $state(0); let containerHeight = $state(0); + + let lastUrl: string | undefined; + + $effect(() => { + if (lastUrl && lastUrl !== imageLoaderUrl) { + imageLoaded = false; + originalImageLoaded = false; + imageError = false; + } + lastUrl = imageLoaderUrl; + }); {#if imageError} -
+
{/if} - - +
- {#if !imageLoaded}
@@ -258,7 +223,7 @@ > {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} 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), }; } diff --git a/web/src/lib/components/layouts/ErrorLayout.svelte b/web/src/lib/components/layouts/ErrorLayout.svelte index 1df1dbf422bcd..f12168423638b 100644 --- a/web/src/lib/components/layouts/ErrorLayout.svelte +++ b/web/src/lib/components/layouts/ErrorLayout.svelte @@ -1,7 +1,19 @@ -
+
- + - +
-
-
-
-
-
-

- 🚨 {$t('error_title')} -

-
- handleCopy()} - /> -
-
+
+
+ + + + + {$t('error_title')} + + + -
+ + {error?.message} (HTTP {error?.code}) + {#if error?.stack} + +
{error.stack}
+ {/if} +
-
-
-

{error?.message} ({error?.code})

- {#if error?.stack} - -
{error?.stack || 'No stack'}
- {/if} -
-
- -
- - -
-
+ + + + + {$t('get_help')} + + + + + + {$t('read_changelog')} + + + + + + {$t('check_logs')} + + + +
diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index cfe11e1026b04..34c6ee18db20f 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -32,7 +32,7 @@ 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 @@

- {#if currentTimelineAssets.some(({ isVideo }) => isVideo)} + {#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
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,7 +145,7 @@ {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} Promise.resolve(false)} onNext={() => Promise.resolve(false)} diff --git a/web/src/lib/components/shared-components/context-menu/menu-option.svelte b/web/src/lib/components/shared-components/context-menu/menu-option.svelte index 95b4b9ad4346c..dc5a2d7c0fd3b 100644 --- a/web/src/lib/components/shared-components/context-menu/menu-option.svelte +++ b/web/src/lib/components/shared-components/context-menu/menu-option.svelte @@ -3,12 +3,12 @@ import { shortcut as bindShortcut, shortcutLabel as computeShortcutLabel } from '$lib/actions/shortcut'; import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; import { generateId } from '$lib/utils/generate-id'; - import { Icon } from '@immich/ui'; + import { Icon, type IconLike } from '@immich/ui'; interface Props { text: string; subtitle?: string; - icon?: string; + icon?: IconLike; activeColor?: string; textColor?: string; onClick: () => void; @@ -19,7 +19,7 @@ let { text, subtitle = '', - icon = '', + icon, activeColor = 'bg-slate-300', textColor = 'text-immich-fg dark:text-immich-dark-bg', onClick, diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index c695cafc76c87..f71944d20c036 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -13,7 +13,7 @@ import { showDeleteModal } from '$lib/stores/preferences.store'; import { handlePromiseError } from '$lib/utils'; 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 { handleError } from '$lib/utils/handle-error'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; @@ -27,7 +27,7 @@ interface Props { initialAssetId?: string; - assets: TimelineAsset[] | AssetResponseDto[]; + assets: AssetResponseDto[]; assetInteraction: AssetInteraction; disableAssetSelect?: boolean; showArchiveIcon?: boolean; @@ -229,7 +229,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 +242,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(); } }; @@ -424,6 +424,12 @@ selectAssetCandidates(lastAssetMouseEvent); } }); + + const assetCursor = $derived({ + current: $viewingAsset, + nextAsset: getNextAsset(assets, $viewingAsset), + previousAsset: getPreviousAsset(assets, $viewingAsset), + }); {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} void; onEscape?: () => void; @@ -82,9 +76,9 @@ withStacked = false, showArchiveIcon = false, isShared = false, - album = null, + album, albumUsers = [], - person = null, + person, isShowDeleteConfirmation = $bindable(false), onSelect = () => {}, onEscape = () => {}, diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 9f8b5fe36b6b7..8500345df4ae1 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -1,32 +1,31 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} { + handleAction(action); + assetCacheManager.invalidate(); + }} onUndoDelete={handleUndoDelete} - onPrevious={handlePrevious} - onNext={handleNext} + onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)} + onNext={() => handleNavigateToAsset(assetCursor.nextAsset)} onRandom={handleRandom} onClose={handleClose} /> diff --git a/web/src/lib/components/timeline/actions/DownloadAction.svelte b/web/src/lib/components/timeline/actions/DownloadAction.svelte index 29f2bab610022..b1b16407984c3 100644 --- a/web/src/lib/components/timeline/actions/DownloadAction.svelte +++ b/web/src/lib/components/timeline/actions/DownloadAction.svelte @@ -3,7 +3,8 @@ import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import { downloadArchive, downloadFile } from '$lib/utils/asset-utils'; + import { handleDownloadAsset } from '$lib/services/asset.service'; + import { downloadArchive } from '$lib/utils/asset-utils'; import { getAssetInfo } from '@immich/sdk'; import { IconButton } from '@immich/ui'; import { mdiDownload } from '@mdi/js'; @@ -24,7 +25,7 @@ if (assets.length === 1) { clearSelect(); let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id }); - await downloadFile(asset); + await handleDownloadAsset(asset); return; } diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 2afeebc559460..16155d44c0615 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -5,6 +5,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; + import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { navigate } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; @@ -102,6 +103,12 @@ const handleStack = () => { onStack(assets); }; + + const assetCursor = $derived({ + current: $viewingAsset, + nextAsset: getNextAsset(assets, $viewingAsset), + previousAsset: getPreviousAsset(assets, $viewingAsset), + }); 1} {onNext} {onPrevious} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 72056008cdfa8..fca52c301f858 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -3,8 +3,6 @@ export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12 export enum AssetAction { ARCHIVE = 'archive', UNARCHIVE = 'unarchive', - FAVORITE = 'favorite', - UNFAVORITE = 'unfavorite', TRASH = 'trash', DELETE = 'delete', RESTORE = 'restore', diff --git a/web/src/lib/managers/AssetCacheManager.svelte.ts b/web/src/lib/managers/AssetCacheManager.svelte.ts new file mode 100644 index 0000000000000..0b5e69768302a --- /dev/null +++ b/web/src/lib/managers/AssetCacheManager.svelte.ts @@ -0,0 +1,60 @@ +import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk'; + +const defaultSerializer = (params: K) => JSON.stringify(params); + +class AsyncCache { + #cache = new Map(); + + async getOrFetch( + params: K, + fetcher: (params: K) => Promise, + keySerializer: (params: K) => string = defaultSerializer, + updateCache: boolean, + ): Promise { + 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(); + #ocrCache = new AsyncCache(); + + 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(); diff --git a/web/src/lib/managers/PreloadManager.svelte.ts b/web/src/lib/managers/PreloadManager.svelte.ts new file mode 100644 index 0000000000000..a68c07d50501b --- /dev/null +++ b/web/src/lib/managers/PreloadManager.svelte.ts @@ -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(); diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 6038c3c3f02e1..f9fa87e0cf3a4 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -3,6 +3,7 @@ import type { ReleaseEvent } from '$lib/types'; import type { AlbumResponseDto, ApiKeyResponseDto, + AssetResponseDto, LibraryResponseDto, LoginResponseDto, QueueResponseDto, @@ -24,6 +25,7 @@ export type Events = { ApiKeyUpdate: [ApiKeyResponseDto]; ApiKeyDelete: [ApiKeyResponseDto]; + AssetUpdate: [AssetResponseDto]; AssetReplace: [{ oldAssetId: string; newAssetId: string }]; AlbumUpdate: [AlbumResponseDto]; diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index b0dc30dc6eeec..7625659e94c7e 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -1,5 +1,6 @@ import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte'; import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; @@ -93,6 +94,7 @@ export class TimelineManager extends VirtualScrollManager { #updatingIntersections = false; #scrollableElement: HTMLElement | undefined = $state(); #showAssetOwners = new PersistedLocalStorage('album-show-asset-owners', false); + #unsubscribes: Array<() => void> = []; get showAssetOwners() { return this.#showAssetOwners.current; @@ -108,6 +110,12 @@ export class TimelineManager extends VirtualScrollManager { constructor() { super(); + + const onAssetUpdate = (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]); + + eventManager.on('AssetUpdate', onAssetUpdate); + + this.#unsubscribes.push(() => eventManager.off('AssetUpdate', onAssetUpdate)); } override get scrollTop(): number { @@ -269,6 +277,11 @@ export class TimelineManager extends VirtualScrollManager { public override destroy() { this.disconnect(); this.isInitialized = false; + + for (const unsubscribe of this.#unsubscribes) { + unsubscribe(); + } + super.destroy(); } diff --git a/web/src/lib/modals/ProfileImageCropperModal.svelte b/web/src/lib/modals/ProfileImageCropperModal.svelte index 7f7050f663ce3..f7cc09f0ea99d 100644 --- a/web/src/lib/modals/ProfileImageCropperModal.svelte +++ b/web/src/lib/modals/ProfileImageCropperModal.svelte @@ -85,7 +85,7 @@
- +
diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index de5223db23a61..81b74e51e2ee7 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -1,12 +1,29 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; +import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; -import { user as authUser } from '$lib/stores/user.store'; +import { user as authUser, preferences } from '$lib/stores/user.store'; +import { getSharedLink, sleep } from '$lib/utils'; +import { downloadUrl } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; -import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk'; -import { modalManager, type ActionItem } from '@immich/ui'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { asQueryString } from '$lib/utils/shared-links'; +import { + AssetVisibility, + copyAsset, + deleteAssets, + getAssetInfo, + getBaseUrl, + updateAsset, + type AssetResponseDto, +} from '@immich/sdk'; +import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { mdiAlertOutline, + mdiDownload, + mdiHeart, + mdiHeartOutline, mdiInformationOutline, mdiMotionPauseOutline, mdiMotionPlayOutline, @@ -16,16 +33,36 @@ import type { MessageFormatter } from 'svelte-i18n'; import { get } from 'svelte/store'; export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => { + const sharedLink = getSharedLink(); + const currentAuthUser = get(authUser); + const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId); + const Share: ActionItem = { title: $t('share'), icon: mdiShareVariantOutline, + type: $t('assets'), $if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked), onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }), }; + const Download: ActionItem = { + title: $t('download'), + icon: mdiDownload, + shortcuts: { key: 'd', shift: true }, + type: $t('assets'), + $if: () => !!currentAuthUser, + onAction: () => handleDownloadAsset(asset), + }; + + const SharedLinkDownload: ActionItem = { + ...Download, + $if: () => !currentAuthUser && sharedLink && sharedLink.allowDownload, + }; + const PlayMotionPhoto: ActionItem = { title: $t('play_motion_photo'), icon: mdiMotionPlayOutline, + type: $t('assets'), $if: () => !!asset.livePhotoVideoId && !assetViewerManager.isPlayingMotionPhoto, onAction: () => { assetViewerManager.isPlayingMotionPhoto = true; @@ -35,15 +72,35 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = const StopMotionPhoto: ActionItem = { title: $t('stop_motion_photo'), icon: mdiMotionPauseOutline, + type: $t('assets'), $if: () => !!asset.livePhotoVideoId && assetViewerManager.isPlayingMotionPhoto, onAction: () => { assetViewerManager.isPlayingMotionPhoto = false; }, }; + const Favorite: ActionItem = { + title: $t('to_favorite'), + icon: mdiHeartOutline, + type: $t('assets'), + $if: () => isOwner && !asset.isFavorite, + onAction: () => handleFavorite(asset), + shortcuts: [{ key: 'f' }], + }; + + const Unfavorite: ActionItem = { + title: $t('unfavorite'), + icon: mdiHeart, + type: $t('assets'), + $if: () => isOwner && asset.isFavorite, + onAction: () => handleUnfavorite(asset), + shortcuts: [{ key: 'f' }], + }; + const Offline: ActionItem = { title: $t('asset_offline'), icon: mdiAlertOutline, + type: $t('assets'), color: 'danger', $if: () => !!asset.isOffline, onAction: () => assetViewerManager.toggleDetailPanel(), @@ -52,12 +109,80 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = const Info: ActionItem = { title: $t('info'), icon: mdiInformationOutline, + type: $t('assets'), $if: () => asset.hasMetadata, onAction: () => assetViewerManager.toggleDetailPanel(), shortcuts: [{ key: 'i' }], }; - return { Share, PlayMotionPhoto, StopMotionPhoto, Offline, Info }; + return { Share, Download, SharedLinkDownload, Offline, Info, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto }; +}; + +export const handleDownloadAsset = async (asset: AssetResponseDto) => { + const $t = await getFormatter(); + + const assets = [ + { + filename: asset.originalFileName, + id: asset.id, + size: asset.exifInfo?.fileSizeInByte || 0, + }, + ]; + + const isAndroidMotionVideo = (asset: AssetResponseDto) => { + return asset.originalPath.includes('encoded-video'); + }; + + if (asset.livePhotoVideoId) { + const motionAsset = await getAssetInfo({ ...authManager.params, id: asset.livePhotoVideoId }); + if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) { + assets.push({ + filename: motionAsset.originalFileName, + id: asset.livePhotoVideoId, + size: motionAsset.exifInfo?.fileSizeInByte || 0, + }); + } + } + + const queryParams = asQueryString(authManager.params); + + for (const [i, { filename, id }] of assets.entries()) { + if (i !== 0) { + // play nice with Safari + await sleep(500); + } + + try { + toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } })); + downloadUrl(getBaseUrl() + `/assets/${id}/original` + (queryParams ? `?${queryParams}` : ''), filename); + } catch (error) { + handleError(error, $t('errors.error_downloading', { values: { filename } })); + } + } +}; + +const handleFavorite = async (asset: AssetResponseDto) => { + const $t = await getFormatter(); + + try { + const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: true } }); + toastManager.success($t('added_to_favorites')); + eventManager.emit('AssetUpdate', response); + } catch (error) { + handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } })); + } +}; + +const handleUnfavorite = async (asset: AssetResponseDto) => { + const $t = await getFormatter(); + + try { + const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: false } }); + toastManager.success($t('removed_from_favorites')); + eventManager.emit('AssetUpdate', response); + } catch (error) { + handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } })); + } }; export const handleReplaceAsset = async (oldAssetId: string) => { diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index 99ee1b8c46b94..00e0224a0ea60 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -1,19 +1,15 @@ 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(); - const preloadAssets = writable([]); + const viewState = writable(false); - const viewingAssetMutex = new Mutex(); const gridScrollTarget = writable(); - const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => { - preloadAssets.set(assetsToPreload); + const setAsset = (asset: AssetResponseDto) => { viewingAssetStoreState.set(asset); viewState.set(true); }; @@ -30,8 +26,6 @@ function createAssetViewingStore() { return { asset: readonly(viewingAssetStoreState), - mutex: viewingAssetMutex, - preloadAssets: readonly(preloadAssets), isViewing: viewState, gridScrollTarget, setAsset, diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 169f42409c998..3bc86652793a3 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -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'); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 5ae025f59c498..c640fa31bb830 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -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,13 +19,14 @@ import { linkOAuthAccount, startOAuth, unlinkOAuthAccount, + type AssetResponseDto, type MemoryResponseDto, type PersonResponseDto, type ServerVersionResponseDto, type SharedLinkResponseDto, type UserResponseDto, } from '@immich/sdk'; -import { toastManager } from '@immich/ui'; +import { toastManager, type ActionItem, type IfLike } from '@immich/ui'; import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, mdiImageRefreshOutline } from '@mdi/js'; import { init, register, t } from 'svelte-i18n'; import { derived, get } from 'svelte/store'; @@ -191,6 +194,40 @@ const createUrl = (path: string, parameters?: Record) => { 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) => { if (typeof options === 'string') { options = { id: options }; @@ -403,3 +440,8 @@ export const getReleaseType = ( }; export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`; + +export const withoutIcons = (actions: ActionItem[]): ActionItem[] => + actions.map((action) => ({ ...action, icon: undefined })); + +export const isEnabled = ({ $if }: IfLike) => $if?.() ?? true; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index aa96d56aec544..9d69653439714 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -9,7 +9,7 @@ import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { preferences } from '$lib/stores/user.store'; -import { downloadRequest, sleep, withError } from '$lib/utils'; +import { downloadRequest, withError } from '$lib/utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; @@ -23,7 +23,6 @@ import { createStack, deleteAssets, deleteStacks, - getAssetInfo, getBaseUrl, getDownloadInfo, getStack, @@ -232,48 +231,6 @@ export const downloadArchive = async (fileName: string, options: Omit { - const $t = get(t); - const assets = [ - { - filename: asset.originalFileName, - id: asset.id, - size: asset.exifInfo?.fileSizeInByte || 0, - }, - ]; - - const isAndroidMotionVideo = (asset: AssetResponseDto) => { - return asset.originalPath.includes('encoded-video'); - }; - - if (asset.livePhotoVideoId) { - const motionAsset = await getAssetInfo({ ...authManager.params, id: asset.livePhotoVideoId }); - if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) { - assets.push({ - filename: motionAsset.originalFileName, - id: asset.livePhotoVideoId, - size: motionAsset.exifInfo?.fileSizeInByte || 0, - }); - } - } - - const queryParams = asQueryString(authManager.params); - - for (const [i, { filename, id }] of assets.entries()) { - if (i !== 0) { - // play nice with Safari - await sleep(500); - } - - try { - toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } })); - downloadUrl(getBaseUrl() + `/assets/${id}/original` + (queryParams ? `?${queryParams}` : ''), filename); - } catch (error) { - handleError(error, $t('errors.error_downloading', { values: { filename } })); - } - } -}; - /** * Returns the lowercase filename extension without a dot (.) and * an empty string when not found. @@ -557,6 +514,14 @@ export const delay = async (ms: number) => { 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 => { return !!(navigator.clipboard && globalThis.ClipboardItem); }; diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts index ebc97dfde0957..7d42d8c613dc0 100644 --- a/web/src/lib/utils/invocationTracker.ts +++ b/web/src/lib/utils/invocationTracker.ts @@ -50,4 +50,13 @@ export class InvocationTracker { isActive() { return this.invocationsStarted !== this.invocationsEnded; } + + async invoke(invocable: () => Promise) { + const invocation = this.startInvocation(); + try { + return await invocable(); + } finally { + invocation.endInvocation(); + } + } } diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index daf1d04ed5f6c..b6c0cad61654f 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -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 | 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() { diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 1a19d3c134c1a..61cd1b8df0d7d 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -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 }); } diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 781dc80ec88ba..d33c5e74748b5 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -16,7 +16,6 @@ import TagAction from '$lib/components/timeline/actions/TagAction.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; - import { AssetAction } from '$lib/constants'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; @@ -55,7 +54,6 @@ bind:timelineManager {options} {assetInteraction} - removeAction={AssetAction.UNFAVORITE} onEscape={handleEscape} > {#snippet empty()} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index fd443a64703f8..27dc10be57c9c 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,15 +1,18 @@ {#if featureFlagsManager.value.map} @@ -85,7 +141,7 @@ {#if $showAssetViewer} {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} onNext={navigateNext} onPrevious={navigatePrevious} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index b58210187b763..0cc30c2c0a926 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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) { diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte index 06f075feb6dc2..15f4b233eb8f2 100644 --- a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -5,10 +5,11 @@ import Portal from '$lib/elements/Portal.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; + import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; import { navigate } from '$lib/utils/navigation'; + import type { AssetResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; - import type { AssetResponseDto } from '@immich/sdk'; interface Props { data: PageData; @@ -65,6 +66,12 @@ const onViewAsset = async (asset: AssetResponseDto) => { await navigate({ targetRoute: 'current', assetId: asset.id }); }; + + const assetCursor = $derived({ + current: $viewingAsset, + nextAsset: getNextAsset(assets, $viewingAsset), + previousAsset: getPreviousAsset(assets, $viewingAsset), + }); @@ -85,7 +92,7 @@ {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} {onNext} {onPrevious} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index d92139251201c..1c7a190b08841 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -145,7 +145,6 @@ icon: mdiThemeLightDark, onAction: () => themeManager.toggleTheme(), shortcuts: { shift: true, key: 't' }, - isGlobal: true, }, ]; @@ -181,7 +180,7 @@ icon: mdiServer, onAction: () => goto(AppRoute.ADMIN_STATS), }, - ].map((route) => ({ ...route, type: $t('page'), isGlobal: true, $if: () => $user?.isAdmin })); + ].map((route) => ({ ...route, type: $t('page'), $if: () => $user?.isAdmin })); const commands = $derived([...userCommands, ...adminCommands]);