refactor: zoom support

This commit is contained in:
wuzihao051119
2025-06-30 03:07:33 +08:00
parent 769d0aed87
commit b8dc1a4b1f
37 changed files with 369 additions and 354 deletions

View File

@@ -1,25 +0,0 @@
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { useZoomImageWheel } from '@zoom-image/svelte';
import { get } from 'svelte/store';
export const zoomImageAction = (node: HTMLElement) => {
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(node, {
maxZoom: 10,
});
const state = get(photoZoomState);
if (state) {
setZoomImageState(state);
}
const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)];
return {
destroy() {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
},
};
};

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { AssetManager } from '$lib/managers/asset-manager.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte';
import { navigate } from '$lib/utils/navigation';

View File

@@ -4,7 +4,7 @@
import AlbumMap from '$lib/components/album-page/album-map.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import type { AssetManager } from '$lib/managers/asset-manager.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import type { AssetManager } from '$lib/managers/asset-manager.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { downloadFile } from '$lib/utils/asset-utils';
import { IconButton } from '@immich/ui';
import { mdiFolderDownloadOutline } from '@mdi/js';

View File

@@ -21,9 +21,8 @@
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 type { AssetManager } from '$lib/managers/asset-manager.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
@@ -93,7 +92,8 @@
motionPhoto,
}: Props = $props();
let asset = $derived(assetManager.asset);
let asset = $derived(assetManager.asset!);
let zoomImageState = $derived(assetManager.zoomImageState);
const sharedLink = getSharedLink();
let isOwner = $derived($user && asset.ownerId === $user?.id);
@@ -143,7 +143,7 @@
color="secondary"
variant="ghost"
shape="round"
icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
icon={zoomImageState && zoomImageState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
aria-label={$t('zoom_image')}
onclick={onZoomImage}
/>

View File

@@ -7,7 +7,7 @@
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { isShowDetail } from '$lib/stores/preferences.store';
@@ -96,7 +96,7 @@
let shouldPlayMotionPhoto = $state(false);
let sharedLink = getSharedLink();
let enableDetailPanel = $derived(asset.hasMetadata);
let enableDetailPanel = $derived(asset?.hasMetadata ?? false);
let slideshowStateUnsubscribe: () => void;
let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined = $state();
@@ -442,8 +442,7 @@
{#if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash}
{assetManager}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
@@ -453,15 +452,14 @@
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<ImagePanoramaViewer {asset} />
<ImagePanoramaViewer {assetManager} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} />
{:else}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{asset}
{preloadAssets}
{assetManager}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
@@ -470,8 +468,7 @@
{/if}
{:else}
<VideoViewer
assetId={asset.id}
cacheKey={asset.thumbhash}
{assetManager}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}

View File

@@ -5,6 +5,7 @@
import { onDestroy, onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import {
changedOriention,
cropAspectRatio,
@@ -13,7 +14,6 @@
rotateDegrees,
} from '$lib/stores/asset-editor.store';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { animateCropChange, recalculateCrop } from './crop-settings';
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
import { draw } from './drawing';
@@ -21,10 +21,10 @@
import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
interface Props {
asset: AssetResponseDto;
assetManager: AssetManager;
}
let { asset }: Props = $props();
let { assetManager = $bindable() }: Props = $props();
let img = $state<HTMLImageElement>();

View File

@@ -1,17 +1,19 @@
<script lang="ts">
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAssetOriginalUrl } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { AssetMediaSize, viewAsset } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
interface Props {
asset: AssetResponseDto;
assetManager: AssetManager;
}
const { asset }: Props = $props();
// TODO: do not preload assets.
const { assetManager = $bindable() }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ id, size: AssetMediaSize.Preview, key: authManager.key });

View File

