-
-
-
-
-
-
- 🚨 {$t('error_title')}
-
-
- handleCopy()}
- />
-
-
+
+
+
+
+
+
+ {$t('error_title')}
+
+
+
-
+
+ {error?.message} (HTTP {error?.code})
+ {#if error?.stack}
+
+ {error.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]);