mirror of
https://github.com/immich-app/immich.git
synced 2026-01-18 10:00:41 -05:00
refactor: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte, increase performance esp. on low BW conn
This commit is contained in:
parent
7310628581
commit
ec48504238
@ -3,17 +3,25 @@ import { thumbHashToRGBA } from 'thumbhash';
|
||||
|
||||
/**
|
||||
* Renders a thumbnail onto a canvas from a base64 encoded hash.
|
||||
* @param canvas
|
||||
* @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString)
|
||||
*/
|
||||
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
|
||||
export function thumbhash(canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) {
|
||||
render(canvas, options);
|
||||
|
||||
return {
|
||||
update(newOptions: { base64ThumbHash: string }) {
|
||||
render(canvas, newOptions);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const render = (canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash));
|
||||
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(options.base64ThumbHash));
|
||||
const pixels = ctx.createImageData(w, h);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
pixels.data.set(rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { useZoomImageWheel } from '@zoom-image/svelte';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
|
||||
|
||||
createZoomImage(node, {
|
||||
const state = get(photoZoomState);
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
initialState: state,
|
||||
});
|
||||
|
||||
const state = get(photoZoomState);
|
||||
if (state) {
|
||||
setZoomImageState(state);
|
||||
}
|
||||
const unsubscribes = [
|
||||
photoZoomState.subscribe((state) => zoomInstance.setState(state)),
|
||||
zoomInstance.subscribe(({ state }) => {
|
||||
photoZoomState.set(state);
|
||||
}),
|
||||
];
|
||||
|
||||
// Store original event handlers so we can prevent them when disabled
|
||||
const wheelHandler = (event: WheelEvent) => {
|
||||
if (options?.disabled) {
|
||||
event.stopImmediatePropagation();
|
||||
@ -27,22 +28,21 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
}
|
||||
};
|
||||
|
||||
// Add handlers at capture phase with higher priority
|
||||
node.addEventListener('wheel', wheelHandler, { capture: true });
|
||||
node.addEventListener('pointerdown', pointerDownHandler, { capture: true });
|
||||
|
||||
const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)];
|
||||
|
||||
node.style.overflow = 'visible';
|
||||
return {
|
||||
update(newOptions?: { disabled?: boolean }) {
|
||||
options = newOptions;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('wheel', wheelHandler, { capture: true });
|
||||
node.removeEventListener('pointerdown', pointerDownHandler, { capture: true });
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
node.removeEventListener('wheel', wheelHandler, { capture: true });
|
||||
node.removeEventListener('pointerdown', pointerDownHandler, { capture: true });
|
||||
zoomInstance.cleanup();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
163
web/src/lib/components/asset-viewer/adaptive-image.svelte
Normal file
163
web/src/lib/components/asset-viewer/adaptive-image.svelte
Normal file
@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
|
||||
import { photoZoomState, resetZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onMount, untrack, type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
zoomDisabled?: boolean;
|
||||
imageClass?: string;
|
||||
width: string;
|
||||
height: string;
|
||||
slideshowState: SlideshowState;
|
||||
slideshowLook: SlideshowLook;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
imgElement?: HTMLImageElement;
|
||||
overlays?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
imgElement = $bindable<HTMLImageElement>(),
|
||||
asset,
|
||||
sharedLink,
|
||||
zoomDisabled = false,
|
||||
imageClass = '',
|
||||
width,
|
||||
height,
|
||||
slideshowState,
|
||||
slideshowLook,
|
||||
onImageReady,
|
||||
onError,
|
||||
overlays,
|
||||
}: Props = $props();
|
||||
|
||||
let previousLoader = $state<AdaptiveImageLoader>();
|
||||
|
||||
// Zoom transform for thumbhash/thumbnail layers
|
||||
let hashPreviewTransform = $state<string>();
|
||||
|
||||
const adaptiveImageLoader = $derived.by(() => {
|
||||
// Cleanup previous loader
|
||||
untrack(() => {
|
||||
previousLoader?.cleanup();
|
||||
resetZoomState();
|
||||
});
|
||||
|
||||
// Create new loader for this asset
|
||||
const newLoader = new AdaptiveImageLoader(asset, sharedLink, {
|
||||
currentZoomFn: () => $photoZoomState.currentZoom,
|
||||
onImageReady,
|
||||
onError,
|
||||
});
|
||||
|
||||
untrack(() => {
|
||||
previousLoader = newLoader;
|
||||
// Always start with preview - the $effect below will upgrade to original if zoomed
|
||||
void newLoader.load();
|
||||
});
|
||||
|
||||
return newLoader;
|
||||
});
|
||||
|
||||
// Effect: Upgrade to original when user zooms in
|
||||
$effect(() => {
|
||||
if ($photoZoomState.currentZoom > 1 && loadState.quality === 'preview') {
|
||||
imageManager.cancelPreloadUrl(loadState.currentUrl);
|
||||
void adaptiveImageLoader.upgradeToOriginal();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = photoZoomState.subscribe((state) => {
|
||||
hashPreviewTransform = `translate(${state.currentPositionX}px,${state.currentPositionY}px) scale(${state.currentZoom})`;
|
||||
});
|
||||
|
||||
return () => {
|
||||
adaptiveImageLoader.cleanup();
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
const loadState = $derived(adaptiveImageLoader.adaptiveLoaderState);
|
||||
const imageAltText = $derived(loadState.currentUrl ? $getAltText(toTimelineAsset(asset)) : '');
|
||||
const imageOpacity = $derived(loadState.currentUrl ? '1' : '0');
|
||||
const imageSrc = $derived(loadState.currentUrl ?? '');
|
||||
const showSpinner = $derived(!asset.thumbhash && loadState.quality === 'basic');
|
||||
const showBrokenAsset = $derived(loadState.hasError && loadState.quality !== 'loading-original');
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full">
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash and thumbnail layer -->
|
||||
{@const thumbKey = loadState.thumbnailUrl + loadState.thumbOpacity}
|
||||
<div style:transform-origin="0px 0px" style:transform={hashPreviewTransform} class="h-full w-full absolute">
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
|
||||
{#key thumbKey}
|
||||
<img
|
||||
src={loadState.thumbnailUrl}
|
||||
style:opacity={loadState.thumbOpacity}
|
||||
alt=""
|
||||
class="h-full w-full absolute -z-1"
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{:else if showSpinner}
|
||||
<div id="spinner" class="absolute flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showBrokenAsset}
|
||||
<div class="h-full w-full">
|
||||
<BrokenAsset class="text-xl h-full w-full" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Slideshow blurred background -->
|
||||
{#if loadState.currentUrl && slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
src={loadState.currentUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#key asset.id}
|
||||
<div use:zoomImageAction={{ disabled: zoomDisabled }} style:width style:height>
|
||||
<img
|
||||
bind:this={imgElement}
|
||||
style:opacity={imageOpacity}
|
||||
src={imageSrc}
|
||||
alt={imageAltText}
|
||||
class="h-full w-full {imageClass}"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
{@render overlays?.()}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
@ -12,7 +12,7 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
@ -29,7 +29,6 @@
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetTypeEnum,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
getStack,
|
||||
runAssetJobs,
|
||||
@ -106,12 +105,11 @@
|
||||
const asset = $derived(cursor.current);
|
||||
let nextAsset = $derived(cursor.nextAsset);
|
||||
let previousAsset = $derived(cursor.previousAsset);
|
||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||
|
||||
let sharedLink = getSharedLink();
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let isShowEditor = $state(false);
|
||||
let fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let zoomToggle = $state(() => void 0);
|
||||
@ -151,59 +149,43 @@
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
unsubscribes.push(
|
||||
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));
|
||||
}
|
||||
}),
|
||||
);
|
||||
onMount(() => {
|
||||
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
});
|
||||
|
||||
if (!sharedLink) {
|
||||
await handleGetAllAlbums();
|
||||
}
|
||||
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
slideshowStateUnsubscribe();
|
||||
slideshowNavigationUnsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
activityManager.reset();
|
||||
imageManager.cancel(cursor.nextAsset);
|
||||
imageManager.cancel(cursor.previousAsset);
|
||||
});
|
||||
|
||||
const handleGetAllAlbums = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
|
||||
} catch (error) {
|
||||
console.error('Error getting album that asset belong to', error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
if (editManager.hasAppliedEdits) {
|
||||
console.log(asset);
|
||||
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
||||
console.log(refreshedAsset);
|
||||
onAssetChange?.(refreshedAsset);
|
||||
assetViewingStore.setAsset(refreshedAsset);
|
||||
}
|
||||
@ -212,7 +194,7 @@
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
|
||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
@ -221,8 +203,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
e?.stopPropagation();
|
||||
preloadManager.cancel(asset);
|
||||
imageManager.cancel(asset);
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
@ -318,7 +299,7 @@
|
||||
const handleAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case AssetAction.ADD_TO_ALBUM: {
|
||||
await handleGetAllAlbums();
|
||||
eventManager.emit('AlbumAddAssets');
|
||||
break;
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
@ -373,7 +354,6 @@
|
||||
|
||||
const refresh = async () => {
|
||||
await refreshStack();
|
||||
await handleGetAllAlbums();
|
||||
ocrManager.clear();
|
||||
if (!sharedLink) {
|
||||
if (previewStackedAsset) {
|
||||
@ -386,8 +366,19 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset;
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
preloadManager.preload(cursor.nextAsset);
|
||||
preloadManager.preload(cursor.previousAsset);
|
||||
});
|
||||
|
||||
let lastCursor = $state<AssetCursor>();
|
||||
|
||||
$effect(() => {
|
||||
if (cursor !== lastCursor) {
|
||||
imageManager.cancel(lastCursor?.current);
|
||||
imageManager.cancel(lastCursor?.nextAsset);
|
||||
imageManager.cancel(lastCursor?.previousAsset);
|
||||
imageManager.preload(cursor.nextAsset);
|
||||
imageManager.preload(cursor.previousAsset);
|
||||
lastCursor = cursor;
|
||||
}
|
||||
});
|
||||
|
||||
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
|
||||
@ -409,7 +400,7 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset.id;
|
||||
if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') {
|
||||
eventManager.emit('AssetViewerFree');
|
||||
eventManager.emit('AssetViewerReady');
|
||||
}
|
||||
});
|
||||
|
||||
@ -494,10 +485,8 @@
|
||||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
haveFadeTransition={false}
|
||||
{sharedLink}
|
||||
onReady={() => eventManager.emit('AssetViewerReady')}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
@ -532,11 +521,8 @@
|
||||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||
onFree={() => eventManager.emit('AssetViewerFree')}
|
||||
onReady={() => eventManager.emit('AssetViewerReady')}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
@ -585,7 +571,7 @@
|
||||
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} />
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
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 { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
@ -17,10 +18,17 @@
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
@ -35,6 +43,7 @@
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { slide } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
@ -44,11 +53,10 @@
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
albums?: AlbumResponseDto[];
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
}
|
||||
|
||||
let { asset, albums = [], currentAlbum = null }: Props = $props();
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
@ -74,6 +82,31 @@
|
||||
);
|
||||
let previousId: string | undefined = $state();
|
||||
|
||||
let albums = $state<AlbumResponseDto[]>([]);
|
||||
|
||||
const refreshAlbums = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
albums = await getAllAlbums({ assetId: asset.id });
|
||||
} catch (error) {
|
||||
handleError(error, 'Error getting asset album membership');
|
||||
}
|
||||
};
|
||||
|
||||
eventManager.on('AlbumAddAssets', () => void refreshAlbums());
|
||||
onDestroy(() => {
|
||||
eventManager.off('AlbumAddAssets', () => void refreshAlbums());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset;
|
||||
untrack(() => void refreshAlbums());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!previousId) {
|
||||
previousId = asset.id;
|
||||
|
||||
@ -111,7 +111,7 @@
|
||||
viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 });
|
||||
};
|
||||
|
||||
const handleReady = () => eventManager.emit('AssetViewerFree');
|
||||
const handleReady = () => eventManager.emit('AssetViewerReady');
|
||||
|
||||
let hasChangedResolution: boolean = false;
|
||||
onMount(() => {
|
||||
|
||||
@ -1,59 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard, getDimensions } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { scaleToFit } from '$lib/utils/layout-utils';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onFree?: (() => void) | null;
|
||||
onBusy?: (() => void) | null;
|
||||
onError?: (() => void) | null;
|
||||
onLoad?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
onReady?: () => void;
|
||||
copyImage?: () => Promise<void>;
|
||||
zoomToggle?: (() => void) | null;
|
||||
zoomToggle?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
onFree = null,
|
||||
onBusy = null,
|
||||
onError = null,
|
||||
onLoad = null,
|
||||
sharedLink,
|
||||
onReady,
|
||||
copyImage = $bindable(),
|
||||
zoomToggle = $bindable(),
|
||||
}: Props = $props();
|
||||
@ -61,18 +43,14 @@
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
photoZoomState.set({
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
const imageDimensions = $derived.by(() => {
|
||||
if ((asset.width ?? 0) > 0 && (asset.height ?? 0) > 0) {
|
||||
return { width: asset.width!, height: asset.height! };
|
||||
} else if (asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageWidth) {
|
||||
return getDimensions(asset.exifInfo) as { width: number; height: number };
|
||||
} else {
|
||||
return { width: 1, height: 1 };
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@ -123,29 +101,11 @@
|
||||
handlePromiseError(copyImage());
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if ($photoZoomState.currentZoom > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onNextAsset && event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
|
||||
if (onPreviousAsset && event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
};
|
||||
|
||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
|
||||
let currentPreviewUrl = $state<string>();
|
||||
|
||||
$effect(() => {
|
||||
if (imageLoaderUrl) {
|
||||
void cast(imageLoaderUrl);
|
||||
if (currentPreviewUrl) {
|
||||
void cast(currentPreviewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@ -163,50 +123,23 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
onLoad?.();
|
||||
onFree?.();
|
||||
imageLoaded = true;
|
||||
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
||||
};
|
||||
|
||||
const onerror = () => {
|
||||
onError?.();
|
||||
onFree?.();
|
||||
imageError = imageLoaded = true;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
if (!imageLoaded && !imageError) {
|
||||
onFree?.();
|
||||
}
|
||||
preloadManager.cancelPreloadUrl(imageLoaderUrl);
|
||||
};
|
||||
});
|
||||
|
||||
let imageLoaderUrl = $derived(
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
|
||||
);
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
const container = $derived({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
let lastUrl: string | undefined;
|
||||
const scaledDimensions = $derived(scaleToFit(imageDimensions, container));
|
||||
|
||||
$effect(() => {
|
||||
if (!lastUrl) {
|
||||
untrack(() => onBusy?.());
|
||||
}
|
||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||
untrack(() => {
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
onBusy?.();
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
const box = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
left: (containerWidth - width) / 2 + 'px',
|
||||
top: (containerHeight - height) / 2 + 'px',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -219,74 +152,44 @@
|
||||
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
|
||||
]}
|
||||
/>
|
||||
{#if imageError}
|
||||
<div id="broken-asset" class="h-full w-full">
|
||||
<BrokenAsset class="text-xl h-full w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
class="relative h-full w-full select-none"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if !imageError}
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||
<div class="absolute" style:width={box.width} style:height={box.height} style:left={box.left} style:top={box.top}>
|
||||
<AdaptiveImage
|
||||
{asset}
|
||||
{sharedLink}
|
||||
zoomDisabled={isOcrActive}
|
||||
imageClass={$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook]}
|
||||
width={box.width}
|
||||
height={box.height}
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
onImageReady={() => onReady?.()}
|
||||
onError={() => onReady?.()}
|
||||
bind:imgElement={$photoViewerImgElement}
|
||||
>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
src={imageLoaderUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={$photoViewerImgElement}
|
||||
src={imageLoaderUrl}
|
||||
alt={$getAltText(toTimelineAsset(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}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
></div>
|
||||
{/each}
|
||||
{#snippet overlays()}
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
</div>
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#broken-asset,
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiEyeOffOutline } from '@mdi/js';
|
||||
import type { ActionReturn } from 'svelte/action';
|
||||
@ -60,7 +60,7 @@
|
||||
onComplete?.(false);
|
||||
}
|
||||
return {
|
||||
destroy: () => preloadManager.cancelPreloadUrl(url),
|
||||
destroy: () => imageManager.cancelPreloadUrl(url),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||
import { handleErrorAsync } from '$lib/utils/handle-error';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
|
||||
@ -41,8 +42,10 @@
|
||||
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
|
||||
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
|
||||
if (earlierTimelineAsset) {
|
||||
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id });
|
||||
if (preload) {
|
||||
const asset = await handleErrorAsync(() =>
|
||||
assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id }),
|
||||
);
|
||||
if (preload && asset) {
|
||||
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
|
||||
void getNextAsset(asset, false);
|
||||
}
|
||||
@ -53,8 +56,10 @@
|
||||
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
|
||||
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
|
||||
if (laterTimelineAsset) {
|
||||
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id });
|
||||
if (preload) {
|
||||
const asset = await handleErrorAsync(() =>
|
||||
assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id }),
|
||||
);
|
||||
if (preload && asset) {
|
||||
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
|
||||
void getPreviousAsset(asset, false);
|
||||
}
|
||||
|
||||
170
web/src/lib/managers/ImageManager.svelte.ts
Normal file
170
web/src/lib/managers/ImageManager.svelte.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { getAssetUrl } from '$lib/utils';
|
||||
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
class ImageManager {
|
||||
private activeLoadCount = 0;
|
||||
private preloadQueue: AssetResponseDto[] = [];
|
||||
private pendingPreloads = new Map<string, ReturnType<typeof setTimeout> | null>();
|
||||
|
||||
preload(asset: AssetResponseDto | undefined) {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any cancellation marker and remove from queue (fresh preload request)
|
||||
if (this.pendingPreloads.get(asset.id) === null) {
|
||||
this.pendingPreloads.delete(asset.id);
|
||||
this.removeFromQueue(asset.id);
|
||||
}
|
||||
|
||||
// Queue preload if there are active high-priority loads
|
||||
if (this.activeLoadCount > 0) {
|
||||
this.addToQueue(asset);
|
||||
return;
|
||||
}
|
||||
|
||||
this.schedulePreload(asset);
|
||||
}
|
||||
|
||||
private addToQueue(asset: AssetResponseDto) {
|
||||
if (!this.preloadQueue.some((queued) => queued.id === asset.id)) {
|
||||
this.preloadQueue.push(asset);
|
||||
}
|
||||
}
|
||||
|
||||
private removeFromQueue(assetId: string) {
|
||||
this.preloadQueue = this.preloadQueue.filter((queued) => queued.id !== assetId);
|
||||
}
|
||||
|
||||
private schedulePreload(asset: AssetResponseDto) {
|
||||
// Don't schedule if this asset has been cancelled
|
||||
if (this.pendingPreloads.get(asset.id) === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any existing pending preload for this asset
|
||||
const existingTimeout = this.pendingPreloads.get(asset.id);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
|
||||
// Delay preloads by 250ms to prevent bandwidth contention with
|
||||
// main image loads (#loadImage)
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Check if this timeout is still valid
|
||||
const currentTimeout = this.pendingPreloads.get(asset.id);
|
||||
if (currentTimeout !== timeoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingPreloads.delete(asset.id);
|
||||
|
||||
// Don't execute if there are active primary loads - requeue instead
|
||||
if (this.activeLoadCount > 0) {
|
||||
this.addToQueue(asset);
|
||||
return;
|
||||
}
|
||||
|
||||
this.executePreload(asset);
|
||||
}, 250);
|
||||
|
||||
this.pendingPreloads.set(asset.id, timeoutId);
|
||||
}
|
||||
|
||||
private executePreload(asset: AssetResponseDto) {
|
||||
if (asset.type !== AssetTypeEnum.Image) {
|
||||
return;
|
||||
}
|
||||
if (globalThis.isSecureContext) {
|
||||
preloadImageUrl(getAssetUrl({ asset }));
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
const url = getAssetUrl({ asset });
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an image and return a promise that resolves when ready.
|
||||
* @param mode - 'load' resolves on load event, 'decode' resolves when decoded
|
||||
*/
|
||||
loadImage(url: string, mode: 'load' | 'decode' = 'load'): Promise<{ url: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.activeLoadCount++;
|
||||
const img = new Image();
|
||||
|
||||
const complete = () => {
|
||||
this.activeLoadCount--;
|
||||
this.flushPreloadQueue();
|
||||
resolve({ url });
|
||||
};
|
||||
|
||||
let errored = false;
|
||||
const error = (message: string) => {
|
||||
if (errored) {
|
||||
return;
|
||||
}
|
||||
errored = true;
|
||||
this.activeLoadCount--;
|
||||
this.flushPreloadQueue();
|
||||
reject(new Error(message));
|
||||
};
|
||||
|
||||
if (mode === 'load') {
|
||||
img.addEventListener('load', complete, { once: true });
|
||||
}
|
||||
|
||||
img.addEventListener('error', () => error(`Failed to load image: ${url}`), { once: true });
|
||||
img.src = url;
|
||||
|
||||
if (mode === 'decode') {
|
||||
img.decode().then(complete, () => error(`Failed to decode image: ${url}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private flushPreloadQueue() {
|
||||
if (this.activeLoadCount > 0 || this.preloadQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
const queue = [...this.preloadQueue];
|
||||
this.preloadQueue = [];
|
||||
for (const asset of queue) {
|
||||
this.schedulePreload(asset);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(asset: AssetResponseDto | undefined) {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear pending timeout and mark as cancelled
|
||||
const pendingTimeout = this.pendingPreloads.get(asset.id);
|
||||
if (pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
}
|
||||
this.pendingPreloads.set(asset.id, null);
|
||||
|
||||
// Remove from queue
|
||||
this.removeFromQueue(asset.id);
|
||||
|
||||
// Cancel service worker preload if applicable
|
||||
if (globalThis.isSecureContext) {
|
||||
cancelImageUrl(getAssetUrl({ asset }));
|
||||
}
|
||||
}
|
||||
|
||||
cancelPreloadUrl(url: string | undefined) {
|
||||
if (!globalThis.isSecureContext) {
|
||||
return;
|
||||
}
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const imageManager = new ImageManager();
|
||||
@ -1,38 +0,0 @@
|
||||
import { getAssetUrl } from '$lib/utils';
|
||||
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
class PreloadManager {
|
||||
preload(asset: AssetResponseDto | undefined) {
|
||||
if (globalThis.isSecureContext) {
|
||||
preloadImageUrl(getAssetUrl({ asset }));
|
||||
return;
|
||||
}
|
||||
if (!asset || asset.type !== AssetTypeEnum.Image) {
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
const url = getAssetUrl({ asset });
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
cancel(asset: AssetResponseDto | undefined) {
|
||||
if (!globalThis.isSecureContext || !asset) {
|
||||
return;
|
||||
}
|
||||
const url = getAssetUrl({ asset });
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
|
||||
cancelPreloadUrl(url: string | undefined) {
|
||||
if (!globalThis.isSecureContext) {
|
||||
return;
|
||||
}
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const preloadManager = new PreloadManager();
|
||||
@ -62,7 +62,7 @@ export type Events = {
|
||||
// confirmed permanently deleted from server
|
||||
UserAdminDeleted: [{ id: string }];
|
||||
|
||||
AssetViewerFree: [];
|
||||
AssetViewerReady: [];
|
||||
|
||||
SystemConfigUpdate: [SystemConfigDto];
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const photoViewerImgElement = writable<HTMLImageElement | null>(null);
|
||||
export const photoViewerImgElement = writable<HTMLImageElement>();
|
||||
export const isSelectingAllAssets = writable(false);
|
||||
|
||||
@ -1,4 +1,20 @@
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const photoZoomState = writable<ZoomImageWheelState>();
|
||||
export const photoZoomState = writable<ZoomImageWheelState>({
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
});
|
||||
|
||||
export const resetZoomState = () => {
|
||||
photoZoomState.set({
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
});
|
||||
};
|
||||
|
||||
161
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
161
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { getAssetThumbnailUrl, getAssetUrl } from '$lib/utils';
|
||||
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
|
||||
/**
|
||||
* Quality levels for progressive image loading
|
||||
*/
|
||||
type ImageQuality =
|
||||
| 'basic'
|
||||
| 'loading-thumbnail'
|
||||
| 'thumbnail'
|
||||
| 'loading-preview'
|
||||
| 'preview'
|
||||
| 'loading-original'
|
||||
| 'original';
|
||||
|
||||
export interface ImageLoaderState {
|
||||
currentUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
quality: ImageQuality;
|
||||
hasError: boolean;
|
||||
thumbOpacity: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates adaptive loading of a single asset image:
|
||||
* thumbhash → thumbnail → preview → original (on zoom)
|
||||
*
|
||||
*/
|
||||
export class AdaptiveImageLoader {
|
||||
private state = $state<ImageLoaderState>({
|
||||
quality: 'basic',
|
||||
hasError: false,
|
||||
thumbOpacity: '1',
|
||||
});
|
||||
|
||||
private readonly currentZoomFn: () => number;
|
||||
private readonly onImageReady?: () => void;
|
||||
private readonly onError?: () => void;
|
||||
private readonly thumbnailUrl: string;
|
||||
private readonly previewUrl: string | undefined;
|
||||
private readonly originalUrl: string | undefined;
|
||||
|
||||
constructor(
|
||||
asset: AssetResponseDto,
|
||||
sharedLink: SharedLinkResponseDto | undefined,
|
||||
callbacks: {
|
||||
currentZoomFn: () => number;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
},
|
||||
) {
|
||||
this.currentZoomFn = callbacks.currentZoomFn;
|
||||
this.onImageReady = callbacks.onImageReady;
|
||||
this.onError = callbacks.onError;
|
||||
|
||||
this.thumbnailUrl = getAssetThumbnailUrl({
|
||||
id: asset.id,
|
||||
size: AssetMediaSize.Thumbnail,
|
||||
cacheKey: asset.thumbhash,
|
||||
});
|
||||
this.previewUrl = getAssetUrl({ asset, sharedLink });
|
||||
this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true });
|
||||
}
|
||||
|
||||
get adaptiveLoaderState(): ImageLoaderState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start loading sequence for an asset
|
||||
*/
|
||||
async load(): Promise<boolean> {
|
||||
// Step 1: Load thumbnail
|
||||
const thumbSuccess = await this.loadThumbnail();
|
||||
if (!thumbSuccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Load preview or original based on zoom
|
||||
const wantsOriginal = this.currentZoomFn?.() > 1;
|
||||
return wantsOriginal ? await this.loadOriginal() : await this.loadPreview();
|
||||
}
|
||||
|
||||
async upgradeToOriginal(): Promise<void> {
|
||||
if (this.state.quality !== 'preview' && this.state.quality !== 'loading-preview') {
|
||||
return;
|
||||
}
|
||||
await this.loadOriginal();
|
||||
}
|
||||
|
||||
private async loadThumbnail(): Promise<boolean> {
|
||||
this.state.thumbnailUrl = this.thumbnailUrl;
|
||||
this.state.quality = 'loading-thumbnail';
|
||||
|
||||
try {
|
||||
await imageManager.loadImage(this.thumbnailUrl, 'load');
|
||||
this.state.quality = 'thumbnail';
|
||||
this.state.thumbOpacity = '1';
|
||||
return true;
|
||||
} catch {
|
||||
this.state.thumbOpacity = '0';
|
||||
return true; // Continue even if thumbnail fails
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPreview(): Promise<boolean> {
|
||||
if (!this.previewUrl) {
|
||||
this.state.hasError = true;
|
||||
this.onError?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.state.quality = 'loading-preview';
|
||||
try {
|
||||
// Set URL immediately for progressive decode
|
||||
this.state.currentUrl = this.previewUrl;
|
||||
await imageManager.loadImage(this.previewUrl, 'decode');
|
||||
|
||||
this.state.quality = 'preview';
|
||||
this.state.hasError = false;
|
||||
this.onImageReady?.();
|
||||
return true;
|
||||
} catch {
|
||||
this.state.currentUrl = undefined;
|
||||
this.state.hasError = true;
|
||||
this.onError?.();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadOriginal(): Promise<boolean> {
|
||||
if (!this.originalUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.state.quality = 'loading-original';
|
||||
|
||||
try {
|
||||
const { url } = await imageManager.loadImage(this.originalUrl, 'decode');
|
||||
|
||||
this.state.currentUrl = url;
|
||||
this.state.quality = 'original';
|
||||
this.state.hasError = false;
|
||||
this.onImageReady?.();
|
||||
return true;
|
||||
} catch {
|
||||
// Don't clear currentUrl on original load failure - keep showing preview
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources for this asset
|
||||
*/
|
||||
cleanup(): void {
|
||||
imageManager.cancelPreloadUrl(this.thumbnailUrl);
|
||||
imageManager.cancelPreloadUrl(this.previewUrl);
|
||||
imageManager.cancelPreloadUrl(this.originalUrl);
|
||||
}
|
||||
}
|
||||
@ -19,12 +19,15 @@ export function getServerErrorMessage(error: unknown) {
|
||||
return data?.message || error.message;
|
||||
}
|
||||
|
||||
export function handleError(error: unknown, message: string) {
|
||||
export function handleError(error: unknown, message?: string) {
|
||||
if ((error as Error)?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
console.error(`[handleError]: ${message}`, error, (error as Error)?.stack);
|
||||
const msg = message || err.message;
|
||||
|
||||
console.error(`[handleError]: ${msg}`, error, err?.stack);
|
||||
|
||||
try {
|
||||
let serverMessage = getServerErrorMessage(error);
|
||||
@ -32,7 +35,7 @@ export function handleError(error: unknown, message: string) {
|
||||
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
|
||||
}
|
||||
|
||||
const errorMessage = serverMessage || message;
|
||||
const errorMessage = serverMessage || msg;
|
||||
|
||||
toastManager.danger(errorMessage);
|
||||
|
||||
@ -42,3 +45,12 @@ export function handleError(error: unknown, message: string) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleErrorAsync<T>(fn: () => Promise<T>, message?: string): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, message);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
/**
|
||||
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
|
||||
* This class helps manage concurrent operations by tracking which invocations are active
|
||||
@ -55,6 +57,8 @@ export class InvocationTracker {
|
||||
const invocation = this.startInvocation();
|
||||
try {
|
||||
return await invocable();
|
||||
} catch (error: unknown) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
invocation.endInvocation();
|
||||
}
|
||||
|
||||
@ -129,3 +129,19 @@ export type CommonPosition = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// Scales dimensions to fit within a container (like object-fit: contain)
|
||||
export const scaleToFit = (
|
||||
dimensions: { width: number; height: number },
|
||||
container: { width: number; height: number },
|
||||
) => {
|
||||
const scaleX = container.width / dimensions.width;
|
||||
const scaleY = container.height / dimensions.height;
|
||||
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
return {
|
||||
width: dimensions.width * scale,
|
||||
height: dimensions.height * scale,
|
||||
};
|
||||
};
|
||||
|
||||
@ -24,11 +24,11 @@ export interface boundingBox {
|
||||
export const getBoundingBox = (
|
||||
faces: Faces[],
|
||||
zoom: ZoomImageWheelState,
|
||||
photoViewer: HTMLImageElement | null,
|
||||
photoViewer: HTMLImageElement | undefined,
|
||||
): boundingBox[] => {
|
||||
const boxes: boundingBox[] = [];
|
||||
|
||||
if (photoViewer === null) {
|
||||
if (!photoViewer) {
|
||||
return boxes;
|
||||
}
|
||||
const clientHeight = photoViewer.clientHeight;
|
||||
|
||||
@ -30,7 +30,7 @@ export const put = async (key: string, response: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
cache.put(key, response.clone());
|
||||
await cache.put(key, response.clone());
|
||||
};
|
||||
|
||||
export const prune = async () => {
|
||||
|
||||
@ -44,7 +44,7 @@ export const handleRequest = async (request: URL | Request) => {
|
||||
const response = await fetch(request, { signal: cancelToken.signal });
|
||||
|
||||
assertResponse(response);
|
||||
put(cacheKey, response);
|
||||
await put(cacheKey, response);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user