mirror of
https://github.com/immich-app/immich.git
synced 2026-04-28 12:13:09 -07:00
Compare commits
4 Commits
feat/patch
...
push-nwxlp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d0760b4cd | ||
|
|
6ea7235e3c | ||
|
|
3467897113 | ||
|
|
872a6ae993 |
@@ -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,42 @@ 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();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||
await expect(original).toBeInViewport();
|
||||
await expect(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();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||
await expect(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');
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const initialSrc = await thumbnail.getAttribute('src');
|
||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,13 +99,13 @@ export const setupTimelineMockApiRoutes = async (
|
||||
});
|
||||
|
||||
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);
|
||||
if (!match?.groups) {
|
||||
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()) {
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
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.locator('svg')).toBeVisible();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
@@ -53,7 +54,7 @@ test.describe('search gallery-viewer', () => {
|
||||
assets: {
|
||||
total: searchAssets.length,
|
||||
count: searchAssets.length,
|
||||
items: searchAssets,
|
||||
items: searchAssets.map((asset) => toAssetResponseDto(asset)),
|
||||
facets: [],
|
||||
nextPage: null,
|
||||
},
|
||||
|
||||
@@ -163,13 +163,11 @@ export const assetViewerUtils = {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||
const previewUrl = `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true`;
|
||||
await page
|
||||
.locator(
|
||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
||||
)
|
||||
.or(
|
||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
||||
)
|
||||
.getByTestId('preview')
|
||||
.and(page.locator(`[src="${previewUrl}"]`))
|
||||
.or(page.locator(`video[poster="${previewUrl}"]`))
|
||||
.waitFor();
|
||||
},
|
||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||
|
||||
@@ -42,7 +42,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadFiles } from 'src/types';
|
||||
import { ImmichFileResponse, sendFile } from 'src/utils/file';
|
||||
import { ImmichFileResponse, sendFile, sendFileThrottled } from 'src/utils/file';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Assets)
|
||||
@@ -109,7 +109,7 @@ export class AssetMediaController {
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
|
||||
await sendFileThrottled(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger, 10);
|
||||
}
|
||||
|
||||
@Put(':id/original')
|
||||
@@ -162,7 +162,14 @@ export class AssetMediaController {
|
||||
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
|
||||
|
||||
if (viewThumbnailRes instanceof ImmichFileResponse) {
|
||||
await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
|
||||
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
|
||||
let durationSeconds = 1;
|
||||
if (size === AssetMediaSize.PREVIEW) {
|
||||
durationSeconds = 4;
|
||||
} else if (size === AssetMediaSize.FULLSIZE) {
|
||||
durationSeconds = 4;
|
||||
}
|
||||
await sendFileThrottled(res, next, () => Promise.resolve(viewThumbnailRes), this.logger, durationSeconds);
|
||||
} else {
|
||||
// viewThumbnailRes is a AssetMediaRedirectResponse
|
||||
// which redirects to the original asset or a specific size to make better use of caching
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
import { HttpException, StreamableFile } from '@nestjs/common';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { access, constants } from 'node:fs/promises';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { access, constants, stat } from 'node:fs/promises';
|
||||
import { basename, extname } from 'node:path';
|
||||
import { Transform, TransformCallback } from 'node:stream';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import sharp from 'sharp';
|
||||
import { CacheControl } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { ImmichReadStream } from 'src/repositories/storage.repository';
|
||||
import { isConnectionAborted } from 'src/utils/misc';
|
||||
|
||||
class ThrottleTransform extends Transform {
|
||||
private bytesPerSecond: number;
|
||||
private startTime: number;
|
||||
private bytesSent: number;
|
||||
|
||||
constructor(bytesPerSecond: number) {
|
||||
super();
|
||||
this.bytesPerSecond = bytesPerSecond;
|
||||
this.startTime = Date.now();
|
||||
this.bytesSent = 0;
|
||||
}
|
||||
|
||||
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
|
||||
this.bytesSent += chunk.length;
|
||||
const targetTime = (this.bytesSent / this.bytesPerSecond) * 1000;
|
||||
const elapsed = Date.now() - this.startTime;
|
||||
const delay = Math.max(0, targetTime - elapsed);
|
||||
|
||||
setTimeout(() => {
|
||||
callback(null, chunk);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileNameWithoutExtension(path: string): string {
|
||||
return basename(path, extname(path));
|
||||
}
|
||||
@@ -20,6 +48,11 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string
|
||||
return getFileNameWithoutExtension(stillName) + extname(motionName);
|
||||
}
|
||||
|
||||
export async function hasAlphaChannel(input: string | Buffer): Promise<boolean> {
|
||||
const metadata = await sharp(input).metadata();
|
||||
return metadata.hasAlpha === true;
|
||||
}
|
||||
|
||||
export class ImmichFileResponse {
|
||||
public readonly path!: string;
|
||||
public readonly contentType!: string;
|
||||
@@ -85,3 +118,57 @@ export const sendFile = async (
|
||||
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
|
||||
return new StreamableFile(stream, { type, length });
|
||||
};
|
||||
|
||||
const THROTTLE_TRANSFER_DURATION_SECONDS = 6;
|
||||
const THROTTLE_INITIAL_DELAY_MS = 500;
|
||||
|
||||
export const sendFileThrottled = async (
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
handler: () => Promise<ImmichFileResponse> | ImmichFileResponse,
|
||||
logger: LoggingRepository,
|
||||
durationSeconds: number = THROTTLE_TRANSFER_DURATION_SECONDS,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const file = await handler();
|
||||
const cacheControlHeader = cacheControlHeaders[file.cacheControl];
|
||||
if (cacheControlHeader) {
|
||||
res.set('Cache-Control', cacheControlHeader);
|
||||
}
|
||||
|
||||
res.header('Content-Type', file.contentType);
|
||||
if (file.fileName) {
|
||||
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
|
||||
}
|
||||
|
||||
const fileStat = await stat(file.path);
|
||||
res.header('Content-Length', fileStat.size.toString());
|
||||
|
||||
await sleep(THROTTLE_INITIAL_DELAY_MS);
|
||||
|
||||
const bytesPerSecond = fileStat.size / durationSeconds;
|
||||
const readStream = createReadStream(file.path);
|
||||
const throttle = new ThrottleTransform(bytesPerSecond);
|
||||
|
||||
readStream.pipe(throttle).pipe(res);
|
||||
|
||||
readStream.on('error', (error) => {
|
||||
if (!isConnectionAborted(error) && !res.headersSent) {
|
||||
logger.error(`Unable to send file: ${error}`, (error as Error).stack);
|
||||
res.header('Cache-Control', 'none');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
} catch (error: Error | any) {
|
||||
if (isConnectionAborted(error) || res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof HttpException === false) {
|
||||
logger.error(`Unable to send file: ${error}`, error.stack);
|
||||
}
|
||||
|
||||
res.header('Cache-Control', 'none');
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
25
web/src/lib/actions/image-loader.svelte.ts
Normal file
25
web/src/lib/actions/image-loader.svelte.ts
Normal file
@@ -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;
|
||||
@@ -1,8 +1,12 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean; zoomTarget?: HTMLElement }) => {
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
initialState: assetViewerManager.zoomState,
|
||||
zoomTarget: options?.zoomTarget,
|
||||
});
|
||||
|
||||
const unsubscribes = [
|
||||
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
||||
@@ -20,8 +24,11 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
|
||||
node.style.overflow = 'visible';
|
||||
return {
|
||||
update(newOptions?: { disabled?: boolean }) {
|
||||
update(newOptions?: { disabled?: boolean; zoomTarget?: HTMLElement }) {
|
||||
options = newOptions;
|
||||
if (newOptions?.zoomTarget !== undefined) {
|
||||
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
|
||||
212
web/src/lib/components/AdaptiveImage.svelte
Normal file
212
web/src/lib/components/AdaptiveImage.svelte
Normal 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" 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>
|
||||
11
web/src/lib/components/AlphaBackground.svelte
Normal file
11
web/src/lib/components/AlphaBackground.svelte
Normal file
@@ -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>
|
||||
20
web/src/lib/components/DelayedLoadingSpinner.svelte
Normal file
20
web/src/lib/components/DelayedLoadingSpinner.svelte
Normal file
@@ -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
web/src/lib/components/ImageLayer.svelte
Normal file
47
web/src/lib/components/ImageLayer.svelte
Normal 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}
|
||||
103
web/src/lib/components/asset-viewer/PreloadManager.svelte.ts
Normal file
103
web/src/lib/components/asset-viewer/PreloadManager.svelte.ts
Normal file
@@ -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 PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.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 { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
@@ -92,20 +93,20 @@
|
||||
stopProgress: stopSlideshowProgress,
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowTransition,
|
||||
slideshowRepeat,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
const asset = $derived(cursor.current);
|
||||
let stack: StackResponseDto | undefined = $state();
|
||||
let selectedStackAsset = $derived(stack?.assets.find(({ id }) => id === stack?.primaryAssetId));
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
|
||||
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
let sharedLink = getSharedLink();
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
let slideshowStartAssetId = $state<string>();
|
||||
@@ -115,62 +116,60 @@
|
||||
};
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
if (authManager.isSharedLink || !withStacked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.stack) {
|
||||
stack = await getStack({ id: asset.stack.id });
|
||||
if (!cursor.current.stack) {
|
||||
stack = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||
stack = null;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
imageManager.preload(stack?.assets[1]);
|
||||
});
|
||||
stack = await getStack({ id: cursor.current.stack.id });
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (album && album.isActivityEnabled) {
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
if (!album || !album.isActivityEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
syncAssetViewerOpenClass(true);
|
||||
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));
|
||||
}
|
||||
}),
|
||||
);
|
||||
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(() => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
activityManager.reset();
|
||||
assetViewerManager.closeEditor();
|
||||
syncAssetViewerOpenClass(false);
|
||||
preloadManager.destroy();
|
||||
});
|
||||
|
||||
const closeViewer = () => {
|
||||
@@ -186,52 +185,57 @@
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
const getNavigationTarget = () => {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
return $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
} else {
|
||||
return 'skip';
|
||||
}
|
||||
};
|
||||
|
||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
} else {
|
||||
return;
|
||||
const completeNavigation = async (target: 'previous' | 'next') => {
|
||||
preloadManager.cancelBeforeNavigation(target);
|
||||
let hasNext: boolean;
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||
hasNext = target === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
hasNext = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasNext =
|
||||
target === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
|
||||
e?.stopPropagation();
|
||||
imageManager.cancel(asset);
|
||||
if (tracker.isActive()) {
|
||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
||||
return;
|
||||
}
|
||||
|
||||
void tracker.invoke(async () => {
|
||||
let hasNext: boolean;
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
};
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
hasNext = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasNext =
|
||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
const tracker = new InvocationTracker();
|
||||
const navigateAsset = (target: 'previous' | 'next' | 'skip') => {
|
||||
if (target === 'skip' || tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
// Loop back to starting asset
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
}
|
||||
}, $t('error_while_navigating'));
|
||||
void tracker.invoke(
|
||||
() => completeNavigation(target),
|
||||
(error: unknown) => handleError(error, $t('error_while_navigating')),
|
||||
() => eventManager.emit('ViewerFinishNavigate'),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -274,12 +278,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? asset : undefined;
|
||||
};
|
||||
const handlePreAction = (action: Action) => {
|
||||
preAction?.(action);
|
||||
};
|
||||
|
||||
const handleAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case AssetAction.DELETE:
|
||||
@@ -288,7 +290,7 @@
|
||||
break;
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
stack = action.stack;
|
||||
stack = action.stack ?? undefined;
|
||||
if (stack) {
|
||||
cursor.current = stack.assets[0];
|
||||
}
|
||||
@@ -345,24 +347,41 @@
|
||||
const refresh = async () => {
|
||||
await refreshStack();
|
||||
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;
|
||||
cursor.current;
|
||||
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) {
|
||||
selectedStackAsset = undefined;
|
||||
previewStackedAsset = undefined;
|
||||
preloadManager.updateAfterNavigation(lastCursor, cursor);
|
||||
}
|
||||
if (!lastCursor) {
|
||||
preloadManager.initializePreloads(cursor);
|
||||
}
|
||||
lastCursor = cursor;
|
||||
});
|
||||
|
||||
const viewerKind = $derived.by(() => {
|
||||
if (previewStackedAsset) {
|
||||
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
|
||||
return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
||||
}
|
||||
if (asset.type === AssetTypeEnum.Video) {
|
||||
return 'VideoViewer';
|
||||
@@ -448,44 +467,36 @@
|
||||
</div>
|
||||
{/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">
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
haveFadeTransition={false}
|
||||
{sharedLink}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
asset={previewStackedAsset!}
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
assetId={previewStackedAsset!.id}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||
loopVideo={true}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoEnded={() => navigateAsset(getNavigationTarget())}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'LiveVideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
{cursor}
|
||||
assetId={asset.livePhotoVideoId!}
|
||||
{sharedLink}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
@@ -495,22 +506,20 @@
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
cursor={{ ...cursor, current: asset }}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
{cursor}
|
||||
{sharedLink}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoEnded={() => navigateAsset(getNavigationTarget())}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
@@ -535,7 +544,7 @@
|
||||
{/if}
|
||||
</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">
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
@@ -563,10 +572,14 @@
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<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)}
|
||||
<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'}
|
||||
>
|
||||
<Thumbnail
|
||||
@@ -575,21 +588,19 @@
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
cursor.current = stackedAsset;
|
||||
selectedStackAsset = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
onMouseEvent={({ isMouseOver }) => isMouseOver && (previewStackedAsset = stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
disableLinkMouseOver
|
||||
/>
|
||||
|
||||
{#if stackedAsset.id === asset.id}
|
||||
<div class="w-full flex place-items-center place-content-center">
|
||||
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
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 { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
@@ -81,15 +81,20 @@
|
||||
await getPeople();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const metrics = getContentMetrics(htmlElement);
|
||||
|
||||
const imageBoundingBox = {
|
||||
top: metrics.offsetY,
|
||||
left: metrics.offsetX,
|
||||
width: metrics.contentWidth,
|
||||
height: metrics.contentHeight,
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
const container = { width: containerWidth, height: containerHeight };
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (containerWidth - contentWidth) / 2,
|
||||
offsetY: (containerHeight - contentHeight) / 2,
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const { offsetX, offsetY } = imageContentMetrics;
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
@@ -105,8 +110,8 @@
|
||||
}
|
||||
|
||||
faceRect.set({
|
||||
top: imageBoundingBox.top + 200,
|
||||
left: imageBoundingBox.left + 200,
|
||||
top: offsetY + 200,
|
||||
left: offsetX + 200,
|
||||
});
|
||||
|
||||
faceRect.setCoords();
|
||||
@@ -214,13 +219,13 @@
|
||||
}
|
||||
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const metrics = getContentMetrics(htmlElement);
|
||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
|
||||
const scaleX = natural.width / metrics.contentWidth;
|
||||
const scaleY = natural.height / metrics.contentHeight;
|
||||
const imageX = (left - metrics.offsetX) * scaleX;
|
||||
const imageY = (top - metrics.offsetY) * scaleY;
|
||||
const scaleX = natural.width / contentWidth;
|
||||
const scaleY = natural.height / contentHeight;
|
||||
const imageX = (left - offsetX) * scaleX;
|
||||
const imageY = (top - offsetY) * scaleY;
|
||||
|
||||
return {
|
||||
imageWidth: natural.width,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<div class="absolute left-0 top-0">
|
||||
<div
|
||||
class="absolute flex items-center justify-center text-transparent text-sm border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text transition-all hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3"
|
||||
class="absolute flex items-center justify-center text-transparent text-sm border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text transition-colors hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3"
|
||||
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;"
|
||||
>
|
||||
{ocrBox.text}
|
||||
|
||||
@@ -1,66 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
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 OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-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 { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/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 { 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 { type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
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;
|
||||
onError?: () => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
}: Props = $props();
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
|
||||
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 visibleImageReady: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
let previousAssetId: string | undefined;
|
||||
$effect.pre(() => {
|
||||
void asset.id;
|
||||
const id = asset.id;
|
||||
if (id === previousAssetId) {
|
||||
return;
|
||||
}
|
||||
previousAssetId = id;
|
||||
untrack(() => {
|
||||
assetViewerManager.resetZoomState();
|
||||
visibleImageReady = false;
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
});
|
||||
@@ -69,25 +60,30 @@
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
const container = $derived({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
const overlayMetrics = $derived.by((): ContentMetrics => {
|
||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
|
||||
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const scaled = scaleToFit(natural, container);
|
||||
return {
|
||||
contentWidth: contentWidth * currentZoom,
|
||||
contentHeight: contentHeight * currentZoom,
|
||||
offsetX: offsetX * currentZoom + currentPositionX,
|
||||
offsetY: offsetY * currentZoom + currentPositionY,
|
||||
contentWidth: scaled.width,
|
||||
contentHeight: scaled.height,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||
|
||||
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
||||
@@ -124,29 +120,15 @@
|
||||
handlePromiseError(onCopy());
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
return;
|
||||
}
|
||||
let currentPreviewUrl = $state<string>();
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onNextAsset && event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
|
||||
if (onPreviousAsset && event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
const onUrlChange = (url: string) => {
|
||||
currentPreviewUrl = url;
|
||||
};
|
||||
|
||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
|
||||
|
||||
$effect(() => {
|
||||
if (imageLoaderUrl) {
|
||||
void cast(imageLoaderUrl);
|
||||
if (currentPreviewUrl) {
|
||||
void cast(currentPreviewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -164,37 +146,11 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
imageLoaded = true;
|
||||
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 }),
|
||||
const blurredSlideshow = $derived(
|
||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||
);
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
let lastUrl: string | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||
untrack(() => {
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
visibleImageReady = false;
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
});
|
||||
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
||||
|
||||
const faceToNameMap = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
@@ -209,28 +165,12 @@
|
||||
|
||||
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 = [];
|
||||
};
|
||||
let swipeFeedbackReset = $state<(() => void) | undefined>();
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset.id;
|
||||
untrack(() => swipeFeedbackReset?.());
|
||||
});
|
||||
</script>
|
||||
|
||||
<AssetViewerEvents {onCopy} {onZoom} />
|
||||
@@ -243,57 +183,68 @@
|
||||
{ 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
|
||||
bind:this={element}
|
||||
|
||||
<SwipeFeedback
|
||||
bind:element
|
||||
class="relative h-full w-full select-none"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
role="presentation"
|
||||
onmousemove={handleImageMouseMove}
|
||||
onmouseleave={handleImageMouseLeave}
|
||||
disabled={!onSwipe || ocrManager.showOverlay || assetViewerManager.zoom > 1}
|
||||
disableSwipeLeft={!cursor.nextAsset}
|
||||
disableSwipeRight={!cursor.previousAsset}
|
||||
bind:reset={swipeFeedbackReset}
|
||||
onSwipe={onSwipe ?? (() => {})}
|
||||
{@attach fromAction(zoomImageAction, () => ({ disabled: isFaceEditMode.value, zoomTarget: adaptiveImage }))}
|
||||
>
|
||||
{#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"
|
||||
/>
|
||||
<AdaptiveImage
|
||||
{asset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
||||
{onUrlChange}
|
||||
onImageReady={() => {
|
||||
visibleImageReady = true;
|
||||
onReady?.();
|
||||
}}
|
||||
onError={() => {
|
||||
onError?.();
|
||||
onReady?.();
|
||||
}}
|
||||
bind:imgRef={assetViewerManager.imgRef}
|
||||
bind:ref={adaptiveImage}
|
||||
>
|
||||
{#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}
|
||||
<img
|
||||
bind:this={assetViewerManager.imgRef}
|
||||
src={imageLoaderUrl}
|
||||
onload={() => (visibleImageReady = true)}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet overlays()}
|
||||
{#if !isFaceEditMode.value && !ocrManager.showOverlay}
|
||||
{#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute pointer-events-auto outline-none"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
aria-label="{$t('person')}: {faceToNameMap.get(faces[index]) || $t('unknown')}"
|
||||
onmouseenter={() => ($boundingBoxesArray = [faces[index]])}
|
||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
||||
onfocus={() => ($boundingBoxesArray = [faces[index]])}
|
||||
onblur={() => ($boundingBoxesArray = [])}
|
||||
></button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
class="absolute border-solid border-white border-3 rounded-lg pointer-events-none"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
></div>
|
||||
{#if faceToNameMap.get($boundingBoxesArray[index])}
|
||||
<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.top + boundingbox.height + 4}px; left: {boundingbox.left +
|
||||
boundingbox.width}px; transform: translateX(-100%);"
|
||||
@@ -306,23 +257,36 @@
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {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>
|
||||
{#snippet leftPreview()}
|
||||
{#if cursor.previousAsset}
|
||||
<AdaptiveImage
|
||||
asset={cursor.previousAsset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rightPreview()}
|
||||
{#if cursor.nextAsset}
|
||||
<AdaptiveImage
|
||||
asset={cursor.nextAsset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SwipeFeedback>
|
||||
|
||||
393
web/src/lib/components/asset-viewer/swipe-feedback.svelte
Normal file
393
web/src/lib/components/asset-viewer/swipe-feedback.svelte
Normal file
@@ -0,0 +1,393 @@
|
||||
<script lang="ts">
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
type SwipeProps = {
|
||||
disabled?: boolean;
|
||||
disableSwipeLeft?: boolean;
|
||||
disableSwipeRight?: boolean;
|
||||
onSwipeEnd?: (offsetX: number) => void;
|
||||
onSwipeMove?: (offsetX: number) => void;
|
||||
onSwipe: (direction: 'left' | 'right') => void;
|
||||
element?: HTMLDivElement;
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
reset?: () => void;
|
||||
children: Snippet;
|
||||
leftPreview?: Snippet;
|
||||
rightPreview?: Snippet;
|
||||
} & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let {
|
||||
disabled = false,
|
||||
disableSwipeLeft = false,
|
||||
disableSwipeRight = false,
|
||||
onSwipeEnd,
|
||||
onSwipeMove,
|
||||
onSwipe,
|
||||
element = $bindable(),
|
||||
clientWidth = $bindable(),
|
||||
clientHeight = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
reset = $bindable(),
|
||||
children,
|
||||
leftPreview,
|
||||
rightPreview,
|
||||
...restProps
|
||||
}: SwipeProps = $props();
|
||||
|
||||
interface SwipeAnimations {
|
||||
currentImageAnimation: Animation;
|
||||
previewAnimation: Animation | null;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION_MS = 300;
|
||||
// Tolerance to avoid edge cases where animation is nearly complete but not exactly at end
|
||||
const ANIMATION_COMPLETION_TOLERANCE_MS = 5;
|
||||
const SWIPE_THRESHOLD = 45;
|
||||
// Minimum velocity to trigger swipe (tuned for natural flick gesture)
|
||||
const MIN_SWIPE_VELOCITY = 0.11; // pixels per millisecond
|
||||
// Require 25% drag progress if velocity is too low (prevents accidental swipes)
|
||||
const MIN_PROGRESS_THRESHOLD = 0.25;
|
||||
const ENABLE_SCALE_ANIMATION = false;
|
||||
|
||||
let contentElement: HTMLElement | undefined = $state();
|
||||
let leftPreviewContainer: HTMLDivElement | undefined = $state();
|
||||
let rightPreviewContainer: HTMLDivElement | undefined = $state();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let startX = $state(0);
|
||||
let currentOffsetX = $state(0);
|
||||
let dragStartTime: number | null = $state(null);
|
||||
|
||||
let leftAnimations: SwipeAnimations | null = $state(null);
|
||||
let rightAnimations: SwipeAnimations | null = $state(null);
|
||||
let isSwipeInProgress = $state(false);
|
||||
|
||||
const cursorStyle = $derived(disabled ? '' : isDragging ? 'grabbing' : 'grab');
|
||||
|
||||
const isValidPointerEvent = (event: PointerEvent) =>
|
||||
event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0);
|
||||
|
||||
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
|
||||
if (!contentElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createAnimationKeyframes = (direction: 'left' | 'right', isPreview: boolean) => {
|
||||
const scale = (s: number) => (ENABLE_SCALE_ANIMATION ? ` scale(${s})` : '');
|
||||
const sign = direction === 'left' ? -1 : 1;
|
||||
|
||||
if (isPreview) {
|
||||
return [
|
||||
{ transform: `translateX(${sign * -100}vw)${scale(0)}`, opacity: '0', offset: 0 },
|
||||
{ transform: `translateX(${sign * -80}vw)${scale(0.2)}`, opacity: '0', offset: 0.2 },
|
||||
{ transform: `translateX(${sign * -50}vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
|
||||
{ transform: `translateX(${sign * -20}vw)${scale(0.8)}`, opacity: '1', offset: 0.8 },
|
||||
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
|
||||
{ transform: `translateX(${sign * 100}vw)${scale(0)}`, opacity: '0', offset: 1 },
|
||||
];
|
||||
};
|
||||
|
||||
contentElement.style.transformOrigin = 'center';
|
||||
|
||||
const currentImageAnimation = contentElement.animate(createAnimationKeyframes(direction, false), {
|
||||
duration: ANIMATION_DURATION_MS,
|
||||
easing: 'linear',
|
||||
fill: 'both',
|
||||
});
|
||||
|
||||
// Preview slides in from opposite side of swipe direction
|
||||
const previewContainer = direction === 'left' ? rightPreviewContainer : leftPreviewContainer;
|
||||
let previewAnimation: Animation | null = null;
|
||||
|
||||
if (previewContainer) {
|
||||
previewContainer.style.transformOrigin = 'center';
|
||||
previewAnimation = previewContainer.animate(createAnimationKeyframes(direction, true), {
|
||||
duration: ANIMATION_DURATION_MS,
|
||||
easing: 'linear',
|
||||
fill: 'both',
|
||||
});
|
||||
}
|
||||
|
||||
currentImageAnimation.pause();
|
||||
previewAnimation?.pause();
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
return { currentImageAnimation, previewAnimation, abortController };
|
||||
};
|
||||
|
||||
const setAnimationTime = (animations: SwipeAnimations, time: number) => {
|
||||
animations.currentImageAnimation.currentTime = time;
|
||||
if (animations.previewAnimation) {
|
||||
animations.previewAnimation.currentTime = time;
|
||||
}
|
||||
};
|
||||
|
||||
const playAnimation = (animations: SwipeAnimations, playbackRate: number) => {
|
||||
animations.currentImageAnimation.playbackRate = playbackRate;
|
||||
if (animations.previewAnimation) {
|
||||
animations.previewAnimation.playbackRate = playbackRate;
|
||||
}
|
||||
animations.currentImageAnimation.play();
|
||||
animations.previewAnimation?.play();
|
||||
};
|
||||
|
||||
const cancelAnimations = (animations: SwipeAnimations | null) => {
|
||||
if (!animations) {
|
||||
return;
|
||||
}
|
||||
animations.abortController.abort();
|
||||
animations.currentImageAnimation.cancel();
|
||||
animations.previewAnimation?.cancel();
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (disabled || !contentElement || !isValidPointerEvent(event) || !element || isSwipeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
startDrag(event);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const startDrag = (event: PointerEvent) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
startX = event.clientX;
|
||||
currentOffsetX = 0;
|
||||
|
||||
element.setPointerCapture(event.pointerId);
|
||||
dragStartTime = Date.now();
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (disabled || !contentElement || !isDragging || isSwipeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawOffsetX = event.clientX - startX;
|
||||
const direction = rawOffsetX < 0 ? 'left' : 'right';
|
||||
|
||||
if ((direction === 'left' && disableSwipeLeft) || (direction === 'right' && disableSwipeRight)) {
|
||||
currentOffsetX = 0;
|
||||
cancelAnimations(leftAnimations);
|
||||
cancelAnimations(rightAnimations);
|
||||
leftAnimations = null;
|
||||
rightAnimations = null;
|
||||
return;
|
||||
}
|
||||
|
||||
currentOffsetX = rawOffsetX;
|
||||
const animationTime = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1) * ANIMATION_DURATION_MS;
|
||||
|
||||
if (direction === 'left') {
|
||||
if (!leftAnimations) {
|
||||
leftAnimations = createSwipeAnimations('left');
|
||||
}
|
||||
if (leftAnimations) {
|
||||
setAnimationTime(leftAnimations, animationTime);
|
||||
}
|
||||
if (rightAnimations) {
|
||||
cancelAnimations(rightAnimations);
|
||||
rightAnimations = null;
|
||||
}
|
||||
} else {
|
||||
if (!rightAnimations) {
|
||||
rightAnimations = createSwipeAnimations('right');
|
||||
}
|
||||
if (rightAnimations) {
|
||||
setAnimationTime(rightAnimations, animationTime);
|
||||
}
|
||||
if (leftAnimations) {
|
||||
cancelAnimations(leftAnimations);
|
||||
leftAnimations = null;
|
||||
}
|
||||
}
|
||||
onSwipeMove?.(currentOffsetX);
|
||||
event.preventDefault(); // Prevent scrolling during drag
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (!isDragging || !isValidPointerEvent(event) || !contentElement || !element) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
if (element.hasPointerCapture(event.pointerId)) {
|
||||
element.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const dragDuration = dragStartTime ? Date.now() - dragStartTime : 0;
|
||||
const velocity = dragDuration > 0 ? Math.abs(currentOffsetX) / dragDuration : 0;
|
||||
const progress = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1);
|
||||
|
||||
if (
|
||||
Math.abs(currentOffsetX) < SWIPE_THRESHOLD ||
|
||||
(velocity < MIN_SWIPE_VELOCITY && progress <= MIN_PROGRESS_THRESHOLD)
|
||||
) {
|
||||
resetPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
isSwipeInProgress = true;
|
||||
|
||||
onSwipeEnd?.(currentOffsetX);
|
||||
completeTransition(currentOffsetX > 0 ? 'right' : 'left');
|
||||
};
|
||||
|
||||
const resetPosition = () => {
|
||||
if (!contentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = currentOffsetX < 0 ? 'left' : 'right';
|
||||
const animations = direction === 'left' ? leftAnimations : rightAnimations;
|
||||
|
||||
if (!animations) {
|
||||
currentOffsetX = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
playAnimation(animations, -1);
|
||||
|
||||
const handleFinish = () => {
|
||||
cancelAnimations(animations);
|
||||
if (direction === 'left') {
|
||||
leftAnimations = null;
|
||||
} else {
|
||||
rightAnimations = null;
|
||||
}
|
||||
};
|
||||
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
|
||||
signal: animations.abortController.signal,
|
||||
});
|
||||
|
||||
currentOffsetX = 0;
|
||||
};
|
||||
|
||||
const completeTransition = (direction: 'left' | 'right') => {
|
||||
if (!contentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animations = direction === 'left' ? leftAnimations : rightAnimations;
|
||||
if (!animations) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Number(animations.currentImageAnimation.currentTime) || 0;
|
||||
|
||||
if (currentTime >= ANIMATION_DURATION_MS - ANIMATION_COMPLETION_TOLERANCE_MS) {
|
||||
onSwipe(direction);
|
||||
return;
|
||||
}
|
||||
|
||||
playAnimation(animations, 1);
|
||||
|
||||
const handleFinish = () => {
|
||||
if (contentElement) {
|
||||
onSwipe(direction);
|
||||
}
|
||||
};
|
||||
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
|
||||
signal: animations.abortController.signal,
|
||||
});
|
||||
};
|
||||
|
||||
const resetPreviewContainers = () => {
|
||||
cancelAnimations(leftAnimations);
|
||||
cancelAnimations(rightAnimations);
|
||||
leftAnimations = null;
|
||||
rightAnimations = null;
|
||||
|
||||
if (contentElement) {
|
||||
contentElement.style.transform = '';
|
||||
contentElement.style.transition = '';
|
||||
contentElement.style.opacity = '';
|
||||
}
|
||||
currentOffsetX = 0;
|
||||
};
|
||||
|
||||
const finishSwipeInProgress = () => {
|
||||
isSwipeInProgress = false;
|
||||
};
|
||||
|
||||
const resetSwipeFeedback = () => {
|
||||
resetPreviewContainers();
|
||||
finishSwipeInProgress();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
reset = resetSwipeFeedback;
|
||||
onMount(() =>
|
||||
eventManager.on({
|
||||
ViewerFinishNavigate: finishSwipeInProgress,
|
||||
ResetSwipeFeedback: resetSwipeFeedback,
|
||||
}),
|
||||
);
|
||||
|
||||
onDestroy(() => {
|
||||
resetSwipeFeedback();
|
||||
if (element) {
|
||||
element.style.cursor = '';
|
||||
}
|
||||
if (contentElement) {
|
||||
contentElement.style.cursor = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Listen on window to catch pointer release outside element (due to setPointerCapture) -->
|
||||
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
|
||||
|
||||
<div
|
||||
{...restProps}
|
||||
bind:this={element}
|
||||
bind:clientWidth
|
||||
bind:clientHeight
|
||||
style:cursor={cursorStyle}
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
role="presentation"
|
||||
>
|
||||
{#if leftPreview}
|
||||
<!-- Swiping right reveals left preview -->
|
||||
<div
|
||||
bind:this={leftPreviewContainer}
|
||||
class="absolute inset-0"
|
||||
style:pointer-events="none"
|
||||
style:display={rightAnimations ? 'block' : 'none'}
|
||||
>
|
||||
{@render leftPreview()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rightPreview}
|
||||
<!-- Swiping left reveals right preview -->
|
||||
<div
|
||||
bind:this={rightPreviewContainer}
|
||||
class="absolute inset-0"
|
||||
style:pointer-events="none"
|
||||
style:display={leftAnimations ? 'block' : 'none'}
|
||||
>
|
||||
{@render rightPreview()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div bind:this={contentElement} class="h-full w-full" style:cursor={cursorStyle}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
|
||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
@@ -10,56 +13,83 @@
|
||||
videoViewerMuted,
|
||||
videoViewerVolume,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { scaleToFit } from '$lib/utils/container-utils';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
assetId: string;
|
||||
cursor: AssetCursor;
|
||||
assetId?: string;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
loopVideo: boolean;
|
||||
cacheKey: string | null;
|
||||
playOriginalVideo: boolean;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onSwipe: (direction: 'left' | 'right') => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
assetId,
|
||||
sharedLink,
|
||||
loopVideo,
|
||||
cacheKey,
|
||||
playOriginalVideo,
|
||||
onPreviousAsset = () => {},
|
||||
onNextAsset = () => {},
|
||||
onSwipe,
|
||||
onVideoEnded = () => {},
|
||||
onVideoStarted = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const asset = $derived(cursor.current);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const effectiveAssetId = $derived(assetId ?? asset.id);
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
let isLoading = $state(true);
|
||||
let assetFileUrl = $derived(
|
||||
playOriginalVideo
|
||||
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
|
||||
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
|
||||
? getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Original, cacheKey })
|
||||
: getAssetPlaybackUrl({ id: effectiveAssetId, cacheKey }),
|
||||
);
|
||||
let previousAssetFileUrl = $state<string | undefined>();
|
||||
let isScrubbing = $state(false);
|
||||
let showVideo = $state(false);
|
||||
|
||||
let containerWidth = $state(document.documentElement.clientWidth);
|
||||
let containerHeight = $state(document.documentElement.clientHeight);
|
||||
|
||||
const assetDimensions = $derived(
|
||||
(asset.width ?? 0) > 0 && (asset.height ?? 0) > 0 ? { width: asset.width!, height: asset.height! } : null,
|
||||
);
|
||||
const container = $derived({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
let dimensions = $derived(assetDimensions ?? { width: 1, height: 1 });
|
||||
const scaledDimensions = $derived(scaleToFit(dimensions, container));
|
||||
|
||||
onMount(() => {
|
||||
// Show video after mount to ensure fading in.
|
||||
showVideo = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// reactive on `assetFileUrl` changes
|
||||
if (assetFileUrl) {
|
||||
videoPlayer?.load();
|
||||
if (assetFileUrl && assetFileUrl !== previousAssetFileUrl) {
|
||||
previousAssetFileUrl = assetFileUrl;
|
||||
untrack(() => {
|
||||
isLoading = true;
|
||||
videoPlayer?.load();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,6 +99,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
dimensions = {
|
||||
width: videoPlayer?.videoWidth ?? 1,
|
||||
height: videoPlayer?.videoHeight ?? 1,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||
try {
|
||||
if (!video.paused && !isScrubbing) {
|
||||
@@ -100,76 +137,116 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
if (event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
};
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value) {
|
||||
videoPlayer?.pause();
|
||||
}
|
||||
});
|
||||
|
||||
const calculateSize = () => {
|
||||
const { width, height } = scaledDimensions;
|
||||
|
||||
const size = {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
};
|
||||
|
||||
return size;
|
||||
};
|
||||
|
||||
const box = $derived(calculateSize());
|
||||
</script>
|
||||
|
||||
{#if showVideo}
|
||||
<div
|
||||
transition:fade={{ duration: assetViewerFadeDuration }}
|
||||
class="flex h-full select-none place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
{onVideoEnded}
|
||||
{assetFileUrl}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
playsinline
|
||||
controls
|
||||
disablePictureInPicture
|
||||
class="h-full object-contain"
|
||||
{...useSwipe(onSwipe)}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={$videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
<SwipeFeedback
|
||||
class="flex select-none h-full w-full place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
{onSwipe}
|
||||
>
|
||||
{#if showVideo}
|
||||
<div
|
||||
in:fade={{ duration: assetViewerFadeDuration }}
|
||||
class="flex h-full w-full place-content-center place-items-center"
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
{onVideoEnded}
|
||||
{assetFileUrl}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<video
|
||||
style:height={box.height}
|
||||
style:width={box.width}
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
playsinline
|
||||
controls
|
||||
disablePictureInPicture
|
||||
onloadedmetadata={() => handleLoadedMetadata()}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={$videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="absolute flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
{#if isLoading}
|
||||
<div class="absolute inset-0 flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} assetId={effectiveAssetId} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet leftPreview()}
|
||||
{#if previousAsset}
|
||||
<AdaptiveImage
|
||||
asset={previousAsset}
|
||||
{sharedLink}
|
||||
container={{ width: containerWidth, height: containerHeight }}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rightPreview()}
|
||||
{#if nextAsset}
|
||||
<AdaptiveImage
|
||||
asset={nextAsset}
|
||||
{sharedLink}
|
||||
container={{ width: containerWidth, height: containerHeight }}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SwipeFeedback>
|
||||
|
||||
<style>
|
||||
video:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
|
||||
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import type { SharedLinkResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
cursor: AssetCursor;
|
||||
assetId?: string;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
projectionType: string | null | undefined;
|
||||
cacheKey: string | null;
|
||||
loopVideo: boolean;
|
||||
playOriginalVideo: boolean;
|
||||
onClose?: () => void;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onSwipe: (direction: 'left' | 'right') => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
asset,
|
||||
cursor,
|
||||
assetId,
|
||||
sharedLink,
|
||||
projectionType,
|
||||
cacheKey,
|
||||
loopVideo,
|
||||
playOriginalVideo,
|
||||
onPreviousAsset,
|
||||
onSwipe,
|
||||
onClose,
|
||||
onNextAsset,
|
||||
onVideoEnded,
|
||||
onVideoStarted,
|
||||
}: Props = $props();
|
||||
|
||||
const effectiveAssetId = $derived(assetId ?? asset.id);
|
||||
</script>
|
||||
|
||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<VideoPanoramaViewer {asset} />
|
||||
<VideoPanoramaViewer asset={cursor.current} />
|
||||
{:else}
|
||||
<VideoNativeViewer
|
||||
{loopVideo}
|
||||
{cacheKey}
|
||||
assetId={effectiveAssetId}
|
||||
{cursor}
|
||||
{assetId}
|
||||
{sharedLink}
|
||||
{playOriginalVideo}
|
||||
{onPreviousAsset}
|
||||
{onNextAsset}
|
||||
{onSwipe}
|
||||
{onVideoEnded}
|
||||
{onVideoStarted}
|
||||
{onClose}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -44,9 +44,7 @@
|
||||
{/if}
|
||||
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<DelayedLoadingSpinner />
|
||||
{:else if imageLoaded}
|
||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
||||
<img
|
||||
@@ -57,15 +55,3 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
type AllAssetMediaSize = AssetMediaSize | 'all';
|
||||
|
||||
class ImageManager {
|
||||
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
|
||||
for (const size of sizes) {
|
||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
if (url) {
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageManager = new ImageManager();
|
||||
@@ -21,6 +21,9 @@ import type {
|
||||
export type Events = {
|
||||
AppInit: [];
|
||||
|
||||
ResetSwipeFeedback: [];
|
||||
ViewerFinishNavigate: [];
|
||||
|
||||
AuthLogin: [LoginResponseDto];
|
||||
AuthLogout: [];
|
||||
AuthUserLoaded: [UserAdminResponseDto];
|
||||
|
||||
@@ -186,6 +186,14 @@ export const getAssetUrl = ({
|
||||
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) => {
|
||||
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
|
||||
};
|
||||
|
||||
185
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
185
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,19 @@ export interface ContentMetrics {
|
||||
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 = (
|
||||
dimensions: { width: number; height: number },
|
||||
container: { width: number; height: number },
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
/**
|
||||
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
|
||||
* This class helps manage concurrent operations by tracking which invocations are active
|
||||
@@ -53,14 +51,19 @@ export class InvocationTracker {
|
||||
return this.invocationsStarted !== this.invocationsEnded;
|
||||
}
|
||||
|
||||
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
|
||||
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
|
||||
const invocation = this.startInvocation();
|
||||
try {
|
||||
return await invocable();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, localizedMessage);
|
||||
if (catchCallback) {
|
||||
catchCallback(error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
invocation.endInvocation();
|
||||
finallyCallback?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
web/src/lib/utils/layout-utils.spec.ts
Normal file
54
web/src/lib/utils/layout-utils.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user