Compare commits

...

5 Commits

Author SHA1 Message Date
midzelis
3dcee3bf1d Merge with dependent PRs 2026-01-16 05:13:34 +00:00
midzelis
58a4047d29 feat: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte 2026-01-16 05:13:34 +00:00
midzelis
745558cbde feat: handle-error minor improvments 2026-01-16 04:52:03 +00:00
midzelis
d50c83c424 feat: thumbhash improvments for reactive prop updates 2026-01-15 21:41:01 +00:00
midzelis
05dccce714 feat: zoom image improvements for reactive prop handlings 2026-01-15 21:40:36 +00:00
21 changed files with 1031 additions and 407 deletions

View File

@@ -1,10 +1,7 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { Page, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
@@ -26,31 +23,32 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
const box = await page.getByTestId('thumbnail').boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
await expect(page.getByTestId('original')).toBeInViewport();
await expect(page.getByTestId('original')).toHaveAttribute('src', /original/);
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
const box = await page.getByTestId('thumbnail').boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
await expect(page.getByTestId('original')).toHaveAttribute('src', /fullsize/);
});
test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src');
await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
const initialSrc = await page.getByTestId('thumbnail').getAttribute('src');
await utils.replaceAsset(admin.accessToken, asset.id);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
await expect(page.getByTestId('preview')).not.toHaveAttribute('src', initialSrc!);
});
});

View File

@@ -993,12 +993,14 @@
"error": "Error",
"error_change_sort_album": "Failed to change album sort order",
"error_delete_face": "Error deleting face from asset",
"error_getting_asset_information": "Error getting asset information: {message}",
"error_getting_places": "Error getting places",
"error_loading_image": "Error loading image",
"error_loading_partners": "Error loading partners: {error}",
"error_saving_image": "Error: {error}",
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
"error_title": "Error - Something went wrong",
"error_while_navigating": "Error while navigating: {message}",
"errors": {
"cannot_navigate_next_asset": "Cannot navigate to the next asset",
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",

View File

@@ -0,0 +1,159 @@
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import type { ClassValue } from 'svelte/elements';
import { imageManager } from '$lib/managers/ImageManager.svelte';
/**
* Converts a ClassValue to a string suitable for className assignment.
* Handles strings, arrays, and objects similar to how clsx works.
*/
function classValueToString(value: ClassValue | undefined): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value
.map((v) => classValueToString(v))
.filter(Boolean)
.join(' ');
}
// Object/dictionary case
return Object.entries(value)
.filter(([, v]) => v)
.map(([k]) => k)
.join(' ');
}
export interface ImageLoaderParams {
src: string | undefined;
onStart?: () => void;
onLoad?: () => void;
onError?: (error: Error) => void;
onElementCreated?: (element: HTMLImageElement) => void;
imgClass?: ClassValue;
alt?: string;
draggable?: boolean;
role?: string;
style?: string;
title?: string | null;
loading?: 'lazy' | 'eager';
dataAttributes?: Record<string, string>;
}
/**
* 1. Creates and appends an <img> element to the parent
* 2. Coordinates with service worker before src triggers fetch
* 3. Adds load/error listeners
* 4. Cancels SW request when element is removed from DOM
*/
export function imageLoader(node: HTMLElement, params: ImageLoaderParams) {
let currentSrc = params.src;
let currentCallbacks = params;
let imgElement: HTMLImageElement | null = null;
const handleLoad = () => {
currentCallbacks.onLoad?.();
};
const handleError = () => {
currentCallbacks.onError?.(new Error(`Failed to load image: ${currentSrc}`));
};
const updateImageAttributes = (img: HTMLImageElement, params: ImageLoaderParams) => {
if (params.alt !== undefined) {
img.alt = params.alt;
}
if (params.draggable !== undefined) {
img.draggable = params.draggable;
}
if (params.imgClass) {
img.className = classValueToString(params.imgClass);
}
if (params.role) {
img.role = params.role;
}
if (params.style !== undefined) {
img.setAttribute('style', params.style);
}
if (params.title !== undefined && params.title !== null) {
img.title = params.title;
}
if (params.loading !== undefined) {
img.loading = params.loading;
}
if (params.dataAttributes) {
for (const [key, value] of Object.entries(params.dataAttributes)) {
img.setAttribute(key, value);
}
}
};
const createImageElement = (src: string | undefined, elementParams: ImageLoaderParams) => {
const img = document.createElement('img');
updateImageAttributes(img, elementParams);
img.addEventListener('load', handleLoad);
img.addEventListener('error', handleError);
elementParams.onElementCreated?.(img);
elementParams.onStart?.();
// Set src once during construction
if (src) {
imageManager.prepareImageUrl(src).then(
() => {
img.src = src;
node.append(img);
},
(error) => {
currentCallbacks.onError?.(error);
},
);
} else {
img.classList.add('hidden');
node.append(img);
}
return img;
};
imgElement = createImageElement(currentSrc, params);
const cleanupImageElement = () => {
cancelImageUrl(currentSrc);
if (imgElement) {
imgElement.removeEventListener('load', handleLoad);
imgElement.removeEventListener('error', handleError);
imgElement.remove();
}
};
return {
update(newParams: ImageLoaderParams) {
// If src changed, recreate the image element
if (newParams.src !== currentSrc) {
cleanupImageElement();
currentSrc = newParams.src;
currentCallbacks = newParams;
imgElement = createImageElement(currentSrc, newParams);
return;
}
currentCallbacks = newParams;
if (!imgElement) {
return;
}
updateImageAttributes(imgElement, newParams);
},
destroy() {
cleanupImageElement();
},
};
}

