GalleryViewer

This commit is contained in:
Min Idzelis 2025-04-20 02:51:32 +00:00
parent 3b9490e28d
commit c1e699ebaf
12 changed files with 168 additions and 154 deletions

View File

@ -66,7 +66,7 @@
onClose: (asset: AssetResponseDto) => void; onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>; onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>; onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<AssetResponseDto | undefined>; onRandom: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>; copyImage?: () => Promise<void>;
} }
@ -89,7 +89,7 @@
copyImage = $bindable(), copyImage = $bindable(),
}: Props = $props(); }: Props = $props();
const { setAsset } = assetViewingStore; const { setAssetId } = assetViewingStore;
const { const {
restartProgress: restartSlideshowProgress, restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress, stopProgress: stopSlideshowProgress,
@ -210,7 +210,7 @@
slideshowStateUnsubscribe = slideshowState.subscribe((value) => { slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) { if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset(); slideshowHistory.reset();
slideshowHistory.queue(asset); slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow()); handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) { } else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow()); handlePromiseError(handleStopSlideshow());
@ -220,7 +220,7 @@
shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => { shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) { if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset(); slideshowHistory.reset();
slideshowHistory.queue(asset); slideshowHistory.queue(toTimelineAsset(asset));
} }
}); });
@ -335,8 +335,7 @@
let assetViewerHtmlElement = $state<HTMLElement>(); let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => { const slideshowHistory = new SlideshowHistory((asset) => {
setAsset(asset); handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
$restartSlideshowProgress = true;
}); });
const handleVideoStarted = () => { const handleVideoStarted = () => {

View File

@ -26,14 +26,15 @@
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type Viewport } from '$lib/stores/assets-store.svelte'; import { type TimelineAsset, type Viewport } from '$lib/stores/assets-store.svelte';
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte'; import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { getAssetPlaybackUrl, getAssetThumbnailUrl, getKey, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromLocalDateTime } from '$lib/utils/timeline-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk'; import { fromLocalDateTime, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui'; import { IconButton } from '@immich/ui';
import { import {
mdiCardsOutline, mdiCardsOutline,
@ -66,6 +67,11 @@
let playerInitialized = $state(false); let playerInitialized = $state(false);
let paused = $state(false); let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined); let current = $state<MemoryAsset | undefined>(undefined);
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ id: current?.asset.id, key: getKey() }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
let isSaved = $derived(current?.memory.isSaved); let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0); let viewerHeight = $state(0);
@ -73,11 +79,11 @@
const viewport: Viewport = $state({ width: 0, height: 0 }); const viewport: Viewport = $state({ width: 0, height: 0 });
// need to include padding in the viewport for gallery // need to include padding in the viewport for gallery
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 }); const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
const assetInteraction = new AssetInteraction<AssetResponseDto>(); const assetInteraction = new AssetInteraction();
let progressBarController: Tween<number> | undefined = $state(undefined); let progressBarController: Tween<number> | undefined = $state(undefined);
let videoPlayer: HTMLVideoElement | undefined = $state(); let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: AssetResponseDto) => `?${QueryParameter.ID}=${asset.id}`; const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: AssetResponseDto) => { const handleNavigate = async (asset?: { id: string }) => {
if ($isViewing) { if ($isViewing) {
return asset; return asset;
} }
@ -88,9 +94,9 @@
await goto(asHref(asset)); await goto(asHref(asset));
}; };
const setProgressDuration = (asset: AssetResponseDto) => { const setProgressDuration = (asset: TimelineAsset) => {
if (asset.type === AssetTypeEnum.Video) { if (asset.isVideo) {
const timeParts = asset.duration.split(':').map(Number); const timeParts = asset.duration!.split(':').map(Number);
const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000; const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000;
progressBarController = new Tween<number>(0, { progressBarController = new Tween<number>(0, {
duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0), duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0),
@ -106,7 +112,8 @@
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
const handleEscape = async () => goto(AppRoute.PHOTOS); const handleEscape = async () => goto(AppRoute.PHOTOS);
const handleSelectAll = () => assetInteraction.selectAssets(current?.memory.assets || []); const handleSelectAll = () =>
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
const handleAction = async (callingContext: string, action: 'reset' | 'pause' | 'play') => { const handleAction = async (callingContext: string, action: 'reset' | 'pause' | 'play') => {
// leaving these log statements here as comments. Very useful to figure out what's going on during dev! // leaving these log statements here as comments. Very useful to figure out what's going on during dev!
// console.log(`handleAction[${callingContext}] called with: ${action}`); // console.log(`handleAction[${callingContext}] called with: ${action}`);
@ -239,7 +246,7 @@
}; };
const initPlayer = () => { const initPlayer = () => {
const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.type === AssetTypeEnum.Video && !videoPlayer; const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.isVideo && !videoPlayer;
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) { if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
return; return;
} }
@ -439,7 +446,7 @@
<div class="relative h-full w-full rounded-2xl bg-black"> <div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id} {#key current.asset.id}
<div transition:fade class="h-full w-full"> <div transition:fade class="h-full w-full">
{#if current.asset.type === AssetTypeEnum.Video} {#if current.asset.isVideo}
<video <video
bind:this={videoPlayer} bind:this={videoPlayer}
autoplay autoplay
@ -453,13 +460,15 @@
transition:fade transition:fade
></video> ></video>
{:else} {:else}
{#await currentMemoryAssetFull then asset}
<img <img
class="h-full w-full rounded-2xl object-contain transition-all" class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })} src={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })}
alt={current.asset.exifInfo?.description} alt={$getAltText(asset)}
draggable="false" draggable="false"
transition:fade transition:fade
/> />
{/await}
{/if} {/if}
</div> </div>
{/key} {/key}
@ -547,8 +556,10 @@
{fromLocalDateTime(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL)} {fromLocalDateTime(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL)}
</p> </p>
<p> <p>
{current.asset.exifInfo?.city || ''} {#await currentMemoryAssetFull then asset}
{current.asset.exifInfo?.country || ''} {asset?.exifInfo?.city || ''}
{asset?.exifInfo?.country || ''}
{/await}
</p> </p>
</div> </div>
</div> </div>
@ -619,7 +630,7 @@
<GalleryViewer <GalleryViewer
onNext={handleNextAsset} onNext={handleNextAsset}
onPrevious={handlePreviousAsset} onPrevious={handlePreviousAsset}
assets={current.memory.assets} assets={currentTimelineAssets}
viewport={galleryViewport} viewport={galleryViewport}
{assetInteraction} {assetInteraction}
slidingWindowOffset={viewerHeight} slidingWindowOffset={viewerHeight}

View File

@ -1,27 +1,27 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Viewport } from '$lib/stores/assets-store.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { getKey, handlePromiseError } from '$lib/utils'; import { getKey, handlePromiseError } from '$lib/utils';
import { downloadArchive } from '$lib/utils/asset-utils'; import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { addSharedLinkAssets, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js'; import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte';
import RemoveFromSharedLink from '../photos-page/actions/remove-from-shared-link.svelte'; import RemoveFromSharedLink from '../photos-page/actions/remove-from-shared-link.svelte';
import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import type { Viewport } from '$lib/stores/assets-store.svelte';
import { t } from 'svelte-i18n';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
interface Props { interface Props {
sharedLink: SharedLinkResponseDto; sharedLink: SharedLinkResponseDto;
@ -31,9 +31,10 @@
let { sharedLink = $bindable(), isOwned }: Props = $props(); let { sharedLink = $bindable(), isOwned }: Props = $props();
const viewport: Viewport = $state({ width: 0, height: 0 }); const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction<AssetResponseDto>(); const assetInteraction = new AssetInteraction();
let assets = $derived(sharedLink.assets); let assets = $derived(sharedLink.assets.map((a) => toTimelineAsset(a)));
let fullAsset = $derived(assets[0] ? getAssetInfo({ id: assets[0]?.id, key: getKey() }) : null);
dragAndDropFilesStore.subscribe((value) => { dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) { if (value.isDragging && value.files.length > 0) {
@ -127,8 +128,9 @@
<GalleryViewer {assets} {assetInteraction} {viewport} /> <GalleryViewer {assets} {assetInteraction} {viewport} />
</section> </section>
{:else} {:else}
{#await fullAsset then asset}
<AssetViewer <AssetViewer
asset={assets[0]} asset={asset!}
showCloseButton={false} showCloseButton={false}
onAction={handleAction} onAction={handleAction}
onPrevious={() => Promise.resolve(false)} onPrevious={() => Promise.resolve(false)}
@ -136,5 +138,6 @@
onRandom={() => Promise.resolve(undefined)} onRandom={() => Promise.resolve(undefined)}
onClose={() => {}} onClose={() => {}}
/> />
{/await}
{/if} {/if}
</section> </section>

View File

@ -6,7 +6,7 @@
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset, Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
@ -25,17 +25,17 @@
import ShowShortcuts from '../show-shortcuts.svelte'; import ShowShortcuts from '../show-shortcuts.svelte';
interface Props { interface Props {
assets: AssetResponseDto[]; assets: (TimelineAsset | AssetResponseDto)[];
assetInteraction: AssetInteraction<AssetResponseDto>; assetInteraction: AssetInteraction;
disableAssetSelect?: boolean; disableAssetSelect?: boolean;
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
viewport: Viewport; viewport: Viewport;
onIntersected?: (() => void) | undefined; onIntersected?: (() => void) | undefined;
showAssetName?: boolean; showAssetName?: boolean;
isShowDeleteConfirmation?: boolean; isShowDeleteConfirmation?: boolean;
onPrevious?: (() => Promise<AssetResponseDto | undefined>) | undefined; onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<AssetResponseDto | undefined>) | undefined; onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<AssetResponseDto | undefined>) | undefined; onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
pageHeaderOffset?: number; pageHeaderOffset?: number;
slidingWindowOffset?: number; slidingWindowOffset?: number;
} }
@ -56,7 +56,7 @@
pageHeaderOffset = 0, pageHeaderOffset = 0,
}: Props = $props(); }: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
let geometry: CommonJustifiedLayout | undefined = $state(); let geometry: CommonJustifiedLayout | undefined = $state();
@ -83,19 +83,26 @@
containerHeight = geometry.containerHeight; containerHeight = geometry.containerHeight;
containerWidth = geometry.containerWidth; containerWidth = geometry.containerWidth;
for (const [i, asset] of assets.entries()) { for (const [i, asset] of assets.entries()) {
const layout = { const top = geometry.getTop(i);
asset, const left = geometry.getLeft(i);
top: geometry.getTop(i), const width = geometry.getWidth(i);
left: geometry.getLeft(i), const height = geometry.getHeight(i);
width: geometry.getWidth(i),
height: geometry.getHeight(i), const layoutTopWithOffset = top + pageHeaderOffset;
}; const layoutBottom = layoutTopWithOffset + height;
// 54 is the content height of the asset-selection-app-bar
const layoutTopWithOffset = layout.top + pageHeaderOffset;
const layoutBottom = layoutTopWithOffset + layout.height;
const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top; const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
assetLayout.push({ ...layout, display });
const layout = {
asset,
top,
left,
width,
height,
display,
};
assetLayout.push(layout);
} }
} }
@ -109,7 +116,7 @@
let showShortcuts = $state(false); let showShortcuts = $state(false);
let currentViewAssetIndex = 0; let currentViewAssetIndex = 0;
let shiftKeyIsDown = $state(false); let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: AssetResponseDto | null = $state(null); let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let slidingWindow = $state({ top: 0, bottom: 0 }); let slidingWindow = $state({ top: 0, bottom: 0 });
const updateSlidingWindow = () => { const updateSlidingWindow = () => {
@ -139,14 +146,14 @@
} }
} }
}); });
const viewAssetHandler = async (asset: AssetResponseDto) => { const viewAssetHandler = async (asset: TimelineAsset) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id); currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
setAsset(assets[currentViewAssetIndex]); await setAssetId(assets[currentViewAssetIndex].id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
}; };
const selectAllAssets = () => { const selectAllAssets = () => {
assetInteraction.selectAssets(assets); assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
}; };
const deselectAllAssets = () => { const deselectAllAssets = () => {
@ -168,7 +175,7 @@
} }
}; };
const handleSelectAssets = (asset: AssetResponseDto) => { const handleSelectAssets = (asset: TimelineAsset) => {
if (!asset) { if (!asset) {
return; return;
} }
@ -191,14 +198,14 @@
assetInteraction.setAssetSelectionStart(deselect ? null : asset); assetInteraction.setAssetSelectionStart(deselect ? null : asset);
}; };
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => { const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) { if (asset) {
selectAssetCandidates(asset); selectAssetCandidates(asset);
} }
lastAssetMouseEvent = asset; lastAssetMouseEvent = asset;
}; };
const selectAssetCandidates = (endAsset: AssetResponseDto) => { const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) { if (!shiftKeyIsDown) {
return; return;
} }
@ -215,7 +222,7 @@
[start, end] = [end, start]; [start, end] = [end, start];
} }
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1)); assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
}; };
const onSelectStart = (e: Event) => { const onSelectStart = (e: Event) => {
@ -310,7 +317,7 @@
const handleNext = async (): Promise<boolean> => { const handleNext = async (): Promise<boolean> => {
try { try {
let asset: AssetResponseDto | undefined; let asset: { id: string } | undefined;
if (onNext) { if (onNext) {
asset = await onNext(); asset = await onNext();
} else { } else {
@ -334,9 +341,9 @@
} }
}; };
const handleRandom = async (): Promise<AssetResponseDto | undefined> => { const handleRandom = async (): Promise<{ id: string } | undefined> => {
try { try {
let asset: AssetResponseDto | undefined; let asset: { id: string } | undefined;
if (onRandom) { if (onRandom) {
asset = await onRandom(); asset = await onRandom();
} else { } else {
@ -360,7 +367,7 @@
const handlePrevious = async (): Promise<boolean> => { const handlePrevious = async (): Promise<boolean> => {
try { try {
let asset: AssetResponseDto | undefined; let asset: { id: string } | undefined;
if (onPrevious) { if (onPrevious) {
asset = await onPrevious(); asset = await onPrevious();
} else { } else {
@ -384,9 +391,9 @@
} }
}; };
const navigateToAsset = async (asset?: AssetResponseDto) => { const navigateToAsset = async (asset?: { id: string }) => {
if (asset && asset.id !== $viewingAsset.id) { if (asset && asset.id !== $viewingAsset.id) {
setAsset(asset); await setAssetId(asset.id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
} }
}; };
@ -405,20 +412,20 @@
} else if (currentViewAssetIndex === assets.length) { } else if (currentViewAssetIndex === assets.length) {
await handlePrevious(); await handlePrevious();
} else { } else {
setAsset(assets[currentViewAssetIndex]); await setAssetId(assets[currentViewAssetIndex].id);
} }
break; break;
} }
} }
}; };
const assetMouseEventHandler = (asset: AssetResponseDto | null) => { const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset); handleSelectAssetCandidates(asset);
} }
}; };
const assetOnFocusHandler = (asset: AssetResponseDto) => { const assetOnFocusHandler = (asset: TimelineAsset) => {
assetInteraction.focussedAssetId = asset.id; assetInteraction.focussedAssetId = asset.id;
}; };
@ -478,20 +485,19 @@
class="absolute" class="absolute"
style:overflow="clip" style:overflow="clip"
style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px" style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
title={showAssetName ? asset.originalFileName : ''}
> >
<Thumbnail <Thumbnail
readonly={disableAssetSelect} readonly={disableAssetSelect}
onClick={() => { onClick={() => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {
handleSelectAssets(asset); handleSelectAssets(toTimelineAsset(asset));
return; return;
} }
void viewAssetHandler(asset); void viewAssetHandler(toTimelineAsset(asset));
}} }}
onSelect={() => handleSelectAssets(asset)} onSelect={() => handleSelectAssets(toTimelineAsset(asset))}
onMouseEvent={() => assetMouseEventHandler(asset)} onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(asset))}
handleFocus={() => assetOnFocusHandler(asset)} handleFocus={() => assetOnFocusHandler(toTimelineAsset(asset))}
{showArchiveIcon} {showArchiveIcon}
asset={toTimelineAsset(asset)} asset={toTimelineAsset(asset)}
selected={assetInteraction.hasSelectedAsset(asset.id)} selected={assetInteraction.hasSelectedAsset(asset.id)}
@ -500,11 +506,13 @@
thumbnailWidth={layout.width} thumbnailWidth={layout.width}
thumbnailHeight={layout.height} thumbnailHeight={layout.height}
/> />
<!-- note: if using showAssetName then the 'assets' prop must be AssetResponseDto (only used by folders) -->
{#if showAssetName} {#if showAssetName}
<div <div
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap" class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
> >
{asset.originalFileName} {@debug}
{(asset as AssetResponseDto).originalFileName}
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -1,27 +1,20 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import type { AssetStackResponseDto, UserAdminResponseDto } from '@immich/sdk'; import type { UserAdminResponseDto } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { fromStore } from 'svelte/store'; import { fromStore } from 'svelte/store';
export type BaseInteractionAsset = { export class AssetInteraction {
id: string; selectedAssets = $state<TimelineAsset[]>([]);
isTrashed: boolean;
isArchived: boolean;
isFavorite: boolean;
ownerId: string;
stack?: AssetStackResponseDto | null | undefined;
};
export class AssetInteraction<T extends BaseInteractionAsset> {
selectedAssets = $state<T[]>([]);
hasSelectedAsset(assetId: string) { hasSelectedAsset(assetId: string) {
return this.selectedAssets.some((asset) => asset.id === assetId); return this.selectedAssets.some((asset) => asset.id === assetId);
} }
selectedGroup = new SvelteSet<string>(); selectedGroup = new SvelteSet<string>();
assetSelectionCandidates = $state<T[]>([]); assetSelectionCandidates = $state<TimelineAsset[]>([]);
hasSelectionCandidate(assetId: string) { hasSelectionCandidate(assetId: string) {
return this.assetSelectionCandidates.some((asset) => asset.id === assetId); return this.assetSelectionCandidates.some((asset) => asset.id === assetId);
} }
assetSelectionStart = $state<T | null>(null); assetSelectionStart = $state<TimelineAsset | null>(null);
focussedAssetId = $state<string | null>(null); focussedAssetId = $state<string | null>(null);
selectionActive = $derived(this.selectedAssets.length > 0); selectionActive = $derived(this.selectedAssets.length > 0);
@ -33,13 +26,13 @@ export class AssetInteraction<T extends BaseInteractionAsset> {
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite)); isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId)); isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
selectAsset(asset: T) { selectAsset(asset: TimelineAsset) {
if (!this.hasSelectedAsset(asset.id)) { if (!this.hasSelectedAsset(asset.id)) {
this.selectedAssets.push(asset); this.selectedAssets.push(asset);
} }
} }
selectAssets(assets: T[]) { selectAssets(assets: TimelineAsset[]) {
for (const asset of assets) { for (const asset of assets) {
this.selectAsset(asset); this.selectAsset(asset);
} }
@ -60,11 +53,11 @@ export class AssetInteraction<T extends BaseInteractionAsset> {
this.selectedGroup.delete(group); this.selectedGroup.delete(group);
} }
setAssetSelectionStart(asset: T | null) { setAssetSelectionStart(asset: TimelineAsset | null) {
this.assetSelectionStart = asset; this.assetSelectionStart = asset;
} }
setAssetSelectionCandidates(assets: T[]) { setAssetSelectionCandidates(assets: TimelineAsset[]) {
this.assetSelectionCandidates = assets; this.assetSelectionCandidates = assets;
} }

View File

@ -1,12 +1,7 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { asLocalTimeISO } from '$lib/utils/date-time'; import { asLocalTimeISO } from '$lib/utils/date-time';
import { import { toTimelineAsset } from '$lib/utils/timeline-util';
type AssetResponseDto, import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
deleteMemory,
type MemoryResponseDto,
removeMemoryAssets,
searchMemories,
updateMemory,
} from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
type MemoryIndex = { type MemoryIndex = {
@ -16,7 +11,7 @@ type MemoryIndex = {
export type MemoryAsset = MemoryIndex & { export type MemoryAsset = MemoryIndex & {
memory: MemoryResponseDto; memory: MemoryResponseDto;
asset: AssetResponseDto; asset: TimelineAsset;
previousMemory?: MemoryResponseDto; previousMemory?: MemoryResponseDto;
previous?: MemoryAsset; previous?: MemoryAsset;
next?: MemoryAsset; next?: MemoryAsset;
@ -36,7 +31,7 @@ class MemoryStoreSvelte {
memoryIndex, memoryIndex,
previousMemory: this.memories[memoryIndex - 1], previousMemory: this.memories[memoryIndex - 1],
nextMemory: this.memories[memoryIndex + 1], nextMemory: this.memories[memoryIndex + 1],
asset, asset: toTimelineAsset(asset),
assetIndex, assetIndex,
previous, previous,
}; };

View File

@ -1,17 +1,15 @@
import type { AssetResponseDto } from '@immich/sdk';
export class SlideshowHistory { export class SlideshowHistory {
private history: AssetResponseDto[] = []; private history: { id: string }[] = [];
private index = 0; private index = 0;
constructor(private onChange: (asset: AssetResponseDto) => void) {} constructor(private onChange: (asset: { id: string }) => void) {}
reset() { reset() {
this.history = []; this.history = [];
this.index = 0; this.index = 0;
} }
queue(asset: AssetResponseDto) { queue(asset: { id: string }) {
this.history.push(asset); this.history.push(asset);
// If we were at the end of the slideshow history, move the index to the new end // If we were at the end of the slideshow history, move the index to the new end

View File

@ -37,8 +37,16 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number
return 300; return 300;
} }
export const getAltTextForTimelineAsset = () => {
// TODO: implement this in a performant way
return '';
};
export const getAltText = derived(t, ($t) => { export const getAltText = derived(t, ($t) => {
return (asset: AssetResponseDto) => { return (asset: AssetResponseDto | null | undefined) => {
if (!asset) {
return '';
}
if (asset.exifInfo?.description) { if (asset.exifInfo?.description) {
return asset.exifInfo.description; return asset.exifInfo.description;
} }

View File

@ -1,4 +1,3 @@
import type { BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte';
import type { AssetBucket, TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { AssetBucket, TimelineAsset } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils'; import { getAssetRatio } from '$lib/utils/asset-utils';
@ -108,7 +107,7 @@ export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): strin
export const formatDateGroupTitle = memoize(formatGroupTitle); export const formatDateGroupTitle = memoize(formatGroupTitle);
export const toTimelineAsset = (unknownAsset: BaseInteractionAsset): TimelineAsset => { export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => {
if (isTimelineAsset(unknownAsset)) { if (isTimelineAsset(unknownAsset)) {
return unknownAsset; return unknownAsset;
} }
@ -132,5 +131,5 @@ export const toTimelineAsset = (unknownAsset: BaseInteractionAsset): TimelineAss
livePhotoVideoId: assetResponse.livePhotoVideoId || null, livePhotoVideoId: assetResponse.livePhotoVideoId || null,
}; };
}; };
export const isTimelineAsset = (arg: BaseInteractionAsset): arg is TimelineAsset => export const isTimelineAsset = (arg: AssetResponseDto | TimelineAsset): arg is TimelineAsset =>
(arg as TimelineAsset).ratio !== undefined; (arg as TimelineAsset).ratio !== undefined;

View File

@ -27,8 +27,8 @@
import { foldersStore } from '$lib/stores/folders.svelte'; import { foldersStore } from '$lib/stores/folders.svelte';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -47,7 +47,7 @@
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort()); let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort());
const assetInteraction = new AssetInteraction<AssetResponseDto>(); const assetInteraction = new AssetInteraction();
onMount(async () => { onMount(async () => {
await foldersStore.fetchUniquePaths(); await foldersStore.fetchUniquePaths();
@ -83,7 +83,7 @@
return; return;
} }
assetInteraction.selectAssets(data.pathAssets); assetInteraction.selectAssets(data.pathAssets.map((a) => toTimelineAsset(a)));
}; };
</script> </script>

View File

@ -22,7 +22,7 @@
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import { import {
@ -42,7 +42,7 @@
void assetStore.updateOptions({ isArchived: false, withStacked: true, withPartners: true }); void assetStore.updateOptions({ isArchived: false, withStacked: true, withPartners: true });
onDestroy(() => assetStore.destroy()); onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction<TimelineAsset>(); const assetInteraction = new AssetInteraction();
let selectedAssets = $derived(assetInteraction.selectedAssets); let selectedAssets = $derived(assetInteraction.selectedAssets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack); let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);

View File

@ -1,28 +1,41 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import { cancelMultiselect } from '$lib/utils/asset-utils'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { shortcut } from '$lib/actions/shortcut'; import type { TimelineAsset, Viewport } from '$lib/stores/assets-store.svelte';
import { lang, locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { parseUtcDate } from '$lib/utils/date-time';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
type AlbumResponseDto, type AlbumResponseDto,
type AssetResponseDto,
getPerson, getPerson,
getTagById, getTagById,
type MetadataSearchDto, type MetadataSearchDto,
@ -31,21 +44,8 @@
type SmartSearchDto, type SmartSearchDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import type { Viewport } from '$lib/stores/assets-store.svelte';
import { lang, locale } from '$lib/stores/preferences.store';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { handlePromiseError } from '$lib/utils';
import { parseUtcDate } from '$lib/utils/date-time';
import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { t } from 'svelte-i18n';
import { tick } from 'svelte'; import { tick } from 'svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; import { t } from 'svelte-i18n';
import { preferences } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
const MAX_ASSET_COUNT = 5000; const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
@ -58,12 +58,12 @@
let nextPage = $state(1); let nextPage = $state(1);
let searchResultAlbums: AlbumResponseDto[] = $state([]); let searchResultAlbums: AlbumResponseDto[] = $state([]);
let searchResultAssets: AssetResponseDto[] = $state([]); let searchResultAssets: TimelineAsset[] = $state([]);
let isLoading = $state(true); let isLoading = $state(true);
let scrollY = $state(0); let scrollY = $state(0);
let scrollYHistory = 0; let scrollYHistory = 0;
const assetInteraction = new AssetInteraction<AssetResponseDto>(); const assetInteraction = new AssetInteraction();
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>; type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>;
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY)); let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
@ -122,7 +122,7 @@
const onAssetDelete = (assetIds: string[]) => { const onAssetDelete = (assetIds: string[]) => {
const assetIdSet = new Set(assetIds); const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); searchResultAssets = searchResultAssets.filter((a: TimelineAsset) => !assetIdSet.has(a.id));
}; };
const handleSelectAll = () => { const handleSelectAll = () => {
assetInteraction.selectAssets(searchResultAssets); assetInteraction.selectAssets(searchResultAssets);
@ -160,7 +160,7 @@
: await searchAssets({ metadataSearchDto: searchDto }); : await searchAssets({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items); searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items); searchResultAssets.push(...assets.items.map((a) => toTimelineAsset(a)));
nextPage = Number(assets.nextPage) || 0; nextPage = Number(assets.nextPage) || 0;
} catch (error) { } catch (error) {
@ -238,7 +238,7 @@
if (terms.isNotInAlbum.toString() == 'true') { if (terms.isNotInAlbum.toString() == 'true') {
const assetIdSet = new Set(assetIds); const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); searchResultAssets = searchResultAssets.filter((a) => !assetIdSet.has(a.id));
} }
}; };