@@ -1,26 +1,28 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { type AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import {
cancelImageLoad,
mediaLoadError,
mediaLoaded,
} from '$lib/managers/asset-manager/internal/load-support.svelte';
import { zoomImageAttachment } from '$lib/managers/asset-manager/internal/zoom-support.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy } from 'svelte';
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -28,8 +30,7 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
interface Props {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[] | undefined;
assetManager: AssetManager;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
@@ -40,8 +41,7 @@
}
let {
asset,
preloadAssets = undefined,
assetManager = $bindable(),
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
@@ -53,51 +53,25 @@
const { slideshowState, slideshowLook } = slideshowStore;
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
let zoomImageState = $derived(assetManager.zoomImageState);
let loader = $state<HTMLImageElement>();
photoZoomState.set({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
zoomToggle = () => {
if (zoomImageState) {
zoomImageState.currentZoom = zoomImageState.currentZoom > 1 ? 1 : 2;
}
};
onDestroy(() => {
$boundingBoxesArray = [];
});
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.isImage) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
}
}
};
const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
copyImage = async () => {
if (!canCopyImageToClipboard()) {
return;
}
try {
await copyImageToClipboard($photoViewerImgElement ?? assetFileUrl);
await copyImageToClipboard($photoViewerImgElement ?? assetManager.url!);
notificationController.show({
type: NotificationType.Info,
message: $t('copied_image_to_clipboard'),
@@ -108,17 +82,10 @@
}
};
zoomToggle = () => {
photoZoomState.set({
...$photoZoomState,
currentZoom: $photoZoomState.currentZoom > 1 ? 1 : 2,
});
};
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
$effect(() => {
if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) {
if (isFaceEditMode.value && zoomImageState && zoomImageState.currentZoom > 1) {
zoomToggle();
}
});
@@ -132,7 +99,7 @@
};
const onSwipe = (event: SwipeCustomEvent) => {
if ($photoZoomState.currentZoom > 1) {
if (!zoomImageState || zoomImageState.currentZoom > 1) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
@@ -143,21 +110,10 @@
}
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
});
$effect(() => {
if (assetFileUrl) {
if (assetManager.url) {
// this can't be in an async context with $effect
void cast(assetFileUrl);
void cast(assetManager.url);
}
});
@@ -175,35 +131,10 @@
}
};
const onload = () => {
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
imageError = imageLoaded = true;
};
$effect(() => {
preload(targetImageSize, preloadAssets);
onDestroy(() => {
cancelImageLoad(assetManager);
});
onMount(() => {
if (loader?.complete) {
onload();
}
loader?.addEventListener('load', onload, { passive: true });
loader?.addEventListener('error', onerror, { passive: true });
return () => {
loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror);
cancelImageUrl(imageLoaderUrl);
};
});
let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash));
let containerWidth = $state(0);
let containerHeight = $state(0);
</script>
@@ -217,27 +148,32 @@
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
]}
/>
{#if imageError}
{#if assetManager.loadError}
<div class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<div
bind:this={element}
class="relative h-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#if !imageLoaded}
<img
style="display:none"
src={assetManager.url}
alt=""
aria-hidden="true"
onload={() => mediaLoaded(assetManager)}
onerror={() => mediaLoadError(assetManager)}
/>
{#if !assetManager.isLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !imageError}
{:else if !assetManager.loadError}
<div
use:zoomImageAction
{@attach zoomImageAttachment(assetManager)}
use:swipe={() => ({})}
onswipe={onSwipe}
class="h-full w-full"
@@ -245,7 +181,7 @@
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
src={assetManager.url}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
@@ -253,15 +189,15 @@
{/if}
<img
bind:this={$photoViewerImgElement}
src={assetFileUrl}
alt={$getAltText(toTimelineAsset(asset))}
src={assetManager.url}
alt={$getAltText(toTimelineAsset(assetManager.asset!))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
{#each getBoundingBox($boundingBoxesArray, zoomImageState!, $photoViewerImgElement) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
@@ -270,7 +206,12 @@
</div>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
<FaceEditor
htmlElement={$photoViewerImgElement}
{containerWidth}
{containerHeight}
assetId={assetManager.asset!.id}
/>
{/if}
{/if}
</div>

View File

@@ -3,6 +3,7 @@
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
@@ -16,9 +17,8 @@
import { fade } from 'svelte/transition';
interface Props {
assetId: string;
assetManager: AssetManager;
loopVideo: boolean;
cacheKey: string | null;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
@@ -27,9 +27,8 @@
}
let {
assetId,
assetManager = $bindable(),
loopVideo,
cacheKey,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},

View File

@@ -3,12 +3,13 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
interface Props {
assetId: string;
assetManager: AssetManager;
}
const { assetId }: Props = $props();
const { assetManager = $bindable() }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { ProjectionType } from '$lib/constants';
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
interface Props {
assetId: string;
assetManager: AssetManager;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
@@ -15,10 +15,10 @@
onVideoStarted?: () => void;
}
// TODO: do not preload assets.
let {
assetId,
assetManager = $bindable(),
projectionType,
cacheKey,
loopVideo,
onPreviousAsset,
onClose,
@@ -29,12 +29,11 @@
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {assetId} />
<VideoPanoramaViewer {assetManager} />
{:else}
<VideoNativeViewer
{loopVideo}
{cacheKey}
{assetId}
{assetManager}
{onPreviousAsset}
{onNextAsset}
{onVideoEnded}

View File

@@ -26,7 +26,7 @@
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';

View File

@@ -12,7 +12,7 @@
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@@ -439,35 +439,38 @@
};
const handlePrevious = async () => {
const laterAsset = await timelineManager.getLaterAsset(asset);
if (laterAsset) {
// TODO: If preloadAsset is undefined, throw an exception.
// assetManager.preloadAssets = [await timelineManager.getLaterAsset(laterAsset)];
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
let laterAsset = undefined;
if (asset) {
laterAsset = await timelineManager.getLaterAsset(asset);
if (laterAsset) {
// TODO: If preloadAsset is undefined, throw an exception.
// assetManager.preloadAssets = [await timelineManager.getLaterAsset(laterAsset)];
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
}
return !!laterAsset;
};
const handleNext = async () => {
const earlierAsset = await timelineManager.getEarlierAsset(asset);
if (earlierAsset) {
// assetManager.preloadAssets = [await timelineManager.getEarlierAsset(earlierAsset)];
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
let earlierAsset = undefined;
if (asset) {
earlierAsset = await timelineManager.getEarlierAsset(asset);
if (earlierAsset) {
// assetManager.preloadAssets = [await timelineManager.getEarlierAsset(earlierAsset)];
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
}
return !!earlierAsset;
};
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
let randomAsset = undefined;
if (asset) {
randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
}
}
return !!randomAsset;
};
@@ -777,7 +780,7 @@
});
$effect(() => {
if (assetManager.showAssetViewer) {
if (assetManager.showAssetViewer && asset) {
const { localDateTime } = getTimes(asset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
}

View File

@@ -3,7 +3,7 @@
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 type { AssetManager } from '$lib/managers/asset-manager.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';

View File

@@ -4,7 +4,7 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetManager } from '$lib/managers/asset-manager.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';

View File

@@ -2,7 +2,7 @@
import { shortcuts } from '$lib/actions/shortcut';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import type { AssetManager } from '$lib/managers/asset-manager.svelte';
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { navigate } from '$lib/utils/navigation';

View File

@@ -1,143 +0,0 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { CancellableTask } from '$lib/utils/cancellable-task';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAllAlbums,
getAssetInfo,
getAssetOriginalPath,
getAssetPlaybackPath,
getAssetThumbnailPath,
getBaseUrl,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { isEqual } from 'lodash-es';
export type AssetManagerOptions = {
assetId?: string;
preloadAssetIds?: string[];
size?: AssetMediaSize;
};
export class AssetManager {
isInitialized = $state(false);
#asset: AssetResponseDto | undefined = $state();
preloadAssets: TimelineAsset[] = $state([]);
cacheKey: string | null = $state(null);
albums: AlbumResponseDto[] = $state([]);
showAssetViewer: boolean = $state(false);
gridScrollTarget: AssetGridRouteSearchParams | undefined = $state();
initTask = new CancellableTask(
() => (this.isInitialized = true),
() => (this.isInitialized = false),
() => void 0,
);
// TODO: Delete this after development
#emptyAsset: AssetResponseDto = {
checksum: '',
deviceAssetId: '',
deviceId: '',
duration: '',
fileCreatedAt: '',
fileModifiedAt: '',
hasMetadata: true,
id: '',
isArchived: false,
isFavorite: false,
isOffline: false,
isTrashed: false,
localDateTime: '',
originalFileName: '',
originalPath: '',
ownerId: '',
thumbhash: null,
type: AssetTypeEnum.Image,
updatedAt: '',
visibility: AssetVisibility.Timeline,
};
static #INIT_OPTIONS = {};
#options: AssetManagerOptions = AssetManager.#INIT_OPTIONS;
constructor() {}
async #initializeAsset(id: string) {
const assetResponse = await getAssetInfo({ id, key: authManager.key });
if (!assetResponse) {
return;
}
this.#asset = assetResponse;
const albumsResponse = await getAllAlbums({ assetId: this.asset.id });
if (!albumsResponse) {
return;
}
this.albums = albumsResponse;
}
async refreshAlbums() {}
async refreshAsset() {}
async updateOptions(options: AssetManagerOptions) {
if (this.#options !== AssetManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
return;
}
await this.initTask.reset();
await this.#init(options);
}
async #init(options: AssetManagerOptions) {
this.isInitialized = false;
await this.initTask.execute(async () => {
this.#options = options;
// TODO: If assetId is undefined, throw an exception.
await this.#initializeAsset(this.#options.assetId!);
// TODO: Preload assets.
}, true);
}
public destroy() {
this.isInitialized = false;
}
#createUrl(path: string, parameters?: Record<string, unknown>) {
const searchParameters = new URLSearchParams();
for (const key in parameters) {
const value = parameters[key];
if (value !== undefined && value !== null) {
searchParameters.set(key, value.toString());
}
}
return getBaseUrl() + path + searchParameters.toString();
}
get asset() {
return this.#asset ?? this.#emptyAsset;
}
get originalUrl() {
return this.#createUrl(getAssetOriginalPath(this.asset.id), { key: authManager.key, c: this.cacheKey });
}
get thumbnailUrl() {
return this.#createUrl(getAssetThumbnailPath(this.asset.id), {
key: authManager.key,
c: this.cacheKey,
size: this.#options.size,
});
}
get playbackUrl() {
return this.#createUrl(getAssetPlaybackPath(this.asset.id), { key: authManager.key, c: this.cacheKey });
}
}

View File

@@ -0,0 +1,204 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { CancellableTask } from '$lib/utils/cancellable-task';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
getAllAlbums,
getAssetInfo,
getAssetOriginalPath,
getAssetPlaybackPath,
getAssetThumbnailPath,
getBaseUrl,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { type ZoomImageWheelState } from '@zoom-image/core';
import { isEqual } from 'lodash-es';
export enum AssetMediaSize {
Original = 'original',
Fullsize = 'fullsize',
Preview = 'preview',
Thumbnail = 'thumbnail',
Playback = 'playback',
}
export type AssetManagerOptions = {
assetId?: string;
preloadAssetIds?: string[];
loadAlbums?: boolean;
size?: AssetMediaSize;
};
export class AssetManager {
isInitialized = $state(false);
isLoaded = $state(false);
loadError = $state(false);
asset: AssetResponseDto | undefined = $state();
preloadAssets: TimelineAsset[] = $state([]);
albums: AlbumResponseDto[] = $state([]);
cacheKey: string | null = $derived(this.asset?.thumbhash ?? null);
url: string | undefined = $derived.by(() => {
if (this.asset) {
return this.#getAssetUrl(toTimelineAsset(this.asset!));
}
});
showAssetViewer: boolean = $state(false);
gridScrollTarget: AssetGridRouteSearchParams | undefined = $state();
zoomImageState: ZoomImageWheelState | undefined = $state();
initTask = new CancellableTask(
() => (this.isInitialized = true),
() => {
this.asset = undefined;
this.preloadAssets = [];
this.albums = [];
this.isInitialized = false;
},
() => void 0,
);
static #INIT_OPTIONS = {};
#options: AssetManagerOptions = AssetManager.#INIT_OPTIONS;
constructor() {}
async #initializeAsset() {
if (this.#options.assetId) {
const assetResponse = await getAssetInfo({ id: this.#options.assetId, key: authManager.key });
if (!assetResponse) {
return;
}
this.asset = assetResponse;
} else {
throw new Error('The assetId in required in options.');
}
// TODO: Preload assets.
if (this.#options.loadAlbums ?? true) {
const albumsResponse = await getAllAlbums({ assetId: this.#options.assetId });
if (!albumsResponse) {
return;
}
this.albums = albumsResponse;
}
}
async updateOptions(options: AssetManagerOptions) {
if (this.#options !== AssetManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
return;
}
await this.initTask.reset();
await this.#init(options);
}
#checkOptions() {
this.#options.size = AssetMediaSize.Original;
if (!this.asset || !this.zoomImageState) {
return;
}
if (this.asset.originalMimeType === 'image/gif' || this.zoomImageState.currentZoom > 1) {
// TODO: use original image forcely and according to the setting.
}
}
async #init(options: AssetManagerOptions) {
this.isInitialized = false;
this.asset = undefined;
this.preloadAssets = [];
this.albums = [];
await this.initTask.execute(async () => {
this.#options = options;
await this.#initializeAsset();
this.#checkOptions();
}, true);
}
public destroy() {
this.isInitialized = false;
}
async refreshAlbums() {}
async refreshAsset() {}
#preload() {
for (const preloadAsset of this.preloadAssets) {
if (preloadAsset.isImage) {
let img = new Image();
const preloadUrl = this.#getAssetUrl(preloadAsset);
if (preloadUrl) {
img.src = preloadUrl;
} else {
throw new Error('AssetManager is not initialized.');
}
}
}
}
#getAssetUrl(asset: TimelineAsset) {
if (!this.asset) {
return;
}
let path = undefined;
const searchParameters = new URLSearchParams();
if (authManager.key) {
searchParameters.set('key', authManager.key);
}
if (this.cacheKey) {
searchParameters.set('c', this.cacheKey);
}
switch (this.#options.size) {
case AssetMediaSize.Original: {
path = getAssetOriginalPath(this.asset.id);
break;
}
case AssetMediaSize.Fullsize:
case AssetMediaSize.Thumbnail:
case AssetMediaSize.Preview: {
path = getAssetThumbnailPath(this.asset.id);
break;
}
case AssetMediaSize.Playback: {
path = getAssetPlaybackPath(this.asset.id);
break;
}
default:
// TODO: default AssetMediaSize
}
return getBaseUrl() + path + '?' + searchParameters.toString();
}
get isOriginalImage() {
return this.#options.size === AssetMediaSize.Original || this.#options.size === AssetMediaSize.Fullsize;
}
}
// const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
// if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
// return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
// }
// return targetSize === 'original'
// ? getAssetOriginalUrl({ id, cacheKey })
// : getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
// };
// $effect(() => {
// if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
// assetManager.updateOptions({
// size: isWebCompatibleImage(asset) ? AssetMediaSize.Original : AssetMediaSize.Fullsize,
// });
// }
// assetManager.updateOptions({ size: AssetMediaSize.Preview });
// });

View File

@@ -0,0 +1,17 @@
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
export function mediaLoaded(assetManager: AssetManager) {
assetManager.isLoaded = true;
}
export function mediaLoadError(assetManager: AssetManager) {
assetManager.isLoaded = assetManager.loadError = true;
}
export function cancelImageLoad(assetManager: AssetManager) {
if (assetManager.url) {
cancelImageUrl(assetManager.url);
}
assetManager.isLoaded = assetManager.loadError = false;
}

View File

@@ -0,0 +1,24 @@
import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { useZoomImageWheel } from '@zoom-image/svelte';
import type { Attachment } from 'svelte/attachments';
export function zoomImageAttachment(assetManager: AssetManager): Attachment<HTMLElement> {
return (element) => {
let zoomImage = $derived(assetManager.zoomImageState);
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(element, { maxZoom: 10 });
$effect(() => {
if (zoomImage) {
setZoomImageState(zoomImage);
}
});
const unsubscribe = zoomImageState.subscribe((value) => (zoomImage = value));
return () => {
unsubscribe();
};
};
}

View File

@@ -1,4 +0,0 @@
import type { ZoomImageWheelState } from '@zoom-image/core';
import { writable } from 'svelte/store';
export const photoZoomState = writable<ZoomImageWheelState>();

View File

@@ -34,7 +34,7 @@
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';

View File

@@ -14,7 +14,7 @@
import { AssetAction } from '$lib/constants';
import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility } from '@immich/sdk';

