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.