mirror of
https://github.com/immich-app/immich.git
synced 2026-01-22 01:18:54 -08:00
feat: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte
preload thumbs and previews
This commit is contained in:
@@ -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!);
|
||||
});
|
||||
});
|
||||
|
||||
196
web/src/lib/actions/image-loader.svelte.ts
Normal file
196
web/src/lib/actions/image-loader.svelte.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
/**
|
||||
* 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 ImageLoaderProperties {
|
||||
imgClass?: ClassValue;
|
||||
alt?: string;
|
||||
draggable?: boolean;
|
||||
role?: string;
|
||||
style?: string;
|
||||
title?: string | null;
|
||||
loading?: 'lazy' | 'eager';
|
||||
dataAttributes?: Record<string, string>;
|
||||
}
|
||||
export interface ImageSourceProperty {
|
||||
src: string | undefined;
|
||||
}
|
||||
export interface ImageLoaderCallbacks {
|
||||
onStart?: () => void;
|
||||
onLoad?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
onElementCreated?: (element: HTMLImageElement) => void;
|
||||
}
|
||||
|
||||
const updateImageAttributes = (img: HTMLImageElement, params: ImageLoaderProperties) => {
|
||||
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 cleanupImageElement = (
|
||||
imgElement: HTMLImageElement,
|
||||
currentSrc: string | undefined,
|
||||
handleLoad: () => void,
|
||||
handleError: () => void,
|
||||
) => {
|
||||
cancelImageUrl(currentSrc);
|
||||
if (imgElement) {
|
||||
imgElement.removeEventListener('load', handleLoad);
|
||||
imgElement.removeEventListener('error', handleError);
|
||||
imgElement.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const createImageElement = (
|
||||
src: string | undefined,
|
||||
properties: ImageLoaderProperties,
|
||||
onLoad: () => void,
|
||||
onError: () => void,
|
||||
onStart?: () => void,
|
||||
onElementCreated?: (imgElement: HTMLImageElement) => void,
|
||||
) => {
|
||||
if (!src) {
|
||||
return undefined;
|
||||
}
|
||||
const img = document.createElement('img');
|
||||
updateImageAttributes(img, properties);
|
||||
|
||||
img.addEventListener('load', onLoad);
|
||||
img.addEventListener('error', onError);
|
||||
|
||||
onStart?.();
|
||||
|
||||
if (src) {
|
||||
img.src = src;
|
||||
onElementCreated?.(img);
|
||||
}
|
||||
|
||||
return img;
|
||||
};
|
||||
|
||||
export function loadImage(
|
||||
src: string,
|
||||
properties: ImageLoaderProperties,
|
||||
onLoad: () => void,
|
||||
onError: () => void,
|
||||
onStart?: () => void,
|
||||
) {
|
||||
const img = createImageElement(src, properties, onLoad, onError, onStart);
|
||||
if (!img) {
|
||||
return () => void 0;
|
||||
}
|
||||
return () => cleanupImageElement(img, src, onLoad, onError);
|
||||
}
|
||||
|
||||
export type LoadImageFunction = typeof loadImage;
|
||||
|
||||
/**
|
||||
* 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: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks,
|
||||
) {
|
||||
let currentSrc = params.src;
|
||||
let currentCallbacks = params;
|
||||
let imgElement: HTMLImageElement | undefined = undefined;
|
||||
|
||||
const handleLoad = () => {
|
||||
currentCallbacks.onLoad?.();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
currentCallbacks.onError?.(new Error(`Failed to load image: ${currentSrc}`));
|
||||
};
|
||||
|
||||
const handleElementCreated = (img: HTMLImageElement) => {
|
||||
if (img) {
|
||||
node.append(img);
|
||||
currentCallbacks.onElementCreated?.(img);
|
||||
}
|
||||
};
|
||||
|
||||
const createImage = () => {
|
||||
imgElement = createImageElement(currentSrc, params, handleLoad, handleError, params.onStart, handleElementCreated);
|
||||
};
|
||||
createImage();
|
||||
|
||||
return {
|
||||
update(newParams: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks) {
|
||||
// If src changed, recreate the image element
|
||||
if (newParams.src !== currentSrc) {
|
||||
cleanupImageElement(imgElement!, currentSrc, handleLoad, handleError);
|
||||
|
||||
currentSrc = newParams.src;
|
||||
currentCallbacks = newParams;
|
||||
|
||||
createImage();
|
||||
return;
|
||||
}
|
||||
|
||||
currentCallbacks = newParams;
|
||||
|
||||
if (!imgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateImageAttributes(imgElement, newParams);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (imgElement) {
|
||||
cleanupImageElement(imgElement, currentSrc, handleLoad, handleError);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
246
web/src/lib/components/asset-viewer/adaptive-image.svelte
Normal file
246
web/src/lib/components/asset-viewer/adaptive-image.svelte
Normal 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'],
|
||||
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 }],
|
||||
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 }],
|
||||
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>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
import { loadImage } from '$lib/actions/image-loader.svelte';
|
||||
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||
@@ -12,7 +13,6 @@
|
||||
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 { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
@@ -21,14 +21,15 @@
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
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 { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
getStack,
|
||||
type AlbumResponseDto,
|
||||
@@ -95,7 +96,6 @@
|
||||
stopProgress: stopSlideshowProgress,
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowTransition,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
@@ -103,12 +103,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);
|
||||
@@ -123,91 +122,138 @@
|
||||
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(() => {
|
||||
imageManager.preload(stack?.assets[1]);
|
||||
});
|
||||
if (stack?.assets[1]) {
|
||||
untrack(() => {
|
||||
const loader = new AdaptiveImageLoader(stack!.assets[1], undefined, undefined, loadImage);
|
||||
loader.start();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
destroyNextPreloader();
|
||||
destroyPreviousPreloader();
|
||||
});
|
||||
|
||||
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();
|
||||
let nextPreloader: AdaptiveImageLoader | undefined;
|
||||
let previousPreloader: AdaptiveImageLoader | undefined;
|
||||
|
||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||
const startPreloader = (asset: AssetResponseDto | undefined) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const loader = new AdaptiveImageLoader(asset, undefined, undefined, loadImage);
|
||||
loader.start();
|
||||
return loader;
|
||||
};
|
||||
|
||||
const destroyPreviousPreloader = () => {
|
||||
previousPreloader?.destroy();
|
||||
previousPreloader = undefined;
|
||||
};
|
||||
|
||||
const destroyNextPreloader = () => {
|
||||
nextPreloader?.destroy();
|
||||
nextPreloader = undefined;
|
||||
};
|
||||
|
||||
const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => {
|
||||
if (direction === 'next') {
|
||||
destroyPreviousPreloader();
|
||||
return;
|
||||
}
|
||||
destroyNextPreloader();
|
||||
};
|
||||
|
||||
const updatePreloadsAfterNavigation = (oldCursor: AssetCursor, newCursor: AssetCursor) => {
|
||||
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
|
||||
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
|
||||
|
||||
const shouldDestroyPrevious = movedForward || !movedBackward;
|
||||
const shouldDestroyNext = movedBackward || !movedForward;
|
||||
|
||||
if (shouldDestroyPrevious) {
|
||||
destroyPreviousPreloader();
|
||||
}
|
||||
|
||||
if (shouldDestroyNext) {
|
||||
destroyNextPreloader();
|
||||
}
|
||||
|
||||
if (movedForward) {
|
||||
nextPreloader = startPreloader(newCursor.nextAsset);
|
||||
} else if (movedBackward) {
|
||||
previousPreloader = startPreloader(newCursor.previousAsset);
|
||||
} else {
|
||||
// Non-adjacent navigation (e.g., slideshow random)
|
||||
previousPreloader = startPreloader(newCursor.previousAsset);
|
||||
nextPreloader = startPreloader(newCursor.nextAsset);
|
||||
}
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
@@ -216,12 +262,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
e?.stopPropagation();
|
||||
imageManager.cancel(asset);
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelPreloadsBeforeNavigation(order);
|
||||
|
||||
void tracker.invoke(async () => {
|
||||
let hasNext = false;
|
||||
|
||||
@@ -239,12 +285,14 @@
|
||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
}, $t('error_while_navigating'));
|
||||
};
|
||||
@@ -304,7 +352,12 @@
|
||||
const handleAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case AssetAction.ADD_TO_ALBUM: {
|
||||
await handleGetAllAlbums();
|
||||
eventManager.emit('AlbumAddAssets');
|
||||
break;
|
||||
}
|
||||
case AssetAction.DELETE:
|
||||
case AssetAction.TRASH: {
|
||||
eventManager.emit('AssetsDelete', [asset.id]);
|
||||
break;
|
||||
}
|
||||
case AssetAction.DELETE:
|
||||
@@ -364,21 +417,42 @@
|
||||
|
||||
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()));
|
||||
imageManager.preload(cursor.nextAsset);
|
||||
imageManager.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);
|
||||
}
|
||||
if (!lastCursor && cursor) {
|
||||
// "first time" load, start preloads
|
||||
if (cursor.nextAsset) {
|
||||
nextPreloader = startPreloader(cursor.nextAsset);
|
||||
}
|
||||
if (cursor.previousAsset) {
|
||||
previousPreloader = startPreloader(cursor.previousAsset);
|
||||
}
|
||||
}
|
||||
lastCursor = cursor;
|
||||
});
|
||||
|
||||
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
|
||||
@@ -488,15 +562,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}
|
||||
@@ -526,15 +592,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}
|
||||
@@ -582,7 +640,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}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { 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 { Route } from '$lib/route';
|
||||
@@ -17,9 +18,16 @@
|
||||
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 { 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, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
@@ -34,6 +42,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';
|
||||
@@ -43,11 +52,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 previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
|
||||
|
||||
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 => {
|
||||
|
||||
@@ -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 { imageManager } from '$lib/managers/ImageManager.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(() => imageManager.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>
|
||||
|
||||
@@ -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 { imageManager } from '$lib/managers/ImageManager.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: () => imageManager.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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -226,13 +226,10 @@ export const getAssetUrl = ({
|
||||
sharedLink,
|
||||
forceOriginal = false,
|
||||
}: {
|
||||
asset: AssetResponseDto | undefined;
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
forceOriginal?: boolean;
|
||||
}) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const id = asset.id;
|
||||
const cacheKey = asset.thumbhash;
|
||||
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
|
||||
|
||||
202
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
202
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
|
||||
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;
|
||||
thumbnailImage: ImageStatus;
|
||||
previewImage: ImageStatus;
|
||||
originalImage: ImageStatus;
|
||||
}
|
||||
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,
|
||||
thumbnailImage: ImageStatus.Unloaded,
|
||||
previewImage: ImageStatus.Unloaded,
|
||||
originalImage: ImageStatus.Unloaded,
|
||||
});
|
||||
|
||||
private readonly currentZoomFn?: () => number;
|
||||
private readonly onImageReady?: () => void;
|
||||
private readonly onError?: () => void;
|
||||
private readonly imageLoader?: LoadImageFunction;
|
||||
private readonly destroyFunctions: (() => void)[] = [];
|
||||
readonly thumbnailUrl: string;
|
||||
readonly previewUrl: string;
|
||||
readonly originalUrl: string;
|
||||
asset: AssetResponseDto;
|
||||
constructor(
|
||||
asset: AssetResponseDto,
|
||||
sharedLink: SharedLinkResponseDto | undefined,
|
||||
callbacks?: {
|
||||
currentZoomFn: () => number;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
},
|
||||
imageLoader?: LoadImageFunction,
|
||||
) {
|
||||
this.asset = asset;
|
||||
this.currentZoomFn = callbacks?.currentZoomFn;
|
||||
this.onImageReady = callbacks?.onImageReady;
|
||||
this.onError = callbacks?.onError;
|
||||
this.imageLoader = imageLoader;
|
||||
|
||||
this.thumbnailUrl = getAssetUrlForKind(asset, 'thumbnail');
|
||||
this.previewUrl = getAssetUrl({ asset, sharedLink });
|
||||
this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true });
|
||||
this.state.thumbnailUrl = this.thumbnailUrl;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.imageLoader) {
|
||||
throw new Error('Start requires imageLoader to be specified');
|
||||
}
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.thumbnailUrl,
|
||||
{},
|
||||
() => this.onThumbnailLoad(),
|
||||
() => this.onThumbnailError(),
|
||||
() => this.onThumbnailStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get adaptiveLoaderState(): ImageLoaderState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
onThumbnailStart() {
|
||||
this.state.quality = 'loading-thumbnail';
|
||||
}
|
||||
|
||||
onThumbnailLoad() {
|
||||
this.state.quality = 'thumbnail';
|
||||
this.state.thumbnailImage = ImageStatus.Success;
|
||||
this.onImageReady?.();
|
||||
this.triggerMainImage();
|
||||
}
|
||||
|
||||
onThumbnailError() {
|
||||
this.state.thumbnailUrl = undefined;
|
||||
this.state.thumbnailImage = ImageStatus.Error;
|
||||
this.triggerMainImage();
|
||||
}
|
||||
|
||||
triggerMainImage() {
|
||||
const wantsOriginal = (this.currentZoomFn?.() ?? 1) > 1;
|
||||
return wantsOriginal ? this.triggerOriginal() : this.triggerPreview();
|
||||
}
|
||||
|
||||
triggerPreview() {
|
||||
if (!this.previewUrl) {
|
||||
// no preview, try original?
|
||||
this.triggerOriginal();
|
||||
return false;
|
||||
}
|
||||
this.state.previewUrl = this.previewUrl;
|
||||
if (this.imageLoader) {
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.previewUrl,
|
||||
{},
|
||||
() => this.onPreviewLoad(),
|
||||
() => this.onPreviewError(),
|
||||
() => this.onPreviewStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onPreviewStart() {
|
||||
this.state.quality = 'loading-preview';
|
||||
}
|
||||
|
||||
onPreviewLoad() {
|
||||
this.state.quality = 'preview';
|
||||
this.state.previewImage = ImageStatus.Success;
|
||||
this.onImageReady?.();
|
||||
}
|
||||
|
||||
onPreviewError() {
|
||||
this.state.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;
|
||||
|
||||
if (this.imageLoader) {
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.originalUrl,
|
||||
{},
|
||||
() => this.onOriginalLoad(),
|
||||
() => this.onOriginalError(),
|
||||
() => this.onOriginalStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onOriginalStart() {
|
||||
this.state.quality = 'loading-original';
|
||||
}
|
||||
|
||||
onOriginalLoad() {
|
||||
this.state.quality = 'original';
|
||||
this.state.originalImage = ImageStatus.Success;
|
||||
this.onImageReady?.();
|
||||
}
|
||||
|
||||
onOriginalError() {
|
||||
this.state.originalImage = ImageStatus.Error;
|
||||
this.state.originalUrl = undefined;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.imageLoader) {
|
||||
for (const destroy of this.destroyFunctions) {
|
||||
destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
imageManager.cancelPreloadUrl(this.thumbnailUrl);
|
||||
imageManager.cancelPreloadUrl(this.previewUrl);
|
||||
imageManager.cancelPreloadUrl(this.originalUrl);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user