View File

@@ -17,7 +17,7 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences } from '$lib/stores/user.store';

View File

@@ -32,7 +32,7 @@
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { onDestroy } from 'svelte';
interface Props {

View File

@@ -12,7 +12,7 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility, lockAuthSession } from '@immich/sdk';

View File

@@ -4,7 +4,7 @@
import Map from '$lib/components/shared-components/map/map.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AppRoute } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { navigate } from '$lib/utils/navigation';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import MemoryViewer from '$lib/components/memory-page/memory-viewer.svelte';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { onDestroy } from 'svelte';
import type { PageData } from './$types';

View File

@@ -8,7 +8,7 @@
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 { AppRoute } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility } from '@immich/sdk';

View File

@@ -31,7 +31,7 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';

View File

@@ -22,7 +22,7 @@
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';

View File

@@ -23,7 +23,7 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';

View File

@@ -5,7 +5,7 @@
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
import PasswordField from '$lib/components/shared-components/password-field.svelte';
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { user } from '$lib/stores/user.store';
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';

View File

@@ -20,7 +20,7 @@
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
interface Props {
data: PageData;

View File

@@ -13,7 +13,7 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';

View File

@@ -20,7 +20,7 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { onDestroy } from 'svelte';
import { AssetManager } from '$lib/managers/asset-manager.svelte';
import { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte';
interface Props {
data: PageData;