diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts
index e49f04dbee..5c834d0544 100644
--- a/web/src/lib/actions/thumbhash.ts
+++ b/web/src/lib/actions/thumbhash.ts
@@ -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);
}
-}
+};
diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts
index e67d3e1928..f39cefbbff 100644
--- a/web/src/lib/actions/zoom-image.ts
+++ b/web/src/lib/actions/zoom-image.ts
@@ -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();
},
};
};
diff --git a/web/src/lib/components/asset-viewer/adaptive-image.svelte b/web/src/lib/components/asset-viewer/adaptive-image.svelte
new file mode 100644
index 0000000000..23b6ec7f2d
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/adaptive-image.svelte
@@ -0,0 +1,163 @@
+
+
+
+ {#if asset.thumbhash}
+
+ {@const thumbKey = loadState.thumbnailUrl + loadState.thumbOpacity}
+
+
+ {#key thumbKey}
+

+ {/key}
+
+ {:else if showSpinner}
+
+
+
+ {/if}
+
+ {#if showBrokenAsset}
+
+
+
+ {:else}
+
+ {#if loadState.currentUrl && slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground}
+

+ {/if}
+
+ {#key asset.id}
+
+

+
+ {@render overlays?.()}
+
+ {/key}
+ {/if}
+
+
+
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index d280a6294b..901e6ffd0b 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -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();
- 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();
+
+ $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'}
navigateAsset('previous')}
- onNextAsset={() => navigateAsset('next')}
{sharedLink}
- haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
- onFree={() => eventManager.emit('AssetViewerFree')}
+ onReady={() => eventManager.emit('AssetViewerReady')}
/>
{:else if viewerKind === 'VideoViewer'}
-
+
{/if}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index 0328025594..156f987130 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -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([]);
+
+ 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;
diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte
index 399c4fb7de..e329be09b5 100644
--- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte
+++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte
@@ -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(() => {
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte
index 1631faaa23..d0b98477a0 100644
--- a/web/src/lib/components/asset-viewer/photo-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte
@@ -1,59 +1,41 @@
@@ -219,74 +152,44 @@
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
]}
/>
-{#if imageError}
-
-
-
-{/if}
-
+
- {#if !imageLoaded}
-
-
-
- {:else if !imageError}
-
+
onReady?.()}
+ onError={() => onReady?.()}
+ bind:imgElement={$photoViewerImgElement}
>
- {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
-
- {/if}
-
-
- {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
-
- {/each}
+ {#snippet overlays()}
+
+ {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
+
+ {/each}
- {#each ocrBoxes as ocrBox (ocrBox.id)}
-
- {/each}
-
+ {#each ocrBoxes as ocrBox (ocrBox.id)}
+
+ {/each}
+ {/snippet}
+
{#if isFaceEditMode.value}
{/if}
- {/if}
+
-
-
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
index 740cf784b7..a1dd22f44f 100644
--- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
@@ -1,6 +1,6 @@