refactor: asset viewer (#25059)

This commit is contained in:
Jason Rasmussen
2026-01-05 16:02:01 -05:00
committed by GitHub
parent 9d4a12dfd4
commit 984f06ac40
10 changed files with 104 additions and 108 deletions

View File

@@ -1,23 +1,22 @@
<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';
interface Props {
onShowDetail: () => void;
}
let { onShowDetail }: Props = $props();
const onAction = () => {
assetViewerManager.toggleDetailPanel();
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} />
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onAction }} />
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiInformationOutline}
onclick={onShowDetail}
onclick={onAction}
aria-label={$t('info')}
/>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import type { ActivityResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
@@ -9,11 +10,10 @@
numberOfComments: number | undefined;
numberOfLikes: number | undefined;
disabled: boolean;
onOpenActivityTab: () => void;
onFavorite: () => void;
}
let { isLiked, numberOfComments, numberOfLikes, disabled, onOpenActivityTab, onFavorite }: Props = $props();
let { isLiked, numberOfComments, numberOfLikes, disabled, onFavorite }: Props = $props();
</script>
<div class="w-full flex p-4 items-center justify-center rounded-full gap-5 bg-subtle border bg-opacity-60">
@@ -25,7 +25,7 @@
{/if}
</div>
</button>
<button type="button" onclick={onOpenActivityTab}>
<button type="button" onclick={() => assetViewerManager.toggleActivityPanel()}>
<div class="flex gap-2 items-center justify-center">
<Icon icon={mdiCommentOutline} class="scale-x-[-1]" size="24" />
{#if numberOfComments}

View File

@@ -5,6 +5,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
@@ -44,10 +45,9 @@
assetType?: AssetTypeEnum | undefined;
albumOwnerId: string;
disabled: boolean;
onClose: () => void;
}
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled, onClose }: Props = $props();
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled }: Props = $props();
let innerHeight: number = $state(0);
let activityHeight: number = $state(0);
@@ -117,7 +117,7 @@
shape="round"
variant="ghost"
color="secondary"
onclick={onClose}
onclick={() => assetViewerManager.closeActivityPanel()}
icon={mdiClose}
aria-label={$t('close')}
/>
@@ -243,38 +243,34 @@
<div>
<UserAvatar {user} size="md" noTitle />
</div>
<form class="flex w-full max-h-56 gap-1" {onsubmit}>
<div class="flex w-full items-center gap-4">
<Textarea
{disabled}
bind:value={message}
rows={1}
grow
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
{@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter' },
onShortcut: () => handleSendComment(),
}))}
class="h-4.5 {disabled
? 'cursor-not-allowed'
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200 dark:bg-gray-200"
></Textarea>
</div>
<form class="flex w-full items-center max-h-56 gap-1" {onsubmit}>
<Textarea
{disabled}
bind:value={message}
rows={1}
grow
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
{@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter' },
onShortcut: () => handleSendComment(),
}))}
class="{disabled
? 'cursor-not-allowed'
: ''} ring-0! w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200 dark:bg-gray-200"
/>
{#if isSendingMessage}
<div class="flex items-end place-items-center pb-2 ms-0">
<div class="flex place-items-center pb-2 ms-0">
<div class="flex w-full place-items-center">
<LoadingSpinner />
<LoadingSpinner size="large" />
</div>
</div>
{:else if message}
<div class="flex items-end w-fit ms-0">
<div class="flex items-center w-fit ms-0 light">
<IconButton
shape="round"
aria-label={$t('send_message')}
size="small"
variant="ghost"
icon={mdiSend}
class="dark:text-immich-dark-gray"
onclick={() => handleSendComment()}
/>
</div>

View File

@@ -10,7 +10,6 @@ describe('AssetViewerNavBar component', () => {
const additionalProps = {
showCopyButton: false,
showZoomButton: false,
showDetailButton: false,
showDownloadButton: false,
showMotionPlayButton: false,
showShareButton: false,
@@ -19,7 +18,6 @@ describe('AssetViewerNavBar component', () => {
onAction: () => {},
onRunJob: () => {},
onPlaySlideshow: () => {},
onShowDetail: () => {},
onClose: () => {},
playOriginalVideo: false,
setPlayOriginalVideo: () => Promise.resolve(),

View File

@@ -24,6 +24,7 @@
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 { AppRoute } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
@@ -68,7 +69,6 @@
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
showCloseButton?: boolean;
showDetailButton: boolean;
showSlideshow?: boolean;
onZoomImage: () => void;
onCopyImage?: () => Promise<void>;
@@ -77,7 +77,6 @@
onUndoDelete?: OnUndoDelete;
onRunJob: (name: AssetJobName) => void;
onPlaySlideshow: () => void;
onShowDetail: () => void;
// export let showEditorHandler: () => void;
onClose: () => void;
motionPhoto?: Snippet;
@@ -91,7 +90,6 @@
person = null,
stack = null,
showCloseButton = true,
showDetailButton,
showSlideshow = false,
onZoomImage,
onCopyImage,
@@ -100,7 +98,6 @@
onUndoDelete = undefined,
onRunJob,
onPlaySlideshow,
onShowDetail,
onClose,
motionPhoto,
playOriginalVideo = false,
@@ -143,7 +140,7 @@
shape="round"
color="danger"
icon={mdiAlertOutline}
onclick={onShowDetail}
onclick={() => assetViewerManager.toggleDetailPanel()}
aria-label={$t('asset_offline')}
/>
{/if}
@@ -176,8 +173,8 @@
<DownloadAction asset={toTimelineAsset(asset)} />
{/if}
{#if showDetailButton}
<ShowDetailAction {onShowDetail} />
{#if asset.hasMetadata}
<ShowDetailAction />
{/if}
{#if isOwner}

View File

@@ -9,12 +9,13 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
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 { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo, isShowDetail } from '$lib/stores/preferences.store';
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';
@@ -105,11 +106,7 @@
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let shouldPlayMotionPhoto = $state(false);
let sharedLink = getSharedLink();
let enableDetailPanel = asset.hasMetadata;
let slideshowStateUnsubscribe: () => void;
let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowActivity = $state(false);
let isShowEditor = $state(false);
let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
@@ -163,39 +160,29 @@
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();
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
}
}),
slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
}),
);
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
}
});
shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
});
if (!sharedLink) {
await handleGetAllAlbums();
}
});
onDestroy(() => {
if (slideshowStateUnsubscribe) {
slideshowStateUnsubscribe();
}
if (shuffleSlideshowUnsubscribe) {
shuffleSlideshowUnsubscribe();
}
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
@@ -215,18 +202,6 @@
}
};
const handleOpenActivity = () => {
if ($isShowDetail) {
$isShowDetail = false;
}
isShowActivity = !isShowActivity;
};
const toggleDetailPanel = () => {
isShowActivity = false;
$isShowDetail = !$isShowDetail;
};
const closeViewer = () => {
onClose(asset);
};
@@ -389,7 +364,7 @@
});
$effect(() => {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false;
assetViewerManager.closeActivityPanel();
}
});
$effect(() => {
@@ -427,7 +402,6 @@
{person}
{stack}
{showCloseButton}
showDetailButton={enableDetailPanel}
showSlideshow={true}
onZoomImage={zoomToggle}
onCopyImage={copyImage}
@@ -436,7 +410,6 @@
{onUndoDelete}
onRunJob={handleRunJob}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onShowDetail={toggleDetailPanel}
onClose={closeViewer}
{playOriginalVideo}
{setPlayOriginalVideo}
@@ -555,7 +528,6 @@
numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite}
onOpenActivityTab={handleOpenActivity}
/>
</div>
{/if}
@@ -575,14 +547,14 @@
</div>
{/if}
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
</div>
{/if}
@@ -633,7 +605,7 @@
</div>
{/if}
{#if isShared && album && isShowActivity && $user}
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
<div
transition:fly={{ duration: 150 }}
id="activity-panel"
@@ -647,7 +619,6 @@
albumOwnerId={album.ownerId}
albumId={album.id}
assetId={asset.id}
onClose={() => (isShowActivity = false)}
/>
</div>
{/if}

View File

@@ -6,6 +6,7 @@
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
@@ -45,10 +46,9 @@
asset: AssetResponseDto;
albums?: AlbumResponseDto[];
currentAlbum?: AlbumResponseDto | null;
onClose: () => void;
}
let { asset, albums = [], currentAlbum = null, onClose }: Props = $props();
let { asset, albums = [], currentAlbum = null }: Props = $props();
let showAssetPath = $state(false);
let showEditFaces = $state(false);
@@ -127,7 +127,7 @@
<IconButton
icon={mdiClose}
aria-label={$t('close')}
onclick={onClose}
onclick={() => assetViewerManager.closeDetailPanel()}
shape="round"
color="secondary"
variant="ghost"

View File

@@ -0,0 +1,43 @@
import { PersistedLocalStorage } from '$lib/utils/persisted';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
export class AssetViewerManager {
#isShowActivityPanel = $state(false);
get isShowActivityPanel() {
return this.#isShowActivityPanel;
}
private set isShowActivityPanel(value: boolean) {
this.#isShowActivityPanel = value;
}
get isShowDetailPanel() {
return isShowDetailPanel.current;
}
private set isShowDetailPanel(value: boolean) {
isShowDetailPanel.current = value;
}
toggleActivityPanel() {
this.closeDetailPanel();
this.isShowActivityPanel = !this.isShowActivityPanel;
}
closeActivityPanel() {
this.isShowActivityPanel = false;
}
toggleDetailPanel() {
this.closeActivityPanel();
this.isShowDetailPanel = !this.isShowDetailPanel;
}
closeDetailPanel() {
this.isShowDetailPanel = false;
}
}
export const assetViewerManager = new AssetViewerManager();

View File

@@ -59,8 +59,6 @@ export const mapSettings = persistedObject<MapSettings>('map-settings', defaultM
export const videoViewerVolume = persisted<number>('video-viewer-volume', 1, {});
export const videoViewerMuted = persisted<boolean>('video-viewer-muted', false, {});
export const isShowDetail = persisted<boolean>('info-opened', false, {});
export interface AlbumViewSettings {
view: string;
filter: string;

View File

@@ -30,6 +30,7 @@
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -100,7 +101,6 @@
let backUrl: string = $state(AppRoute.ALBUMS);
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
let isCreatingSharedAlbum = $state(false);
let isShowActivity = $state(false);
let albumOrder: AssetOrder | undefined = $state(data.album.order);
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -138,10 +138,6 @@
}
};
const handleOpenAndCloseActivityTab = () => {
isShowActivity = !isShowActivity;
};
const handleStartSlideshow = async () => {
const asset =
$slideshowNavigation === SlideshowNavigation.Shuffle
@@ -302,7 +298,7 @@
$effect(() => {
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false;
assetViewerManager.closeActivityPanel();
}
});
@@ -537,7 +533,6 @@
numberOfComments={activityManager.commentCount}
numberOfLikes={undefined}
onFavorite={handleFavorite}
onOpenActivityTab={handleOpenAndCloseActivityTab}
/>
</div>
{/if}
@@ -724,7 +719,7 @@
{/if}
{/if}
</div>
{#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}
{#if album.albumUsers.length > 0 && album && assetViewerManager.isShowActivityPanel && $user && !$showAssetViewer}
<div class="flex">
<div
transition:fly={{ duration: 150 }}
@@ -737,7 +732,6 @@
disabled={!album.isActivityEnabled}
albumOwnerId={album.ownerId}
albumId={album.id}
onClose={handleOpenAndCloseActivityTab}
/>
</div>
</div>