feat: adaptive progressive image loading for photo viewer

fully rework/condense/simplify AdaptiveImage.svelte
This commit is contained in:
midzelis
2026-01-15 20:34:21 +00:00
parent 6e9a425592
commit e21c99c420
25 changed files with 1086 additions and 481 deletions
+44 -19
View File
@@ -1,14 +1,13 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { Page, expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import type { Socket } from 'socket.io-client';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => { test.describe('Photo Viewer', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetMediaResponseDto; let asset: AssetMediaResponseDto;
let rawAsset: AssetMediaResponseDto; let rawAsset: AssetMediaResponseDto;
let websocket: Socket;
test.beforeAll(async () => { test.beforeAll(async () => {
utils.initSdk(); utils.initSdk();
@@ -16,6 +15,11 @@ test.describe('Photo Viewer', () => {
admin = await utils.adminSetup(); admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken); asset = await utils.createAsset(admin.accessToken);
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } }); rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
websocket = await utils.connectWebsocket(admin.accessToken);
});
test.afterAll(() => {
utils.disconnectWebsocket(websocket);
}); });
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }) => {
@@ -26,31 +30,52 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => { test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox(); const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
expect(box).toBeTruthy(); await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2); const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1); await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
await originalResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /original/);
}); });
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => { test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`); await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox(); const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
expect(box).toBeTruthy(); await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2); const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1); await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
await fullsizeResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /fullsize/);
}); });
test('reloads photo when checksum changes', async ({ page }) => { test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src'); const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const initialSrc = await thumbnail.getAttribute('src');
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await utils.replaceAsset(admin.accessToken, asset.id); await utils.replaceAsset(admin.accessToken, asset.id);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc); await websocketEvent;
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).not.toHaveAttribute('src', initialSrc!);
}); });
}); });
+2 -2
View File
@@ -99,13 +99,13 @@ export const setupTimelineMockApiRoutes = async (
}); });
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => { await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/; const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail|fullsize)/;
const match = request.url().match(pattern); const match = request.url().match(pattern);
if (!match?.groups) { if (!match?.groups) {
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`); throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
} }
if (match.groups.size === 'preview') { if (match.groups.size === 'preview' || match.groups.size === 'fullsize') {
if (!route.request().serviceWorker()) { if (!route.request().serviceWorker()) {
return route.continue(); return route.continue();
} }
@@ -73,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
await page.goto(`/photos/${fixture.primaryAsset.id}`); await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer'); await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]'); const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]');
await expect(viewerBrokenAsset).toBeVisible(); await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
@@ -6,6 +6,7 @@ import {
generateTimelineData, generateTimelineData,
TimelineAssetConfig, TimelineAssetConfig,
TimelineData, TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline'; } from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network'; import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network'; import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -53,7 +54,7 @@ test.describe('search gallery-viewer', () => {
assets: { assets: {
total: searchAssets.length, total: searchAssets.length,
count: searchAssets.length, count: searchAssets.length,
items: searchAssets, items: searchAssets.map((asset) => toAssetResponseDto(asset)),
facets: [], facets: [],
nextPage: null, nextPage: null,
}, },
+7 -8
View File
@@ -163,13 +163,11 @@ export const assetViewerUtils = {
return page.locator('#immich-asset-viewer'); return page.locator('#immich-asset-viewer');
}, },
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) { async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
const previewUrl = `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true`;
await page await page
.locator( .getByTestId('preview')
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`, .and(page.locator(`[src="${previewUrl}"]`))
) .or(page.locator(`video[poster="${previewUrl}"]`))
.or(
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
)
.waitFor(); .waitFor();
}, },
async expectActiveAssetToBe(page: Page, assetId: string) { async expectActiveAssetToBe(page: Page, assetId: string) {
@@ -215,8 +213,9 @@ export const pageUtils = {
await page.getByText('Confirm').click(); await page.getByText('Confirm').click();
}, },
async selectDay(page: Page, day: string) { async selectDay(page: Page, day: string) {
await page.getByTitle(day).hover(); const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]');
await page.locator('[data-group] .w-8').click(); await section.hover();
await section.locator('.w-8').click();
}, },
async pauseTestDebug() { async pauseTestDebug() {
console.log('NOTE: pausing test indefinately for debug'); console.log('NOTE: pausing test indefinately for debug');
+40 -29
View File
@@ -177,40 +177,51 @@ export const utils = {
}, },
resetDatabase: async (tables?: string[]) => { resetDatabase: async (tables?: string[]) => {
try { client = await utils.connectDatabase();
client = await utils.connectDatabase();
tables = tables || [ tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex // TODO e2e test for deleting a stack, since it is quite complex
'stack', 'stack',
'library', 'library',
'shared_link', 'shared_link',
'person', 'person',
'album', 'album',
'asset', 'asset',
'asset_face', 'asset_face',
'activity', 'activity',
'api_key', 'api_key',
'session', 'session',
'user', 'user',
'system_metadata', 'system_metadata',
'tag', 'tag',
]; ];
const sql: string[] = []; const truncateTables = tables.filter((table) => table !== 'system_metadata');
const sql: string[] = [];
for (const table of tables) { if (truncateTables.length > 0) {
if (table === 'system_metadata') { sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`);
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); }
} else {
sql.push(`DELETE FROM "${table}" CASCADE;`); if (tables.includes('system_metadata')) {
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
}
const query = sql.join('\n');
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await client.query(query);
return;
} catch (error: any) {
if (error?.code === '40P01' && attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
continue;
} }
console.error('Failed to reset database', error);
throw error;
} }
await client.query(sql.join('\n'));
} catch (error) {
console.error('Failed to reset database', error);
throw error;
} }
}, },
@@ -0,0 +1,25 @@
import { cancelImageUrl } from '$lib/utils/sw-messaging';
export function loadImage(src: string, onLoad: () => void, onError: () => void, onStart?: () => void) {
let destroyed = false;
const handleLoad = () => !destroyed && onLoad();
const handleError = () => !destroyed && onError();
const img = document.createElement('img');
img.addEventListener('load', handleLoad);
img.addEventListener('error', handleError);
onStart?.();
img.src = src;
return () => {
destroyed = true;
img.removeEventListener('load', handleLoad);
img.removeEventListener('error', handleError);
cancelImageUrl(src);
img.remove();
};
}
export type LoadImageFunction = typeof loadImage;
+10 -3
View File
@@ -1,8 +1,12 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core'; import { createZoomImageWheel } from '@zoom-image/core';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean; zoomTarget?: HTMLElement }) => {
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState }); const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
initialState: assetViewerManager.zoomState,
zoomTarget: options?.zoomTarget,
});
const unsubscribes = [ const unsubscribes = [
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }), assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
@@ -20,8 +24,11 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
node.style.overflow = 'visible'; node.style.overflow = 'visible';
return { return {
update(newOptions?: { disabled?: boolean }) { update(newOptions?: { disabled?: boolean; zoomTarget?: HTMLElement }) {
options = newOptions; options = newOptions;
if (newOptions?.zoomTarget !== undefined) {
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
}
}, },
destroy() { destroy() {
for (const unsubscribe of unsubscribes) { for (const unsubscribe of unsubscribes) {
+212
View File
@@ -0,0 +1,212 @@
<script lang="ts">
import { thumbhash } from '$lib/actions/thumbhash';
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import ImageLayer from '$lib/components/ImageLayer.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetUrls } from '$lib/utils';
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { untrack, type Snippet } from 'svelte';
type Props = {
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
objectFit?: 'contain' | 'cover';
container: {
width: number;
height: number;
};
onUrlChange?: (url: string) => void;
onImageReady?: () => void;
onError?: () => void;
ref?: HTMLDivElement;
imgRef?: HTMLImageElement;
backdrop?: Snippet;
overlays?: Snippet;
};
let {
ref = $bindable(),
// eslint-disable-next-line no-useless-assignment
imgRef = $bindable(),
asset,
sharedLink,
objectFit = 'contain',
container,
onUrlChange,
onImageReady,
onError,
backdrop,
overlays,
}: Props = $props();
const afterThumbnail = (loader: AdaptiveImageLoader) => {
if (assetViewerManager.zoom > 1) {
loader.trigger('original');
} else {
loader.trigger('preview');
}
};
const buildQualityList = () => {
const assetUrls = getAssetUrls(asset, sharedLink);
const qualityList: QualityList = [
{
quality: 'thumbnail',
url: assetUrls.thumbnail,
checkCanceled: false,
onAfterLoad: afterThumbnail,
onAfterError: afterThumbnail,
},
{
quality: 'preview',
url: assetUrls.preview,
checkCanceled: true,
onAfterError: (loader) => loader.trigger('original'),
},
{ quality: 'original', url: assetUrls.original, checkCanceled: true },
];
return qualityList;
};
const loaderKey = $derived(`${asset.id}:${asset.thumbhash}:${sharedLink?.id}`);
const adaptiveImageLoader = $derived.by(() => {
void loaderKey;
return untrack(
() =>
new AdaptiveImageLoader(asset.id, buildQualityList(), {
onImageReady,
onError,
onUrlChange,
}),
);
});
$effect.pre(() => {
const loader = adaptiveImageLoader;
untrack(() => assetViewerManager.resetZoomState());
return () => loader.destroy();
});
const imageDimensions = $derived.by(() => {
const { width, height } = asset;
if (width && width > 0 && height && height > 0) {
return { width, height };
}
return { width: 1, height: 1 };
});
const { width, height, left, top } = $derived.by(() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
return {
width: width + 'px',
height: height + 'px',
left: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
};
});
const { status } = $derived(adaptiveImageLoader);
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
const show = $derived.by(() => {
const { quality, started, hasError, urls } = status;
return {
alphaBackground: !hasError && started,
spinner: !asset.thumbhash && !started,
brokenAsset: hasError,
thumbhash: quality.thumbnail !== 'success' && quality.preview !== 'success' && quality.original !== 'success',
thumbnail: quality.thumbnail !== 'error' && quality.preview !== 'success' && quality.original !== 'success',
preview: quality.preview !== 'error' && quality.original !== 'success',
original: quality.original !== 'error' && urls.original !== undefined,
};
});
$effect(() => {
if (assetViewerManager.zoom > 1 && status.quality.preview === 'success' && status.quality.original !== 'success') {
untrack(() => void adaptiveImageLoader.trigger('original'));
}
});
let thumbnailElement = $state<HTMLImageElement>();
let previewElement = $state<HTMLImageElement>();
let originalElement = $state<HTMLImageElement>();
$effect(() => {
const quality = status.quality;
imgRef =
(quality.original === 'success' ? originalElement : undefined) ??
(quality.preview === 'success' ? previewElement : undefined) ??
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
});
</script>
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
{@render backdrop?.()}
<div class="absolute" style:left style:top style:width style:height>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
{width}
{height}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="text-xl h-full w-full absolute" />
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{/if}
</div>
</div>
@@ -0,0 +1,11 @@
<script lang="ts">
import type { ClassValue } from 'svelte/elements';
interface Props {
class?: ClassValue;
}
let { class: className = '' }: Props = $props();
</script>
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { LoadingSpinner } from '@immich/ui';
</script>
<div class="delayed-spinner absolute flex h-full items-center justify-center">
<LoadingSpinner />
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
.delayed-spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import Image from '$lib/components/Image.svelte';
import type { AdaptiveImageLoader, ImageQuality } from '$lib/utils/adaptive-image-loader.svelte';
import type { Snippet } from 'svelte';
type Props = {
adaptiveImageLoader: AdaptiveImageLoader;
quality: ImageQuality;
src: string | undefined;
alt?: string;
role?: string;
ref?: HTMLImageElement;
width: string;
height: string;
overlays?: Snippet;
};
let {
adaptiveImageLoader,
quality,
src,
alt = '',
role,
ref = $bindable(),
width,
height,
overlays,
}: Props = $props();
</script>
{#key adaptiveImageLoader}
<div class="absolute top-0" style:width style:height>
<Image
{src}
onStart={() => adaptiveImageLoader.onStart(quality)}
onLoad={() => adaptiveImageLoader.onLoad(quality)}
onError={() => adaptiveImageLoader.onError(quality)}
bind:ref
class="h-full w-full"
{alt}
{role}
draggable={false}
data-testid={quality}
/>
{@render overlays?.()}
</div>
{/key}
@@ -0,0 +1,103 @@
import { loadImage } from '$lib/actions/image-loader.svelte';
import { getAssetUrls } from '$lib/utils';
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
import type { AssetResponseDto } from '@immich/sdk';
type AssetCursor = {
current: AssetResponseDto;
nextAsset?: AssetResponseDto;
previousAsset?: AssetResponseDto;
};
export class PreloadManager {
private nextPreloader: AdaptiveImageLoader | undefined;
private previousPreloader: AdaptiveImageLoader | undefined;
private startPreloader(asset: AssetResponseDto | undefined): AdaptiveImageLoader | undefined {
if (!asset) {
return;
}
const urls = getAssetUrls(asset);
const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview');
const qualityList: QualityList = [
{
quality: 'thumbnail',
url: urls.thumbnail,
checkCanceled: false,
onAfterLoad: afterThumbnail,
onAfterError: afterThumbnail,
},
{
quality: 'preview',
url: urls.preview,
checkCanceled: true,
onAfterError: (loader) => loader.trigger('original'),
},
{ quality: 'original', url: urls.original, checkCanceled: true },
];
const loader = new AdaptiveImageLoader(asset.id, qualityList, undefined, loadImage);
loader.start();
return loader;
}
private destroyPreviousPreloader() {
this.previousPreloader?.destroy();
this.previousPreloader = undefined;
}
private destroyNextPreloader() {
this.nextPreloader?.destroy();
this.nextPreloader = undefined;
}
cancelBeforeNavigation(direction: 'previous' | 'next') {
switch (direction) {
case 'next': {
this.destroyPreviousPreloader();
break;
}
case 'previous': {
this.destroyNextPreloader();
break;
}
}
}
updateAfterNavigation(oldCursor: AssetCursor, newCursor: AssetCursor) {
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
if (!movedBackward) {
this.destroyPreviousPreloader();
}
if (!movedForward) {
this.destroyNextPreloader();
}
if (movedForward) {
this.nextPreloader = this.startPreloader(newCursor.nextAsset);
} else if (movedBackward) {
this.previousPreloader = this.startPreloader(newCursor.previousAsset);
} else {
this.previousPreloader = this.startPreloader(newCursor.previousAsset);
this.nextPreloader = this.startPreloader(newCursor.nextAsset);
}
}
initializePreloads(cursor: AssetCursor) {
if (cursor.nextAsset) {
this.nextPreloader = this.startPreloader(cursor.nextAsset);
}
if (cursor.previousAsset) {
this.previousPreloader = this.startPreloader(cursor.previousAsset);
}
}
destroy() {
this.destroyNextPreloader();
this.destroyPreviousPreloader();
}
}
export const preloadManager = new PreloadManager();
@@ -5,15 +5,16 @@
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte';
import { AssetAction, ProjectionType } from '$lib/constants'; import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte'; import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { getAssetActions } from '$lib/services/asset.service'; import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -36,6 +37,7 @@
} from '@immich/sdk'; } from '@immich/sdk';
import { CommandPaletteDefaultProvider } from '@immich/ui'; import { CommandPaletteDefaultProvider } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import type { SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
@@ -92,20 +94,20 @@
stopProgress: stopSlideshowProgress, stopProgress: stopSlideshowProgress,
slideshowNavigation, slideshowNavigation,
slideshowState, slideshowState,
slideshowTransition,
slideshowRepeat, slideshowRepeat,
} = slideshowStore; } = slideshowStore;
const stackThumbnailSize = 60; const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65; const stackSelectedThumbnailSize = 65;
const asset = $derived(cursor.current); let stack: StackResponseDto | undefined = $state();
let selectedStackAsset: AssetResponseDto | undefined = $state();
let previewStackedAsset: AssetResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset); const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset); const previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink(); let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let fullscreenElement = $state<Element>(); let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
let stack: StackResponseDto | null = $state(null);
let playOriginalVideo = $state($alwaysLoadOriginalVideo); let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>(); let slideshowStartAssetId = $state<string>();
@@ -115,62 +117,62 @@
}; };
const refreshStack = async () => { const refreshStack = async () => {
if (authManager.isSharedLink) { if (authManager.isSharedLink || !withStacked) {
return; return;
} }
if (asset.stack) { if (!cursor.current.stack) {
stack = await getStack({ id: asset.stack.id }); stack = undefined;
selectedStackAsset = undefined;
return;
} }
if (!stack?.assets.some(({ id }) => id === asset.id)) { stack = await getStack({ id: cursor.current.stack.id });
stack = null; selectedStackAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
}
untrack(() => {
imageManager.preload(stack?.assets[1]);
});
}; };
const handleFavorite = async () => { const handleFavorite = async () => {
if (album && album.isActivityEnabled) { if (!album || !album.isActivityEnabled) {
try { return;
await activityManager.toggleLike(); }
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite')); try {
} await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
} }
}; };
onMount(() => { onMount(() => {
syncAssetViewerOpenClass(true); syncAssetViewerOpenClass(true);
unsubscribes.push( const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) {
if (value === SlideshowState.PlaySlideshow) { slideshowHistory.reset();
slideshowHistory.reset(); slideshowHistory.queue(toTimelineAsset(asset));
slideshowHistory.queue(toTimelineAsset(asset)); handlePromiseError(handlePlaySlideshow());
handlePromiseError(handlePlaySlideshow()); } else if (value === SlideshowState.StopSlideshow) {
} else if (value === SlideshowState.StopSlideshow) { handlePromiseError(handleStopSlideshow());
handlePromiseError(handleStopSlideshow()); }
} });
}),
slideshowNavigation.subscribe((value) => { const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) { if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset(); slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset)); slideshowHistory.queue(toTimelineAsset(asset));
} }
}), });
);
return () => {
slideshowStateUnsubscribe();
slideshowNavigationUnsubscribe();
};
}); });
onDestroy(() => { onDestroy(() => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
activityManager.reset(); activityManager.reset();
assetViewerManager.closeEditor(); assetViewerManager.closeEditor();
syncAssetViewerOpenClass(false); syncAssetViewerOpenClass(false);
preloadManager.destroy();
}); });
const closeViewer = () => { const closeViewer = () => {
@@ -187,8 +189,7 @@
}; };
const tracker = new InvocationTracker(); const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next') => {
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
if (!order) { if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) { if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
@@ -197,16 +198,19 @@
} }
} }
e?.stopPropagation(); preloadManager.cancelBeforeNavigation(order);
imageManager.cancel(asset);
if (tracker.isActive()) { if (tracker.isActive()) {
return; return;
} }
void tracker.invoke(async () => { void tracker.invoke(async () => {
const isShuffle =
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
let hasNext: boolean; let hasNext: boolean;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { if (isShuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) { if (!hasNext) {
const asset = await onRandom?.(); const asset = await onRandom?.();
@@ -220,17 +224,22 @@
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
} }
if ($slideshowState === SlideshowState.PlaySlideshow) { if ($slideshowState !== SlideshowState.PlaySlideshow) {
if (hasNext) { return;
$restartSlideshowProgress = true;
} else if ($slideshowRepeat && slideshowStartAssetId) {
// Loop back to starting asset
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
} }
if (hasNext) {
$restartSlideshowProgress = true;
return;
}
if ($slideshowRepeat && slideshowStartAssetId) {
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
return;
}
await handleStopSlideshow();
}, $t('error_while_navigating')); }, $t('error_while_navigating'));
}; };
@@ -274,12 +283,10 @@
} }
}; };
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? asset : undefined;
};
const handlePreAction = (action: Action) => { const handlePreAction = (action: Action) => {
preAction?.(action); preAction?.(action);
}; };
const handleAction = async (action: Action) => { const handleAction = async (action: Action) => {
switch (action.type) { switch (action.type) {
case AssetAction.DELETE: case AssetAction.DELETE:
@@ -288,7 +295,7 @@
break; break;
} }
case AssetAction.REMOVE_ASSET_FROM_STACK: { case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack; stack = action.stack ?? undefined;
if (stack) { if (stack) {
cursor.current = stack.assets[0]; cursor.current = stack.assets[0];
} }
@@ -342,27 +349,53 @@
} }
}; };
const refreshOcr = async () => {
ocrManager.clear();
if (sharedLink) {
return;
}
await ocrManager.getAssetOcr(asset.id);
};
const refresh = async () => { const refresh = async () => {
await refreshStack(); await refreshStack();
ocrManager.clear(); await refreshOcr();
if (!sharedLink) {
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
}
}; };
$effect(() => { $effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset; cursor.current;
untrack(() => handlePromiseError(refresh())); untrack(() => handlePromiseError(refresh()));
imageManager.preload(cursor.nextAsset); });
imageManager.preload(cursor.previousAsset);
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
previewStackedAsset;
untrack(() => handlePromiseError(refreshOcr()));
});
let lastCursor = $state<AssetCursor>();
$effect(() => {
if (cursor.current.id === lastCursor?.current.id) {
return;
}
if (lastCursor) {
selectedStackAsset = undefined;
previewStackedAsset = undefined;
ocrManager.showOverlay = false;
preloadManager.updateAfterNavigation(lastCursor, cursor);
}
if (!lastCursor) {
preloadManager.initializePreloads(cursor);
}
lastCursor = cursor;
}); });
const viewerKind = $derived.by(() => { const viewerKind = $derived.by(() => {
if (previewStackedAsset) { if (previewStackedAsset) {
return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
} }
if (asset.type === AssetTypeEnum.Video) { if (asset.type === AssetTypeEnum.Video) {
return 'VideoViewer'; return 'VideoViewer';
@@ -403,6 +436,24 @@
assetViewerManager.isShowDetailPanel && assetViewerManager.isShowDetailPanel &&
!assetViewerManager.isShowEditor, !assetViewerManager.isShowEditor,
); );
const onSwipe = (event: SwipeCustomEvent) => {
if (assetViewerManager.zoom > 1) {
return;
}
if (ocrManager.showOverlay) {
return;
}
if (event.detail.direction === 'left') {
navigateAsset('next');
}
if (event.detail.direction === 'right') {
navigateAsset('previous');
}
};
</script> </script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} /> <CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
@@ -448,23 +499,15 @@
</div> </div>
{/if} {/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"> <div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} /> <PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div> </div>
{/if} {/if}
<!-- Asset Viewer --> <!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full"> <div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackPhotoViewer'} {#if viewerKind === 'StackVideoViewer'}
<PhotoViewer
cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer <VideoViewer
asset={previewStackedAsset!} asset={previewStackedAsset!}
cacheKey={previewStackedAsset!.thumbhash} cacheKey={previewStackedAsset!.thumbhash}
@@ -494,13 +537,7 @@
{:else if viewerKind === 'CropArea'} {:else if viewerKind === 'CropArea'}
<CropArea {asset} /> <CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'} {:else if viewerKind === 'PhotoViewer'}
<PhotoViewer <PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/>
{:else if viewerKind === 'VideoViewer'} {:else if viewerKind === 'VideoViewer'}
<VideoViewer <VideoViewer
{asset} {asset}
@@ -535,7 +572,7 @@
{/if} {/if}
</div> </div>
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"> <div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} /> <NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div> </div>
@@ -563,10 +600,14 @@
{#if stack && withStacked && !assetViewerManager.isShowEditor} {#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets} {@const stackedAssets = stack.assets}
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none"> <div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar"> <div
role="presentation"
class="relative inline-flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
onmouseleave={() => (previewStackedAsset = undefined)}
>
{#each stackedAssets as stackedAsset (stackedAsset.id)} {#each stackedAssets as stackedAsset (stackedAsset.id)}
<div <div
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']} class={['inline-block px-1 relative transition-all pb-2']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'} style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
> >
<Thumbnail <Thumbnail
@@ -575,21 +616,19 @@
dimmed={stackedAsset.id !== asset.id} dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)} asset={toTimelineAsset(stackedAsset)}
onClick={() => { onClick={() => {
cursor.current = stackedAsset; selectedStackAsset = stackedAsset;
previewStackedAsset = undefined; previewStackedAsset = undefined;
}} }}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} onMouseEvent={({ isMouseOver }) => isMouseOver && (previewStackedAsset = stackedAsset)}
readonly readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize} thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false} showStackedIcon={false}
disableLinkMouseOver disableLinkMouseOver
/> />
{#if stackedAsset.id === asset.id} <div class="w-full flex place-items-center place-content-center">
<div class="w-full flex place-items-center place-content-center"> <div class={['w-2 h-2 rounded-full flex mt-0.5', { 'bg-white': stackedAsset.id === asset.id }]}></div>
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div> </div>
</div>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -3,7 +3,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils'; import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui'; import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -81,15 +81,20 @@
await getPeople(); await getPeople();
}); });
$effect(() => { const imageContentMetrics = $derived.by(() => {
const metrics = getContentMetrics(htmlElement); const natural = getNaturalSize(htmlElement);
const container = { width: containerWidth, height: containerHeight };
const imageBoundingBox = { const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
top: metrics.offsetY, return {
left: metrics.offsetX, contentWidth,
width: metrics.contentWidth, contentHeight,
height: metrics.contentHeight, offsetX: (containerWidth - contentWidth) / 2,
offsetY: (containerHeight - contentHeight) / 2,
}; };
});
$effect(() => {
const { offsetX, offsetY } = imageContentMetrics;
if (!canvas) { if (!canvas) {
return; return;
@@ -105,8 +110,8 @@
} }
faceRect.set({ faceRect.set({
top: imageBoundingBox.top + 200, top: offsetY + 200,
left: imageBoundingBox.left + 200, left: offsetX + 200,
}); });
faceRect.setCoords(); faceRect.setCoords();
@@ -214,13 +219,13 @@
} }
const { left, top, width, height } = faceRect.getBoundingRect(); const { left, top, width, height } = faceRect.getBoundingRect();
const metrics = getContentMetrics(htmlElement); const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
const natural = getNaturalSize(htmlElement); const natural = getNaturalSize(htmlElement);
const scaleX = natural.width / metrics.contentWidth; const scaleX = natural.width / contentWidth;
const scaleY = natural.height / metrics.contentHeight; const scaleY = natural.height / contentHeight;
const imageX = (left - metrics.offsetX) * scaleX; const imageX = (left - offsetX) * scaleX;
const imageY = (top - metrics.offsetY) * scaleY; const imageY = (top - offsetY) * scaleY;
return { return {
imageWidth: natural.width, imageWidth: natural.width,
@@ -1,66 +1,56 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import { thumbhash } from '$lib/actions/thumbhash';
import { zoomImageAction } from '$lib/actions/zoom-image'; import { zoomImageAction } from '$lib/actions/zoom-image';
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.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 OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils'; import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils'; import { getBoundingBox } from '$lib/utils/people-utils';
import { getAltText } from '$lib/utils/thumbnail-util'; import { type SharedLinkResponseDto } from '@immich/sdk';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toastManager } from '@immich/ui';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, untrack } from 'svelte'; import { onDestroy, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { AssetCursor } from './asset-viewer.svelte'; import type { AssetCursor } from './asset-viewer.svelte';
interface Props { interface Props {
cursor: AssetCursor; cursor: AssetCursor;
element?: HTMLDivElement | undefined; element?: HTMLDivElement;
haveFadeTransition?: boolean; sharedLink?: SharedLinkResponseDto;
sharedLink?: SharedLinkResponseDto | undefined; onReady?: () => void;
onPreviousAsset?: (() => void) | null; onError?: () => void;
onNextAsset?: (() => void) | null; onSwipe?: (event: SwipeCustomEvent) => void;
} }
let { let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
cursor,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore; const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current); const asset = $derived(cursor.current);
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
let visibleImageReady: boolean = $state(false); let visibleImageReady: boolean = $state(false);
let loader = $state<HTMLImageElement>(); let previousAssetId: string | undefined;
$effect.pre(() => { $effect.pre(() => {
void asset.id; const id = asset.id;
if (id === previousAssetId) {
return;
}
previousAssetId = id;
untrack(() => { untrack(() => {
assetViewerManager.resetZoomState(); assetViewerManager.resetZoomState();
visibleImageReady = false;
$boundingBoxesArray = []; $boundingBoxesArray = [];
}); });
}); });
@@ -69,25 +59,30 @@
$boundingBoxesArray = []; $boundingBoxesArray = [];
}); });
let containerWidth = $state(0);
let containerHeight = $state(0);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
const overlayMetrics = $derived.by((): ContentMetrics => { const overlayMetrics = $derived.by((): ContentMetrics => {
if (!assetViewerManager.imgRef || !visibleImageReady) { if (!assetViewerManager.imgRef || !visibleImageReady) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 }; return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
} }
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef); const natural = getNaturalSize(assetViewerManager.imgRef);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState; const scaled = scaleToFit(natural, container);
return { return {
contentWidth: contentWidth * currentZoom, contentWidth: scaled.width,
contentHeight: contentHeight * currentZoom, contentHeight: scaled.height,
offsetX: offsetX * currentZoom + currentPositionX, offsetX: 0,
offsetY: offsetY * currentZoom + currentPositionY, offsetY: 0,
}; };
}); });
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []); const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
let isOcrActive = $derived(ocrManager.showOverlay);
const onCopy = async () => { const onCopy = async () => {
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) { if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
@@ -124,29 +119,15 @@
handlePromiseError(onCopy()); handlePromiseError(onCopy());
}; };
const onSwipe = (event: SwipeCustomEvent) => { let currentPreviewUrl = $state<string>();
if (assetViewerManager.zoom > 1) {
return;
}
if (ocrManager.showOverlay) { const onUrlChange = (url: string) => {
return; currentPreviewUrl = url;
}
if (onNextAsset && event.detail.direction === 'left') {
onNextAsset();
}
if (onPreviousAsset && event.detail.direction === 'right') {
onPreviousAsset();
}
}; };
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
$effect(() => { $effect(() => {
if (imageLoaderUrl) { if (currentPreviewUrl) {
void cast(imageLoaderUrl); void cast(currentPreviewUrl);
} }
}); });
@@ -164,37 +145,11 @@
} }
}; };
const onload = () => { const blurredSlideshow = $derived(
imageLoaded = true; $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
imageError = imageLoaded = true;
};
onDestroy(() => imageManager.cancel(asset, targetImageSize));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
); );
let containerWidth = $state(0); let adaptiveImage = $state<HTMLDivElement | undefined>();
let containerHeight = $state(0);
let lastUrl: string | undefined;
$effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) {
untrack(() => {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
visibleImageReady = false;
});
}
lastUrl = imageLoaderUrl;
});
const faceToNameMap = $derived.by(() => { const faceToNameMap = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
@@ -208,29 +163,6 @@
}); });
const faces = $derived(Array.from(faceToNameMap.keys())); const faces = $derived(Array.from(faceToNameMap.keys()));
const handleImageMouseMove = (event: MouseEvent) => {
$boundingBoxesArray = [];
if (!assetViewerManager.imgRef || !element || isFaceEditMode.value || ocrManager.showOverlay) {
return;
}
const containerRect = element.getBoundingClientRect();
const mouseX = event.clientX - containerRect.left;
const mouseY = event.clientY - containerRect.top;
const faceBoxes = getBoundingBox(faces, overlayMetrics);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
}
}
};
const handleImageMouseLeave = () => {
$boundingBoxesArray = [];
};
</script> </script>
<AssetViewerEvents {onCopy} {onZoom} /> <AssetViewerEvents {onCopy} {onZoom} />
@@ -243,12 +175,7 @@
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, 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 <div
bind:this={element} bind:this={element}
class="relative h-full w-full select-none" class="relative h-full w-full select-none"
@@ -256,74 +183,75 @@
bind:clientHeight={containerHeight} bind:clientHeight={containerHeight}
role="presentation" role="presentation"
ondblclick={onZoom} ondblclick={onZoom}
onmousemove={handleImageMouseMove} use:zoomImageAction={{ disabled: isFaceEditMode.value, zoomTarget: adaptiveImage }}
onmouseleave={handleImageMouseLeave} {...useSwipe((event) => onSwipe?.(event))}
> >
{#if !imageLoaded} <AdaptiveImage
<div id="spinner" class="flex h-full items-center justify-center"> {asset}
<LoadingSpinner /> {sharedLink}
</div> {container}
{:else if !imageError} objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
<div {onUrlChange}
use:zoomImageAction={{ disabled: isOcrActive }} onImageReady={() => {
{...useSwipe(onSwipe)} visibleImageReady = true;
class="h-full w-full" onReady?.();
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }} }}
> onError={() => {
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} onError?.();
<img onReady?.();
src={imageLoaderUrl} }}
alt="" bind:imgRef={assetViewerManager.imgRef}
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg" bind:ref={adaptiveImage}
draggable="false" >
/> {#snippet backdrop()}
{#if blurredSlideshow}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw"
></canvas>
{/if} {/if}
<img {/snippet}
bind:this={assetViewerManager.imgRef} {#snippet overlays()}
src={imageLoaderUrl} {#if !isFaceEditMode.value && !ocrManager.showOverlay}
onload={() => (visibleImageReady = true)} {#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)}
alt={$getAltText(toTimelineAsset(asset))} {@const face = faces[index]}
class="h-full w-full {$slideshowState === SlideshowState.None {@const name = faceToNameMap.get(face)}
? 'object-contain' {@const isActive = $boundingBoxesArray.includes(face)}
: slideshowLookCssMapping[$slideshowLook]}" <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
draggable="false"
/>
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{#if faceToNameMap.get($boundingBoxesArray[index])}
<div <div
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg" tabindex="0"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left + role="region"
boundingbox.width}px; transform: translateX(-100%);" class={[
'absolute pointer-events-auto outline-none rounded-lg',
isActive && 'border-solid border-white border-3',
]}
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
aria-label="{$t('person')}: {name || $t('unknown')}"
onmouseenter={() => ($boundingBoxesArray = [face])}
onmouseleave={() => ($boundingBoxesArray = [])}
onfocus={() => ($boundingBoxesArray = [face])}
onblur={() => ($boundingBoxesArray = [])}
> >
{faceToNameMap.get($boundingBoxesArray[index])} {#if isActive && name}
<div
aria-hidden="true"
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.height + 4}px; right: 0;"
>
{name}
</div>
{/if}
</div> </div>
{/if} {/each}
{/each} {/if}
{#each ocrBoxes as ocrBox (ocrBox.id)} {#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} /> <OcrBoundingBox {ocrBox} />
{/each} {/each}
</div> {/snippet}
</AdaptiveImage>
{#if isFaceEditMode.value} {#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} /> <FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{/if} {/if}
</div> </div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#broken-asset,
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>
@@ -4,7 +4,7 @@
import { getAssetMediaUrl } from '$lib/utils'; import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize } from '@immich/sdk'; import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui'; import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -44,9 +44,7 @@
{/if} {/if}
{#if !imageLoaded} {#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center"> <DelayedLoadingSpinner />
<LoadingSpinner />
</div>
{:else if imageLoaded} {:else if imageLoaded}
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full"> <div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
<img <img
@@ -57,15 +55,3 @@
/> />
</div> </div>
{/if} {/if}
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>
-99
View File
@@ -1,99 +0,0 @@
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { AssetMediaSize } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/sw-messaging', () => ({
cancelImageUrl: vi.fn(),
}));
vi.mock('$lib/utils', () => ({
getAssetMediaUrl: vi.fn(),
}));
describe('ImageManager', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('preload', () => {
it('creates an Image with the correct URL', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.preload(asset);
expect(getAssetMediaUrl).toHaveBeenCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
});
it('does nothing for undefined asset', () => {
imageManager.preload(undefined);
expect(getAssetMediaUrl).not.toHaveBeenCalled();
});
it('does nothing when getAssetMediaUrl returns falsy', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('');
const asset = assetFactory.build();
imageManager.preload(asset);
expect(getAssetMediaUrl).toHaveBeenCalled();
});
it('uses the specified size', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.preload(asset, AssetMediaSize.Thumbnail);
expect(getAssetMediaUrl).toHaveBeenCalledWith({
id: asset.id,
size: AssetMediaSize.Thumbnail,
cacheKey: asset.thumbhash,
});
});
});
describe('cancel', () => {
it('calls cancelImageUrl with the correct URL', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.cancel(asset, AssetMediaSize.Preview);
expect(cancelImageUrl).toHaveBeenCalledWith('/api/assets/123/media');
});
it('does nothing for undefined asset', () => {
imageManager.cancel(undefined);
expect(getAssetMediaUrl).not.toHaveBeenCalled();
expect(cancelImageUrl).not.toHaveBeenCalled();
});
it('cancels all sizes when size is "all"', () => {
vi.mocked(getAssetMediaUrl).mockImplementation(({ size }) => `/api/assets/123/${size}`);
const asset = assetFactory.build();
imageManager.cancel(asset, 'all');
expect(getAssetMediaUrl).toHaveBeenCalledTimes(Object.values(AssetMediaSize).length);
for (const size of Object.values(AssetMediaSize)) {
expect(cancelImageUrl).toHaveBeenCalledWith(`/api/assets/123/${size}`);
}
});
it('does not call cancelImageUrl when URL is falsy', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('');
const asset = assetFactory.build();
imageManager.cancel(asset, AssetMediaSize.Preview);
expect(cancelImageUrl).not.toHaveBeenCalled();
});
});
});
+32 -7
View File
@@ -4,19 +4,42 @@ import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
type AllAssetMediaSize = AssetMediaSize | 'all'; type AllAssetMediaSize = AssetMediaSize | 'all';
type AssetLoadState = 'loading' | 'cancelled';
class ImageManager { class ImageManager {
private assetStates = new Map<string, AssetLoadState>();
private readonly MAX_TRACKED_ASSETS = 10;
private trackAction(asset: AssetResponseDto, action: AssetLoadState) {
this.assetStates.delete(asset.id);
this.assetStates.set(asset.id, action);
if (this.assetStates.size > this.MAX_TRACKED_ASSETS) {
const firstKey = this.assetStates.keys().next().value!;
this.assetStates.delete(firstKey);
}
}
isCanceled(asset: AssetResponseDto) {
return 'cancelled' === this.assetStates.get(asset.id);
}
trackLoad(asset: AssetResponseDto) {
this.trackAction(asset, 'loading');
}
trackCancelled(asset: AssetResponseDto) {
this.trackAction(asset, 'cancelled');
}
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) { preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
if (!asset) { if (!asset) {
return; return;
} }
const src = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash }); this.trackLoad(asset);
if (!url) {
return;
}
const img = new Image(); const img = new Image();
img.src = url; img.src = src;
} }
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) { cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
@@ -24,6 +47,8 @@ class ImageManager {
return; return;
} }
this.trackCancelled(asset);
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size]; const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
for (const size of sizes) { for (const size of sizes) {
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash }); const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
+3 -2
View File
@@ -32,6 +32,7 @@ describe('OcrManager', () => {
beforeEach(() => { beforeEach(() => {
// Reset the singleton state before each test // Reset the singleton state before each test
ocrManager.clear(); ocrManager.clear();
ocrManager.showOverlay = false;
assetCacheManager.clearOcrCache(); assetCacheManager.clearOcrCache();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -132,12 +133,12 @@ describe('OcrManager', () => {
expect(ocrManager.hasOcrData).toBe(false); expect(ocrManager.hasOcrData).toBe(false);
}); });
it('should reset showOverlay to false', () => { it('should not reset showOverlay', () => {
ocrManager.showOverlay = true; ocrManager.showOverlay = true;
ocrManager.clear(); ocrManager.clear();
expect(ocrManager.showOverlay).toBe(false); expect(ocrManager.showOverlay).toBe(true);
}); });
it('should mark as cleared for next load', async () => { it('should mark as cleared for next load', async () => {
-1
View File
@@ -45,7 +45,6 @@ class OcrManager {
clear() { clear() {
this.#cleared = true; this.#cleared = true;
this.#data = []; this.#data = [];
this.showOverlay = false;
} }
toggleOcrBoundingBox() { toggleOcrBoundingBox() {
+8
View File
@@ -186,6 +186,14 @@ export const getAssetUrl = ({
return getAssetMediaUrl({ id, size, cacheKey }); return getAssetMediaUrl({ id, size, cacheKey });
}; };
export function getAssetUrls(asset: AssetResponseDto, sharedLink?: SharedLinkResponseDto) {
return {
thumbnail: getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail }),
preview: getAssetUrl({ asset, sharedLink })!,
original: getAssetUrl({ asset, sharedLink, forceOriginal: true })!,
};
}
const forceUseOriginal = (asset: AssetResponseDto) => { const forceUseOriginal = (asset: AssetResponseDto) => {
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000'); return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
}; };
@@ -0,0 +1,185 @@
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
export type ImageQuality = 'thumbnail' | 'preview' | 'original';
export type ImageStatus = 'unloaded' | 'success' | 'error';
export type ImageLoaderStatus = {
urls: Record<ImageQuality, string | undefined>;
quality: Record<ImageQuality, ImageStatus>;
started: boolean;
hasError: boolean;
};
type ImageLoaderCallbacks = {
onUrlChange?: (url: string) => void;
onImageReady?: () => void;
onError?: () => void;
};
export type QualityConfig = {
url: string;
quality: ImageQuality;
checkCanceled: boolean;
onAfterLoad?: (loader: AdaptiveImageLoader) => void;
onAfterError?: (loader: AdaptiveImageLoader) => void;
};
const MAX_TRACKED_ASSETS = 10;
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const tracker = new Map<string, 'loading' | 'canceled'>();
const updateTracker = (id: string, action: 'loading' | 'canceled') => {
tracker.delete(id);
tracker.set(id, action);
if (tracker.size > MAX_TRACKED_ASSETS) {
const firstKey = tracker.keys().next().value!;
tracker.delete(firstKey);
}
};
const isCanceled = (id: string) => 'canceled' === tracker.get(id);
const setLoading = (id: string) => updateTracker(id, 'loading');
const setCanceled = (id: string) => updateTracker(id, 'canceled');
export type QualityList = [
QualityConfig & { quality: 'thumbnail' },
QualityConfig & { quality: 'preview' },
QualityConfig & { quality: 'original' },
];
export class AdaptiveImageLoader {
private destroyFunctions: (() => void)[] = [];
private qualityConfigs: Record<ImageQuality, QualityConfig>;
private highestLoadedQualityIndex = -1;
private destroyed = false;
status = $state<ImageLoaderStatus>({
started: false,
hasError: false,
urls: { thumbnail: undefined, preview: undefined, original: undefined },
quality: { thumbnail: 'unloaded', preview: 'unloaded', original: 'unloaded' },
});
constructor(
private readonly id: string,
private readonly qualityList: QualityList,
private readonly callbacks?: ImageLoaderCallbacks,
private readonly imageLoader?: LoadImageFunction,
) {
this.qualityConfigs = {
thumbnail: qualityList[0],
preview: qualityList[1],
original: qualityList[2],
};
this.status.urls.thumbnail = qualityList[0].url;
setLoading(id);
}
start() {
if (!this.imageLoader) {
throw new Error('Start requires imageLoader to be specified');
}
this.destroyFunctions.push(
this.imageLoader(
this.qualityList[0].url,
() => this.onLoad('thumbnail'),
() => this.onError('thumbnail'),
() => this.onStart('thumbnail'),
),
);
}
onStart(quality: ImageQuality) {
const config = this.qualityConfigs[quality];
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
return;
}
this.status.started = true;
}
onLoad(quality: ImageQuality) {
const config = this.qualityConfigs[quality];
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
return;
}
if (!this.status.urls[quality]) {
return;
}
const index = this.qualityList.indexOf(config);
if (index <= this.highestLoadedQualityIndex) {
return;
}
this.highestLoadedQualityIndex = index;
this.status.quality[quality] = 'success';
this.callbacks?.onUrlChange?.(this.qualityConfigs[quality].url);
this.callbacks?.onImageReady?.();
config.onAfterLoad?.(this);
}
onError(quality: ImageQuality) {
const config = this.qualityConfigs[quality];
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
return;
}
this.status.hasError = true;
this.status.quality[quality] = 'error';
this.status.urls[quality] = undefined;
this.callbacks?.onError?.();
config.onAfterError?.(this);
}
trigger(quality: ImageQuality) {
if (this.destroyed) {
return false;
}
const url = this.qualityConfigs[quality].url;
if (!url) {
this.qualityConfigs[quality].onAfterError?.(this);
return false;
}
if (this.status.urls[quality]) {
return true;
}
this.status.hasError = false;
this.status.urls[quality] = url;
if (this.imageLoader) {
this.destroyFunctions.push(
this.imageLoader(
url,
() => this.onLoad(quality),
() => this.onError(quality),
() => this.onStart(quality),
),
);
}
return false;
}
destroy() {
setCanceled(this.id);
this.destroyed = true;
if (this.imageLoader) {
for (const destroy of this.destroyFunctions) {
destroy();
}
return;
}
for (const config of Object.values(this.qualityConfigs)) {
cancelImageUrl(config.url);
}
}
}
+13
View File
@@ -5,6 +5,19 @@ export interface ContentMetrics {
offsetY: number; offsetY: number;
} }
export const scaleToCover = (
dimensions: { width: number; height: number },
container: { width: number; height: number },
): { width: number; height: number } => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.max(scaleX, scaleY);
return {
width: dimensions.width * scale,
height: dimensions.height * scale,
};
};
export const scaleToFit = ( export const scaleToFit = (
dimensions: { width: number; height: number }, dimensions: { width: number; height: number },
container: { width: number; height: number }, container: { width: number; height: number },
+54
View File
@@ -0,0 +1,54 @@
import { scaleToFit } from '$lib/utils/container-utils';
describe('scaleToFit', () => {
const tests = [
{
name: 'landscape image in square container',
dimensions: { width: 2000, height: 1000 },
container: { width: 500, height: 500 },
expected: { width: 500, height: 250 },
},
{
name: 'portrait image in square container',
dimensions: { width: 1000, height: 2000 },
container: { width: 500, height: 500 },
expected: { width: 250, height: 500 },
},
{
name: 'square image in square container',
dimensions: { width: 1000, height: 1000 },
container: { width: 500, height: 500 },
expected: { width: 500, height: 500 },
},
{
name: 'landscape image in landscape container',
dimensions: { width: 1600, height: 900 },
container: { width: 800, height: 600 },
expected: { width: 800, height: 450 },
},
{
name: 'portrait image in portrait container',
dimensions: { width: 900, height: 1600 },
container: { width: 600, height: 800 },
expected: { width: 450, height: 800 },
},
{
name: 'image matches container exactly',
dimensions: { width: 500, height: 300 },
container: { width: 500, height: 300 },
expected: { width: 500, height: 300 },
},
{
name: 'image smaller than container scales up',
dimensions: { width: 100, height: 50 },
container: { width: 400, height: 400 },
expected: { width: 400, height: 200 },
},
];
for (const { name, dimensions, container, expected } of tests) {
it(`should handle ${name}`, () => {
expect(scaleToFit(dimensions, container)).toEqual(expected);
});
}
});