View File

@@ -3,17 +3,27 @@ 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 }) {
const ctx = canvas.getContext('2d');
if (ctx) {
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash));
const pixels = ctx.createImageData(w, h);
canvas.width = w;
canvas.height = h;
pixels.data.set(rgba);
ctx.putImageData(pixels, 0, 0);
}
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) {
return;
}
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);
};

View File

@@ -1,48 +1,42 @@
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) => {
const stopIfDisabled = (event: Event) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
const pointerDownHandler = (event: PointerEvent) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
// 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.addEventListener('wheel', stopIfDisabled, { capture: true });
node.addEventListener('pointerdown', stopIfDisabled, { capture: true });
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', stopIfDisabled, { capture: true });
node.removeEventListener('pointerdown', stopIfDisabled, { capture: true });
zoomInstance.cleanup();
},
};
};

View File

@@ -0,0 +1,246 @@
<script lang="ts">
import { imageLoader } from '$lib/actions/image-loader.svelte';
import { thumbhash } from '$lib/actions/thumbhash';
import { zoomImageAction } from '$lib/actions/zoom-image';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
import { photoZoomState, photoZoomTransform, resetZoomState } from '$lib/stores/zoom-image.store';
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
import { getDimensions } from '$lib/utils/asset-utils';
import { scaleToFit } from '$lib/utils/layout-utils';
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 { onDestroy, untrack, type Snippet } from 'svelte';
interface Props {
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
zoomDisabled?: boolean;
imageClass?: string;
container: {
width: number;
height: number;
};
slideshowState: SlideshowState;
slideshowLook: SlideshowLook;
onImageReady?: () => void;
onError?: () => void;
imgElement?: HTMLImageElement;
overlays?: Snippet;
}
let {
imgElement = $bindable<HTMLImageElement | undefined>(),
asset,
sharedLink,
zoomDisabled = false,
imageClass = '',
container,
slideshowState,
slideshowLook,
onImageReady,
onError,
overlays,
}: Props = $props();
let previousLoader = $state<AdaptiveImageLoader>();
let previousAssetId: string | undefined;
let previousSharedLinkId: string | undefined;
const adaptiveImageLoader = $derived.by(() => {
if (previousAssetId === asset.id && previousSharedLinkId === sharedLink?.id) {
return previousLoader!;
}
return untrack(() => {
previousAssetId = asset.id;
previousSharedLinkId = sharedLink?.id;
previousLoader?.destroy();
resetZoomState();
const loader = new AdaptiveImageLoader(asset, sharedLink, {
currentZoomFn: () => $photoZoomState.currentZoom,
onImageReady,
onError,
});
previousLoader = loader;
return loader;
});
});
onDestroy(() => adaptiveImageLoader.destroy());
const imageDimensions = $derived.by(() => {
if ((asset.width ?? 0) > 0 && (asset.height ?? 0) > 0) {
return { width: asset.width!, height: asset.height! };
}
if (asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageWidth) {
return getDimensions(asset.exifInfo) as { width: number; height: number };
}
return { width: 1, height: 1 };
});
const scaledDimensions = $derived(scaleToFit(imageDimensions, container));
const renderDimensions = $derived.by(() => {
const { width, height } = scaledDimensions;
return {
width: width + 'px',
height: height + 'px',
left: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
};
});
const loadState = $derived(adaptiveImageLoader.adaptiveLoaderState);
const imageAltText = $derived(loadState.previewUrl ? $getAltText(toTimelineAsset(asset)) : '');
const thumbnailUrl = $derived(loadState.thumbnailUrl);
const previewUrl = $derived(loadState.previewUrl);
const originalUrl = $derived(loadState.originalUrl);
const showSpinner = $derived(!asset.thumbhash && loadState.quality === 'basic');
const showBrokenAsset = $derived(loadState.hasError && loadState.quality !== 'loading-original');
// Effect: Upgrade to original when user zooms in
$effect(() => {
if ($photoZoomState.currentZoom > 1 && loadState.quality === 'preview') {
void adaptiveImageLoader.triggerOriginal();
}
});
let thumbnailElement = $state<HTMLImageElement>();
let previewElement = $state<HTMLImageElement>();
let originalElement = $state<HTMLImageElement>();
// Effect: Synchronize highest quality element as main imgElement
$effect(() => {
imgElement = originalElement ?? previewElement ?? thumbnailElement;
});
</script>
<div
class="relative h-full w-full"
style:left={renderDimensions.left}
style:top={renderDimensions.top}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<div style:transform-origin="0px 0px" style:transform={$photoZoomTransform} class="h-full w-full absolute">
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
</div>
{:else if showSpinner}
<div id="spinner" class="absolute flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{/if}
<div
use:imageLoader={{
src: thumbnailUrl,
onStart: () => adaptiveImageLoader.onThumbnailStart(),
onLoad: () => adaptiveImageLoader.onThumbnailLoad(),
onError: () => adaptiveImageLoader.onThumbnailError(),
onElementCreated: (el) => (thumbnailElement = el),
imgClass: ['absolute h-full', 'w-full', !thumbnailUrl && 'hidden'],
alt: '',
role: 'presentation',
dataAttributes: {
'data-testid': 'thumbnail',
},
}}
></div>
{#if showBrokenAsset}
<div class="h-full w-full absolute">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{:else}
<!-- Slideshow blurred background -->
{#if thumbnailUrl && slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground}
<img
src={thumbnailUrl}
alt=""
role="presentation"
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<div
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={$photoZoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<div
use:imageLoader={{
src: previewUrl,
onStart: () => adaptiveImageLoader.onPreviewStart(),
onLoad: () => adaptiveImageLoader.onPreviewLoad(),
onError: () => adaptiveImageLoader.onPreviewError(),
onElementCreated: (el) => (previewElement = el),
imgClass: ['h-full', 'w-full', { imageClass }, !previewUrl && 'hidden'],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'preview',
},
}}
></div>
{@render overlays?.()}
</div>
<div
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={$photoZoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<div
use:imageLoader={{
src: originalUrl,
onStart: () => adaptiveImageLoader.onOriginalStart(),
onLoad: () => adaptiveImageLoader.onOriginalLoad(),
onError: () => adaptiveImageLoader.onOriginalError(),
onElementCreated: (el) => (originalElement = el),
imgClass: ['h-full', 'w-full', { imageClass }, !originalUrl && 'hidden'],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'original',
},
}}
></div>
{@render overlays?.()}
</div>
<!-- Use placeholder empty image to zoomImage so it can monitor mouse-wheel events and update zoom state -->
<div
class="absolute top-0"
use:zoomImageAction={{ disabled: zoomDisabled }}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<img alt="" class="absolute h-full w-full hidden" draggable="false" />
</div>
{/if}
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>

View File

@@ -11,24 +11,24 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { eventManager } from '$lib/managers/event-manager.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';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { preloadImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
AssetTypeEnum,
getAllAlbums,
getAssetInfo,
getStack,
runAssetJobs,
@@ -97,7 +97,6 @@
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
slideshowTransition,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
@@ -105,12 +104,11 @@
const asset = $derived(cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const 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);
@@ -125,93 +123,114 @@
return;
}
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
if (!asset.stack) {
return;
}
stack = await getStack({ id: asset.stack.id });
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
}
void imageManager.preload(stack?.assets[1], 'thumbnail');
});
};
const handleFavorite = async () => {
if (album && album.isActivityEnabled) {
try {
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
}
}
};
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));
}
}),
);
if (!sharedLink) {
await handleGetAllAlbums();
}
});
onDestroy(() => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
activityManager.reset();
});
const handleGetAllAlbums = async () => {
if (authManager.isSharedLink) {
if (!album || !album.isActivityEnabled) {
return;
}
try {
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
await activityManager.toggleLike();
} catch (error) {
console.error('Error getting album that asset belong to', error);
handleError(error, $t('errors.unable_to_change_favorite'));
}
};
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());
}
});
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
});
return () => {
slideshowStateUnsubscribe();
slideshowNavigationUnsubscribe();
};
});
onDestroy(() => {
activityManager.reset();
if (cursor.nextAsset) {
imageManager.cancel(cursor.nextAsset, 'all');
}
if (cursor.previousAsset) {
imageManager.cancel(cursor.previousAsset, 'all');
}
});
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);
}
isShowEditor = false;
};
const tracker = new InvocationTracker();
const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => {
const assetToCancel = direction === 'next' ? cursor.previousAsset : cursor.nextAsset;
if (!assetToCancel) {
return;
}
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
imageManager.cancel(assetToCancel, 'all');
};
const updatePreloadsAfterNavigation = (oldCursor: AssetCursor, newCursor: AssetCursor) => {
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
if (movedForward) {
imageManager.cancel(oldCursor.previousAsset, 'all');
void imageManager.preload(newCursor.nextAsset, 'thumbnail');
return;
}
if (movedBackward) {
imageManager.cancel(oldCursor.nextAsset, 'all');
void imageManager.preload(newCursor.previousAsset, 'thumbnail');
return;
}
// For thoroughness: navigated to non-adjacent asset (e.g., slideshow random)
imageManager.cancel(oldCursor.nextAsset, 'all');
imageManager.cancel(oldCursor.previousAsset, 'all');
void imageManager.preload(newCursor.nextAsset, 'thumbnail');
void imageManager.preload(newCursor.previousAsset, 'thumbnail');
};
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next') => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
@@ -220,37 +239,44 @@
}
}
e?.stopPropagation();
preloadManager.cancel(asset);
if (tracker.isActive()) {
return;
}
void tracker.invoke(async () => {
let hasNext = false;
cancelPreloadsBeforeNavigation(order);
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
void tracker.invoke(
async () => {
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else {
hasNext =
order === 'previous'
? await navigateToAsset(cursor.previousAsset)
: await navigateToAsset(cursor.nextAsset);
}
if ($slideshowState !== SlideshowState.PlaySlideshow) {
return;
}
} else {
hasNext =
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}
});
},
(error: Error) => $t('error_while_navigating', { values: { message: error.message } }),
);
};
// const showEditor = () => {
@@ -317,7 +343,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: {
@@ -372,21 +398,34 @@
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
ocrManager.clear();
if (!sharedLink) {
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
if (sharedLink) {
return;
}
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
};
$effect(() => {
// 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.current.id === lastCursor?.current.id) {
return;
}
if (lastCursor) {
// After navigation completes, reconcile preloads with full state information
updatePreloadsAfterNavigation(lastCursor, cursor);
}
lastCursor = cursor;
});
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
@@ -496,15 +535,7 @@
<!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
<PhotoViewer bind:zoomToggle bind:copyImage cursor={{ ...cursor, current: previewStackedAsset! }} {sharedLink} />
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
assetId={previewStackedAsset!.id}
@@ -534,15 +565,7 @@
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/>
<PhotoViewer bind:zoomToggle bind:copyImage {cursor} {sharedLink} />
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
assetId={asset.id}
@@ -590,7 +613,7 @@
class="row-start-1 row-span-4 w-90 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}

