diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte
index 4d0e474389..ae8d1199e0 100644
--- a/web/src/lib/components/ActionButton.svelte
+++ b/web/src/lib/components/ActionButton.svelte
@@ -1,4 +1,5 @@
-{#if icon && (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 0000000000..d50d50bf0b
--- /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/asset-viewer/actions/download-action.svelte b/web/src/lib/components/asset-viewer/actions/download-action.svelte
deleted file mode 100644
index f790569703..0000000000
--- 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/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
index 4aa74c9fe5..60bde6e114 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,12 +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 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';
@@ -27,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, withoutIcons } 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';
@@ -96,9 +96,7 @@
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);
@@ -113,9 +111,8 @@
const { Cast } = $derived(getGlobalActions($t));
- const { Share, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } = $derived(
- getAssetActions($t, asset),
- );
+ const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } =
+ $derived(getAssetActions($t, asset));
// $: showEditorButton =
// isOwner &&
@@ -169,10 +166,7 @@
/>
{/if}
- {#if !isOwner && showDownloadButton}
-
- {/if}
-
+
@@ -188,9 +182,8 @@
{#if showSlideshow && !isLocked}
{/if}
- {#if showDownloadButton}
-
- {/if}
+
+
{#if !isLocked}
{#if asset.isTrashed}
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 95b4b9ad43..dc5a2d7c0f 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/timeline/actions/DownloadAction.svelte b/web/src/lib/components/timeline/actions/DownloadAction.svelte
index 29f2bab610..b1b1640798 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/services/asset.service.ts b/web/src/lib/services/asset.service.ts
index d22d8ab241..81b74e51e2 100644
--- a/web/src/lib/services/asset.service.ts
+++ b/web/src/lib/services/asset.service.ts
@@ -1,14 +1,27 @@
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 { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
-import { AssetVisibility, copyAsset, deleteAssets, updateAsset, type AssetResponseDto } from '@immich/sdk';
+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,
@@ -20,6 +33,7 @@ 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);
@@ -31,6 +45,20 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
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,
@@ -87,7 +115,50 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'i' }],
};
- return { Share, Offline, Info, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto };
+ 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) => {
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
index 0437b05700..c640fa31bb 100644
--- a/web/src/lib/utils.ts
+++ b/web/src/lib/utils.ts
@@ -26,7 +26,7 @@ import {
type SharedLinkResponseDto,
type UserResponseDto,
} from '@immich/sdk';
-import { toastManager, type ActionItem } 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';
@@ -443,3 +443,5 @@ export const semverToName = ({ major, minor, patch }: ServerVersionResponseDto)
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 c0e43f74b5..9d69653439 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.