refactor: asset viewer navbar actions (#25091)

This commit is contained in:
Jason Rasmussen 2026-01-06 17:35:37 -05:00 committed by GitHub
parent f0f1687c79
commit 1a24a2d35e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 72 additions and 117 deletions

View file

@ -8,7 +8,7 @@ import * as Oazapfts from "@oazapfts/runtime";
import * as QS from "@oazapfts/runtime/query"; import * as QS from "@oazapfts/runtime/query";
export const defaults: Oazapfts.Defaults<Oazapfts.CustomHeaders> = { export const defaults: Oazapfts.Defaults<Oazapfts.CustomHeaders> = {
headers: {}, headers: {},
baseUrl: "/api", baseUrl: "/api"
}; };
const oazapfts = Oazapfts.runtime(defaults); const oazapfts = Oazapfts.runtime(defaults);
export const servers = { export const servers = {

View file

@ -1,23 +0,0 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { IconButton } from '@immich/ui';
import { mdiArrowLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiArrowLeft}
aria-label={$t('go_back')}
onclick={onClose}
/>

View file

@ -1,21 +0,0 @@
<script lang="ts">
import { IconButton } from '@immich/ui';
import { mdiMotionPauseOutline, mdiMotionPlayOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
isPlaying: boolean;
onClick: (shouldPlay: boolean) => void;
}
let { isPlaying, onClick }: Props = $props();
</script>
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={isPlaying ? mdiMotionPauseOutline : mdiMotionPlayOutline}
aria-label={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')}
onclick={() => onClick(!isPlaying)}
/>

View file

@ -1,22 +0,0 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { IconButton } from '@immich/ui';
import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
const onAction = () => {
assetViewerManager.toggleDetailPanel();
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onAction }} />
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiInformationOutline}
onclick={onAction}
aria-label={$t('info')}
/>

View file

@ -7,7 +7,6 @@
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; 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 AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import CloseAction from '$lib/components/asset-viewer/actions/close-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-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 DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
@ -20,12 +19,10 @@
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte'; import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte'; import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte'; import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service'; import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
@ -44,9 +41,9 @@
type PersonResponseDto, type PersonResponseDto,
type StackResponseDto, type StackResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { IconButton } from '@immich/ui'; import { CommandPaletteDefaultProvider, IconButton, type ActionItem } from '@immich/ui';
import { import {
mdiAlertOutline, mdiArrowLeft,
mdiCogRefreshOutline, mdiCogRefreshOutline,
mdiCompare, mdiCompare,
mdiContentCopy, mdiContentCopy,
@ -61,7 +58,6 @@
mdiUpload, mdiUpload,
mdiVideoOutline, mdiVideoOutline,
} from '@mdi/js'; } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@ -79,7 +75,6 @@
onPlaySlideshow: () => void; onPlaySlideshow: () => void;
// export let showEditorHandler: () => void; // export let showEditorHandler: () => void;
onClose?: () => void; onClose?: () => void;
motionPhoto?: Snippet;
playOriginalVideo: boolean; playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void; setPlayOriginalVideo: (value: boolean) => void;
} }
@ -98,7 +93,6 @@
onRunJob, onRunJob,
onPlaySlideshow, onPlaySlideshow,
onClose, onClose,
motionPhoto,
playOriginalVideo = false, playOriginalVideo = false,
setPlayOriginalVideo, setPlayOriginalVideo,
}: Props = $props(); }: Props = $props();
@ -109,7 +103,15 @@
let isLocked = $derived(asset.visibility === AssetVisibility.Locked); let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const { Share } = $derived(getAssetActions($t, asset)); const Close: ActionItem = {
title: $t('go_back'),
icon: mdiArrowLeft,
$if: () => !!onClose,
onAction: () => onClose?.(),
shortcuts: [{ key: 'Escape' }],
};
const { Share, Offline, PlayMotionPhoto, StopMotionPhoto, Info } = $derived(getAssetActions($t, asset));
// $: showEditorButton = // $: showEditorButton =
// isOwner && // isOwner &&
@ -122,30 +124,26 @@
// !asset.livePhotoVideoId; // !asset.livePhotoVideoId;
</script> </script>
<CommandPaletteDefaultProvider
name={$t('assets')}
actions={[Close, Share, Offline, PlayMotionPhoto, StopMotionPhoto, Info]}
/>
<div <div
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200" class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200"
> >
<div class="dark"> <div class="dark">
{#if onClose} <ActionButton action={Close} />
<CloseAction {onClose} />
{/if}
</div> </div>
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions"> <div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
<CastButton /> <CastButton />
<ActionButton action={Share} /> <ActionButton action={Share} />
{#if asset.isOffline} <ActionButton action={Offline} />
<IconButton <ActionButton action={PlayMotionPhoto} />
shape="round" <ActionButton action={StopMotionPhoto} />
color="danger"
icon={mdiAlertOutline}
onclick={() => assetViewerManager.toggleDetailPanel()}
aria-label={$t('asset_offline')}
/>
{/if}
{#if asset.livePhotoVideoId}
{@render motionPhoto?.()}
{/if}
{#if asset.type === AssetTypeEnum.Image} {#if asset.type === AssetTypeEnum.Image}
<IconButton <IconButton
class="hidden sm:flex" class="hidden sm:flex"
@ -172,9 +170,7 @@
<DownloadAction asset={toTimelineAsset(asset)} /> <DownloadAction asset={toTimelineAsset(asset)} />
{/if} {/if}
{#if asset.hasMetadata} <ActionButton action={Info} />
<ShowDetailAction />
{/if}
{#if isOwner} {#if isOwner}
<FavoriteAction {asset} {onAction} /> <FavoriteAction {asset} {onAction} />

View file

@ -2,7 +2,6 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap'; import { focusTrap } from '$lib/actions/focus-trap';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
@ -102,7 +101,6 @@
const stackSelectedThumbnailSize = 65; const stackSelectedThumbnailSize = 65;
let appearsInAlbums: AlbumResponseDto[] = $state([]); let appearsInAlbums: AlbumResponseDto[] = $state([]);
let shouldPlayMotionPhoto = $state(false);
let sharedLink = getSharedLink(); let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state(); let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowEditor = $state(false); let isShowEditor = $state(false);
@ -420,14 +418,7 @@
onClose={onClose ? () => onClose(asset) : undefined} onClose={onClose ? () => onClose(asset) : undefined}
{playOriginalVideo} {playOriginalVideo}
{setPlayOriginalVideo} {setPlayOriginalVideo}
> />
{#snippet motionPhoto()}
<MotionPhotoAction
isPlaying={shouldPlayMotionPhoto}
onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)}
/>
{/snippet}
</AssetViewerNavBar>
</div> </div>
{/if} {/if}
@ -483,7 +474,7 @@
{:else} {:else}
{#key asset.id} {#key asset.id}
{#if asset.type === AssetTypeEnum.Image} {#if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId} {#if assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId}
<VideoViewer <VideoViewer
assetId={asset.livePhotoVideoId} assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash} cacheKey={asset.thumbhash}
@ -491,7 +482,7 @@
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (shouldPlayMotionPhoto = false)} onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo} {playOriginalVideo}
/> />
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath

View file

@ -3,15 +3,8 @@ import { PersistedLocalStorage } from '$lib/utils/persisted';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false); const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
export class AssetViewerManager { export class AssetViewerManager {
#isShowActivityPanel = $state(false); isShowActivityPanel = $state(false);
isPlayingMotionPhoto = $state(false);
get isShowActivityPanel() {
return this.#isShowActivityPanel;
}
private set isShowActivityPanel(value: boolean) {
this.#isShowActivityPanel = value;
}
get isShowDetailPanel() { get isShowDetailPanel() {
return isShowDetailPanel.current; return isShowDetailPanel.current;

View file

@ -1,10 +1,17 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser } from '$lib/stores/user.store'; import { user as authUser } from '$lib/stores/user.store';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk'; import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui'; import { modalManager, type ActionItem } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js'; import {
mdiAlertOutline,
mdiInformationOutline,
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiShareVariantOutline,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n'; import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@ -16,7 +23,41 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }), onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
}; };
return { Share }; const PlayMotionPhoto: ActionItem = {
title: $t('play_motion_photo'),
icon: mdiMotionPlayOutline,
$if: () => !!asset.livePhotoVideoId && !assetViewerManager.isPlayingMotionPhoto,
onAction: () => {
assetViewerManager.isPlayingMotionPhoto = true;
},
};
const StopMotionPhoto: ActionItem = {
title: $t('stop_motion_photo'),
icon: mdiMotionPauseOutline,
$if: () => !!asset.livePhotoVideoId && assetViewerManager.isPlayingMotionPhoto,
onAction: () => {
assetViewerManager.isPlayingMotionPhoto = false;
},
};
const Offline: ActionItem = {
title: $t('asset_offline'),
icon: mdiAlertOutline,
color: 'danger',
$if: () => !!asset.isOffline,
onAction: () => assetViewerManager.toggleDetailPanel(),
};
const Info: ActionItem = {
title: $t('info'),
icon: mdiInformationOutline,
$if: () => asset.hasMetadata,
onAction: () => assetViewerManager.toggleDetailPanel(),
shortcuts: [{ key: 'i' }],
};
return { Share, PlayMotionPhoto, StopMotionPhoto, Offline, Info };
}; };
export const handleReplaceAsset = async (oldAssetId: string) => { export const handleReplaceAsset = async (oldAssetId: string) => {