View File

@@ -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,14 +82,43 @@
);
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;
return;
}
if (asset.id !== previousId) {
showEditFaces = false;
previousId = asset.id;
if (asset.id === previousId) {
return;
}
showEditFaces = false;
previousId = asset.id;
});
const getMegapixel = (width: number, height: number): number | undefined => {

View File

@@ -1,51 +1,40 @@
<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 { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
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, 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;
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,
sharedLink,
onReady,
copyImage = $bindable(),
zoomToggle = $bindable(),
}: Props = $props();
@@ -53,20 +42,6 @@
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,
});
onDestroy(() => {
$boundingBoxesArray = [];
});
@@ -115,29 +90,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);
}
});
@@ -155,35 +112,11 @@
}
};
const onload = () => {
imageLoaded = true;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
imageError = imageLoaded = true;
};
onDestroy(() => preloadManager.cancelPreloadUrl(imageLoaderUrl));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
);
let containerWidth = $state(0);
let containerHeight = $state(0);
let lastUrl: string | undefined;
$effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) {
untrack(() => {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
});
}
lastUrl = imageLoaderUrl;
const container = $derived({
width: containerWidth,
height: containerHeight,
});
</script>
@@ -193,49 +126,28 @@
{ shortcut: { key: 's' }, onShortcut: onPlaySlideshow, preventDefault: true },
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ 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 }}
>
{#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"
/>
<AdaptiveImage
{asset}
{sharedLink}
{container}
zoomDisabled={isOcrActive}
imageClass={$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook]}
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
onImageReady={() => onReady?.()}
onError={() => onReady?.()}
bind:imgElement={$photoViewerImgElement}
>
{#snippet overlays()}
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
<div
@@ -247,23 +159,10 @@
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{/snippet}
</AdaptiveImage>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#broken-asset,
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { imageLoader } from '$lib/actions/image-loader.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
import type { ClassValue } from 'svelte/elements';
interface Props {
@@ -54,16 +53,6 @@
onComplete?.(true);
};
function mount(elem: HTMLImageElement): ActionReturn {
if (elem.complete) {
loaded = true;
onComplete?.(false);
}
return {
destroy: () => preloadManager.cancelPreloadUrl(url),
};
}
let optionalClasses = $derived(
[
curve && 'rounded-xl',
@@ -76,26 +65,28 @@
.filter(Boolean)
.join(' '),
);
let style = $derived(
`width: ${widthStyle}; height: ${heightStyle ?? ''}; filter: ${hidden ? 'grayscale(50%)' : 'none'}; opacity: ${hidden ? '0.5' : '1'};`,
);
</script>
{#if errored}
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
{:else}
<img
use:mount
onload={setLoaded}
onerror={setErrored}
style:width={widthStyle}
style:height={heightStyle}
style:filter={hidden ? 'grayscale(50%)' : 'none'}
style:opacity={hidden ? '0.5' : '1'}
src={url}
alt={loaded || errored ? altText : ''}
{title}
class={['object-cover', optionalClasses, imageClass]}
draggable="false"
loading={preload ? 'eager' : 'lazy'}
/>
<div
use:imageLoader={{
src: url,
onLoad: setLoaded,
onError: setErrored,
imgClass: ['object-cover', optionalClasses, imageClass],
style,
alt: loaded || errored ? altText : '',
draggable: false,
title,
loading: preload ? 'eager' : 'lazy',
}}
></div>
{/if}
{#if hidden}

View File

@@ -11,10 +11,12 @@
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';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
@@ -38,28 +40,27 @@
person,
}: Props = $props();
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) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getNextAsset(asset, false);
}
return asset;
}
const getAsset = (id: string) => {
return handleErrorAsync(
() => assetCacheManager.getAsset({ ...authManager.params, id }),
(error: Error) => $t('error_getting_asset_information', { values: { message: error.message } }),
);
};
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) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getPreviousAsset(asset, false);
}
return asset;
const getNextAsset = async (currentAsset: AssetResponseDto) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (!earlierTimelineAsset) {
return;
}
return getAsset(earlierTimelineAsset.id);
};
const getPreviousAsset = async (currentAsset: AssetResponseDto) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
if (!laterTimelineAsset) {
return;
}
return getAsset(laterTimelineAsset.id);
};
let assetCursor = $state<AssetCursor>({
@@ -87,10 +88,12 @@
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return { id: randomAsset.id };
if (!randomAsset) {
return;
}
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return { id: randomAsset.id };
};
const handleClose = async (asset: { id: string }) => {
@@ -180,12 +183,14 @@
};
const handleUndoDelete = async (assets: TimelineAsset[]) => {
timelineManager.upsertAssets(assets);
if (assets.length > 0) {
const restoredAsset = assets[0];
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
if (assets.length === 0) {
return;
}
const restoredAsset = assets[0];
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
};
const handleUpdateOrUpload = (asset: AssetResponseDto) => {

View File

@@ -0,0 +1,66 @@
import { getAssetUrlForKind, ImageKinds, type ImageKind } from '$lib/utils';
import { cancelImageUrl, prepareImageUrl } from '$lib/utils/sw-messaging';
import { type AssetResponseDto } from '@immich/sdk';
class ImageManager {
async preload(asset: AssetResponseDto | undefined, kind: ImageKind = 'preview', suppressError = 'true') {
if (!asset) {
return;
}
const url = getAssetUrlForKind(asset, kind);
if (!url) {
return;
}
const promise = this.#executePreloadAndWait(url);
if (suppressError) {
return promise.catch(() => void 0);
}
return promise;
}
async prepareImageUrl(url: string | undefined) {
if (!url) {
return;
}
return this.#prepareImageUrl(url);
}
// Prepare a url for cancelation - call this before requesting an image in order to be able to cancel it
async #prepareImageUrl(url: string) {
await prepareImageUrl(url);
}
async #executePreloadAndWait(url: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const img = new Image();
img.addEventListener('load', () => resolve());
img.addEventListener('error', () => reject(new Error(`Failed to preload image: ${url}`)));
img.src = url;
});
}
cancel(asset: AssetResponseDto | undefined, kind: ImageKind | 'all' = 'preview') {
if (!asset) {
return;
}
const kinds = kind === 'all' ? (Object.keys(ImageKinds) as ImageKind[]) : [kind];
for (const k of kinds) {
const url = getAssetUrlForKind(asset, k);
if (url) {
cancelImageUrl(url);
}
}
}
cancelPreloadUrl(url: string | undefined) {
if (url) {
cancelImageUrl(url);
}
}
}
export const imageManager = new ImageManager();

View File

@@ -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();

View File

@@ -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);

View File

@@ -1,4 +1,25 @@
import type { ZoomImageWheelState } from '@zoom-image/core';
import { writable } from 'svelte/store';
import { derived, 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 photoZoomTransform = derived(
photoZoomState,
($state) => `translate(${$state.currentPositionX}px,${$state.currentPositionY}px) scale(${$state.currentZoom})`,
);
export const resetZoomState = () => {
photoZoomState.set({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
};

View File

@@ -31,6 +31,15 @@ import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, md
import { init, register, t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
export const ImageKinds = {
thumbnail: true,
preview: true,
fullsize: true,
original: true,
} as const;
export type ImageKind = keyof typeof ImageKinds;
interface DownloadRequestOptions<T = unknown> {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
url: string;
@@ -195,6 +204,23 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
type AssetUrlOptions = { id: string; cacheKey?: string | null; edited?: boolean };
export const getAssetUrlForKind = (asset: AssetResponseDto, kind: ImageKind) => {
switch (kind) {
case 'preview': {
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey: asset.thumbhash });
}
case 'thumbnail': {
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash });
}
case 'fullsize': {
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash });
}
case 'original': {
return getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash });
}
}
};
export const getAssetUrl = ({
asset,
sharedLink,

View File

@@ -0,0 +1,150 @@
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { getAssetUrl, getAssetUrlForKind } from '$lib/utils';
import { 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 {
previewUrl?: string;
thumbnailUrl?: string;
originalUrl?: string;
quality: ImageQuality;
hasError: boolean;
}
enum ImageStatus {
Unloaded = 'Unloaded',
Success = 'Success',
Error = 'Error',
}
/**
* 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,
thumbnailUrl: undefined,
});
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;
private thumbnailImage = ImageStatus.Unloaded;
private previewImage = ImageStatus.Unloaded;
private originalImage = ImageStatus.Unloaded;
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 = getAssetUrlForKind(asset, 'thumbnail');
this.previewUrl = getAssetUrl({ asset, sharedLink });
this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true });
this.state.thumbnailUrl = this.thumbnailUrl;
}
get adaptiveLoaderState(): ImageLoaderState {
return this.state;
}
onThumbnailStart() {
this.state.quality = 'loading-thumbnail';
}
onThumbnailLoad() {
this.state.quality = 'thumbnail';
this.thumbnailImage = ImageStatus.Success;
this.onImageReady?.();
this.triggerMainImage();
}
onThumbnailError() {
this.state.thumbnailUrl = undefined;
this.thumbnailImage = ImageStatus.Error;
this.triggerMainImage();
}
triggerMainImage() {
const wantsOriginal = this.currentZoomFn?.() > 1;
return wantsOriginal ? this.triggerOriginal() : this.triggerPreview();
}
triggerPreview() {
if (!this.previewUrl) {
// no preview, try original?
this.triggerOriginal();
return false;
}
this.state.previewUrl = this.previewUrl;
}
onPreviewStart() {
this.state.quality = 'loading-preview';
}
onPreviewLoad() {
this.state.quality = 'preview';
this.previewImage = ImageStatus.Success;
this.onImageReady?.();
}
onPreviewError() {
this.previewImage = ImageStatus.Error;
this.state.previewUrl = undefined;
// TODO: maybe try original, but only if preview's error isnt due to cancelation
}
triggerOriginal() {
if (!this.originalUrl) {
this.onError?.();
return false;
}
this.state.originalUrl = this.originalUrl;
}
onOriginalStart() {
this.state.quality = 'loading-original';
}
onOriginalLoad() {
this.state.quality = 'original';
this.originalImage = ImageStatus.Success;
this.onImageReady?.();
}
onOriginalError() {
this.originalImage = ImageStatus.Error;
this.state.originalUrl = undefined;
}
destroy(): void {
imageManager.cancelPreloadUrl(this.thumbnailUrl);
imageManager.cancelPreloadUrl(this.previewUrl);
imageManager.cancelPreloadUrl(this.originalUrl);
}
}

View File

@@ -19,12 +19,17 @@ export function getServerErrorMessage(error: unknown) {
return data?.message || error.message;
}
export function handleError(error: unknown, message: string) {
if ((error as Error)?.name === 'AbortError') {
export function standardizeError(error: unknown) {
return error instanceof Error ? error : new Error(String(error));
}
export function handleError(error: unknown, localizedMessage: string) {
const standardizedError = standardizeError(error);
if (standardizedError.name === 'AbortError') {
return;
}
console.error(`[handleError]: ${message}`, error, (error as Error)?.stack);
console.error(`[handleError]: ${standardizedError}`, error, standardizedError.stack);
try {
let serverMessage = getServerErrorMessage(error);
@@ -32,13 +37,25 @@ export function handleError(error: unknown, message: string) {
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
}
const errorMessage = serverMessage || message;
const errorMessage = serverMessage || localizedMessage;
toastManager.danger(errorMessage);
return errorMessage;
} catch (error) {
console.error(error);
return message;
return localizedMessage;
}
}
export async function handleErrorAsync<T>(
fn: () => Promise<T>,
messageGenerator: (error: Error) => string,
): Promise<T | undefined> {
try {
return await fn();
} catch (error: unknown) {
handleError(error, messageGenerator(standardizeError(error)));
return undefined;
}
}

View File

@@ -1,3 +1,5 @@
import { handleError, standardizeError } 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
@@ -51,10 +53,12 @@ export class InvocationTracker {
return this.invocationsStarted !== this.invocationsEnded;
}
async invoke<T>(invocable: () => Promise<T>) {
async invoke<T>(invocable: () => Promise<T>, messageGenerator: (error: Error) => string) {
const invocation = this.startInvocation();
try {
return await invocable();
} catch (error: unknown) {
handleError(error, messageGenerator(standardizeError(error)));
} finally {
invocation.endInvocation();
}

View File

@@ -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,
};
};

View File

@@ -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;
@@ -93,7 +93,7 @@ export const zoomImageToBase64 = async (
image = img;
}
if (image === null) {
if (!image) {
return null;
}
const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face;
@@ -121,11 +121,9 @@ export const zoomImageToBase64 = async (
canvas.height = faceHeight;
const context = canvas.getContext('2d');
if (context) {
context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
return canvas.toDataURL();
} else {
if (!context) {
return null;
}
context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
return canvas.toDataURL();
};