mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
fully rework/condense/simplify AdaptiveImage.svelte
This commit is contained in:
@@ -28,10 +28,9 @@ test.describe('Photo Viewer', () => {
|
|||||||
const original = page.getByTestId('original').filter({ visible: true });
|
const original = page.getByTestId('original').filter({ visible: true });
|
||||||
|
|
||||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||||
const box = await thumbnail.boundingBox();
|
|
||||||
expect(box).toBeTruthy();
|
const { width, height } = page.viewportSize()!;
|
||||||
const { x, y, width, height } = box!;
|
await page.mouse.move(width / 2, height / 2);
|
||||||
await page.mouse.move(x + width / 2, y + height / 2);
|
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
await expect(original).toBeInViewport();
|
await expect(original).toBeInViewport();
|
||||||
await expect(original).toHaveAttribute('src', /original/);
|
await expect(original).toHaveAttribute('src', /original/);
|
||||||
@@ -44,10 +43,9 @@ test.describe('Photo Viewer', () => {
|
|||||||
const original = page.getByTestId('original').filter({ visible: true });
|
const original = page.getByTestId('original').filter({ visible: true });
|
||||||
|
|
||||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||||
const box = await thumbnail.boundingBox();
|
|
||||||
expect(box).toBeTruthy();
|
const { width, height } = page.viewportSize()!;
|
||||||
const { x, y, width, height } = box!;
|
await page.mouse.move(width / 2, height / 2);
|
||||||
await page.mouse.move(x + width / 2, y + height / 2);
|
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,21 +2,21 @@
|
|||||||
import { thumbhash } from '$lib/actions/thumbhash';
|
import { thumbhash } from '$lib/actions/thumbhash';
|
||||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
import Image from '$lib/components/Image.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 { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { AdaptiveImageLoader, ImageStatus } from '$lib/utils/adaptive-image-loader.svelte';
|
import { getAssetUrls } from '$lib/utils';
|
||||||
import { getDimensions } from '$lib/utils/asset-utils';
|
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||||
import { scaleToFit } from '$lib/utils/container-utils';
|
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
|
||||||
import { untrack, type Snippet } from 'svelte';
|
import { untrack, type Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
sharedLink?: SharedLinkResponseDto;
|
sharedLink?: SharedLinkResponseDto;
|
||||||
imageClass?: string;
|
objectFit?: 'contain' | 'cover';
|
||||||
container: {
|
container: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
imgRef?: HTMLImageElement;
|
imgRef?: HTMLImageElement;
|
||||||
backdrop?: Snippet;
|
backdrop?: Snippet;
|
||||||
overlays?: Snippet;
|
overlays?: Snippet;
|
||||||
}
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(),
|
ref = $bindable(),
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
imgRef = $bindable(),
|
imgRef = $bindable(),
|
||||||
asset,
|
asset,
|
||||||
sharedLink,
|
sharedLink,
|
||||||
imageClass = '',
|
objectFit = 'contain',
|
||||||
container,
|
container,
|
||||||
onUrlChange,
|
onUrlChange,
|
||||||
onImageReady,
|
onImageReady,
|
||||||
@@ -45,6 +45,35 @@
|
|||||||
overlays,
|
overlays,
|
||||||
}: Props = $props();
|
}: 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 loaderKey = $derived(`${asset.id}:${asset.thumbhash}:${sharedLink?.id}`);
|
||||||
|
|
||||||
const adaptiveImageLoader = $derived.by(() => {
|
const adaptiveImageLoader = $derived.by(() => {
|
||||||
@@ -52,8 +81,7 @@
|
|||||||
|
|
||||||
return untrack(
|
return untrack(
|
||||||
() =>
|
() =>
|
||||||
new AdaptiveImageLoader(asset, sharedLink, {
|
new AdaptiveImageLoader(asset.id, buildQualityList(), {
|
||||||
currentZoomFn: () => assetViewerManager.zoom,
|
|
||||||
onImageReady,
|
onImageReady,
|
||||||
onError,
|
onError,
|
||||||
onUrlChange,
|
onUrlChange,
|
||||||
@@ -64,27 +92,20 @@
|
|||||||
$effect.pre(() => {
|
$effect.pre(() => {
|
||||||
const loader = adaptiveImageLoader;
|
const loader = adaptiveImageLoader;
|
||||||
untrack(() => assetViewerManager.resetZoomState());
|
untrack(() => assetViewerManager.resetZoomState());
|
||||||
return () => {
|
return () => loader.destroy();
|
||||||
loader.destroy();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageDimensions = $derived.by(() => {
|
const imageDimensions = $derived.by(() => {
|
||||||
if ((asset.width ?? 0) > 0 && (asset.height ?? 0) > 0) {
|
const { width, height } = asset;
|
||||||
return { width: asset.width!, height: asset.height! };
|
if (width && width > 0 && height && height > 0) {
|
||||||
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageWidth) {
|
|
||||||
return getDimensions(asset.exifInfo) as { width: number; height: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { width: 1, height: 1 };
|
return { width: 1, height: 1 };
|
||||||
});
|
});
|
||||||
|
|
||||||
const scaledDimensions = $derived(scaleToFit(imageDimensions, container));
|
const { width, height, left, top } = $derived.by(() => {
|
||||||
|
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||||
const renderDimensions = $derived.by(() => {
|
const { width, height } = scaleFn(imageDimensions, container);
|
||||||
const { width, height } = scaledDimensions;
|
|
||||||
return {
|
return {
|
||||||
width: width + 'px',
|
width: width + 'px',
|
||||||
height: height + 'px',
|
height: height + 'px',
|
||||||
@@ -93,146 +114,99 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const loaderState = $derived(adaptiveImageLoader.state);
|
const { status } = $derived(adaptiveImageLoader);
|
||||||
const imageAltText = $derived(loaderState.previewUrl ? $getAltText(toTimelineAsset(asset)) : '');
|
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
|
||||||
|
|
||||||
const showAlphaBackground = $derived(
|
const show = $derived.by(() => {
|
||||||
!loaderState.hasError &&
|
const { quality, started, hasError, urls } = status;
|
||||||
['thumbnail', 'loading-thumbnail', 'loading-preview', 'loading-original', 'preview', 'original'].includes(
|
return {
|
||||||
loaderState.quality,
|
alphaBackground: !hasError && started,
|
||||||
),
|
spinner: !asset.thumbhash && !started,
|
||||||
);
|
brokenAsset: hasError,
|
||||||
const showSpinner = $derived(!asset.thumbhash && loaderState.quality === 'basic');
|
thumbhash: quality.thumbnail !== 'success' && quality.preview !== 'success' && quality.original !== 'success',
|
||||||
const showBrokenAsset = $derived(loaderState.hasError);
|
thumbnail: quality.thumbnail !== 'error' && quality.preview !== 'success' && quality.original !== 'success',
|
||||||
const showThumbhash = $derived(['basic', 'loading-thumbnail'].includes(loaderState.quality));
|
preview: quality.preview !== 'error' && quality.original !== 'success',
|
||||||
const showThumbnail = true;
|
original: quality.original !== 'error' && urls.original !== undefined,
|
||||||
const showPreview = true;
|
};
|
||||||
const showOriginal = true;
|
});
|
||||||
|
|
||||||
// Effect: Upgrade to original when user zooms in
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (assetViewerManager.zoom > 1 && loaderState.quality === 'preview') {
|
if (assetViewerManager.zoom > 1 && status.quality.preview === 'success' && status.quality.original !== 'success') {
|
||||||
untrack(() => {
|
untrack(() => void adaptiveImageLoader.trigger('original'));
|
||||||
void adaptiveImageLoader.triggerOriginal();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let thumbnailElement = $state<HTMLImageElement>();
|
let thumbnailElement = $state<HTMLImageElement>();
|
||||||
let previewElement = $state<HTMLImageElement>();
|
let previewElement = $state<HTMLImageElement>();
|
||||||
let originalElement = $state<HTMLImageElement>();
|
let originalElement = $state<HTMLImageElement>();
|
||||||
|
|
||||||
const loadedOriginalElement = $derived(
|
|
||||||
loaderState.originalImage === ImageStatus.Success ? originalElement : undefined,
|
|
||||||
);
|
|
||||||
const loadedPreviewElement = $derived(loaderState.previewImage === ImageStatus.Success ? previewElement : undefined);
|
|
||||||
const loadedThumbnailElement = $derived(
|
|
||||||
loaderState.thumbnailImage === ImageStatus.Success ? thumbnailElement : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
imgRef = loadedOriginalElement ?? loadedPreviewElement ?? loadedThumbnailElement;
|
const quality = status.quality;
|
||||||
|
imgRef =
|
||||||
|
(quality.original === 'success' ? originalElement : undefined) ??
|
||||||
|
(quality.preview === 'success' ? previewElement : undefined) ??
|
||||||
|
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative h-full w-full" bind:this={ref}>
|
<div class="relative h-full w-full overflow-hidden" bind:this={ref}>
|
||||||
{@render backdrop?.()}
|
{@render backdrop?.()}
|
||||||
|
|
||||||
<div
|
<div class="absolute" style:left style:top style:width style:height>
|
||||||
class="absolute"
|
{#if show.alphaBackground}
|
||||||
style:left={renderDimensions.left}
|
<AlphaBackground />
|
||||||
style:top={renderDimensions.top}
|
|
||||||
style:width={renderDimensions.width}
|
|
||||||
style:height={renderDimensions.height}
|
|
||||||
>
|
|
||||||
{#if showAlphaBackground}
|
|
||||||
<AlphaBackground class="-z-3" />
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showThumbhash}
|
{#if show.thumbhash}
|
||||||
{#if asset.thumbhash}
|
{#if asset.thumbhash}
|
||||||
<!-- Thumbhash / spinner layer -->
|
<!-- Thumbhash / spinner layer -->
|
||||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
|
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
||||||
{:else if showSpinner}
|
{:else if show.spinner}
|
||||||
<div id="spinner" class="absolute flex h-full items-center justify-center">
|
<DelayedLoadingSpinner />
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showThumbnail}
|
{#if show.thumbnail}
|
||||||
{#key adaptiveImageLoader}
|
<ImageLayer
|
||||||
{@const loader = adaptiveImageLoader}
|
{adaptiveImageLoader}
|
||||||
<div class="absolute top-0 z-1" style:width={renderDimensions.width} style:height={renderDimensions.height}>
|
{width}
|
||||||
<Image
|
{height}
|
||||||
src={loaderState.thumbnailUrl}
|
quality="thumbnail"
|
||||||
onStart={() => loader.onThumbnailStart()}
|
src={status.urls.thumbnail}
|
||||||
onLoad={() => loader.onThumbnailLoad()}
|
alt=""
|
||||||
onError={() => loader.onThumbnailError()}
|
role="presentation"
|
||||||
bind:ref={thumbnailElement}
|
bind:ref={thumbnailElement}
|
||||||
class={['absolute h-full', 'w-full']}
|
/>
|
||||||
alt=""
|
|
||||||
role="presentation"
|
|
||||||
data-testid="thumbnail"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showBrokenAsset}
|
{#if show.brokenAsset}
|
||||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showPreview}
|
{#if show.preview}
|
||||||
{#key adaptiveImageLoader}
|
<ImageLayer
|
||||||
{@const loader = adaptiveImageLoader}
|
{adaptiveImageLoader}
|
||||||
<div class="absolute top-0 z-2" style:width={renderDimensions.width} style:height={renderDimensions.height}>
|
{alt}
|
||||||
<Image
|
{width}
|
||||||
src={loaderState.previewUrl}
|
{height}
|
||||||
onStart={() => loader.onPreviewStart()}
|
{overlays}
|
||||||
onLoad={() => loader.onPreviewLoad()}
|
quality="preview"
|
||||||
onError={() => loader.onPreviewError()}
|
src={status.urls.preview}
|
||||||
bind:ref={previewElement}
|
bind:ref={previewElement}
|
||||||
class={['h-full', 'w-full', imageClass]}
|
/>
|
||||||
alt={imageAltText}
|
|
||||||
draggable={false}
|
|
||||||
data-testid="preview"
|
|
||||||
/>
|
|
||||||
{@render overlays?.()}
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showOriginal}
|
{#if show.original}
|
||||||
{#key adaptiveImageLoader}
|
<ImageLayer
|
||||||
{@const loader = adaptiveImageLoader}
|
{adaptiveImageLoader}
|
||||||
<div class="absolute top-0 z-3" style:width={renderDimensions.width} style:height={renderDimensions.height}>
|
{alt}
|
||||||
<Image
|
{width}
|
||||||
src={loaderState.originalUrl}
|
{height}
|
||||||
onStart={() => loader.onOriginalStart()}
|
{overlays}
|
||||||
onLoad={() => loader.onOriginalLoad()}
|
quality="original"
|
||||||
onError={() => loader.onOriginalError()}
|
src={status.urls.original}
|
||||||
bind:ref={originalElement}
|
bind:ref={originalElement}
|
||||||
class={['h-full', 'w-full', imageClass]}
|
/>
|
||||||
alt={imageAltText}
|
|
||||||
draggable={false}
|
|
||||||
data-testid="original"
|
|
||||||
/>
|
|
||||||
{@render overlays?.()}
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes delayedVisibility {
|
|
||||||
to {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#spinner {
|
|
||||||
visibility: hidden;
|
|
||||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Image from '$lib/components/Image.svelte';
|
||||||
|
import type { AdaptiveImageLoader, ImageQuality } from '$lib/utils/adaptive-image-loader.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
adaptiveImageLoader: AdaptiveImageLoader;
|
||||||
|
quality: ImageQuality;
|
||||||
|
src: string | undefined;
|
||||||
|
alt?: string;
|
||||||
|
role?: string;
|
||||||
|
ref?: HTMLImageElement;
|
||||||
|
width: string;
|
||||||
|
height: string;
|
||||||
|
overlays?: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
adaptiveImageLoader,
|
||||||
|
quality,
|
||||||
|
src,
|
||||||
|
alt = '',
|
||||||
|
role,
|
||||||
|
ref = $bindable(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
overlays,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key adaptiveImageLoader}
|
||||||
|
<div class="absolute top-0" style:width style:height>
|
||||||
|
<Image
|
||||||
|
{src}
|
||||||
|
onStart={() => adaptiveImageLoader.onStart(quality)}
|
||||||
|
onLoad={() => adaptiveImageLoader.onLoad(quality)}
|
||||||
|
onError={() => adaptiveImageLoader.onError(quality)}
|
||||||
|
bind:ref
|
||||||
|
class="h-full w-full"
|
||||||
|
{alt}
|
||||||
|
{role}
|
||||||
|
draggable={false}
|
||||||
|
data-testid={quality}
|
||||||
|
/>
|
||||||
|
{@render overlays?.()}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { loadImage } from '$lib/actions/image-loader.svelte';
|
||||||
|
import { getAssetUrls } from '$lib/utils';
|
||||||
|
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||||
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
|
type AssetCursor = {
|
||||||
|
current: AssetResponseDto;
|
||||||
|
nextAsset?: AssetResponseDto;
|
||||||
|
previousAsset?: AssetResponseDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PreloadManager {
|
||||||
|
private nextPreloader: AdaptiveImageLoader | undefined;
|
||||||
|
private previousPreloader: AdaptiveImageLoader | undefined;
|
||||||
|
|
||||||
|
private startPreloader(asset: AssetResponseDto | undefined): AdaptiveImageLoader | undefined {
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const urls = getAssetUrls(asset);
|
||||||
|
const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview');
|
||||||
|
const qualityList: QualityList = [
|
||||||
|
{
|
||||||
|
quality: 'thumbnail',
|
||||||
|
url: urls.thumbnail,
|
||||||
|
checkCanceled: false,
|
||||||
|
onAfterLoad: afterThumbnail,
|
||||||
|
onAfterError: afterThumbnail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quality: 'preview',
|
||||||
|
url: urls.preview,
|
||||||
|
checkCanceled: true,
|
||||||
|
onAfterError: (loader) => loader.trigger('original'),
|
||||||
|
},
|
||||||
|
{ quality: 'original', url: urls.original, checkCanceled: true },
|
||||||
|
];
|
||||||
|
const loader = new AdaptiveImageLoader(asset.id, qualityList, undefined, loadImage);
|
||||||
|
loader.start();
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyPreviousPreloader() {
|
||||||
|
this.previousPreloader?.destroy();
|
||||||
|
this.previousPreloader = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyNextPreloader() {
|
||||||
|
this.nextPreloader?.destroy();
|
||||||
|
this.nextPreloader = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelBeforeNavigation(direction: 'previous' | 'next') {
|
||||||
|
switch (direction) {
|
||||||
|
case 'next': {
|
||||||
|
this.destroyPreviousPreloader();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'previous': {
|
||||||
|
this.destroyNextPreloader();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAfterNavigation(oldCursor: AssetCursor, newCursor: AssetCursor) {
|
||||||
|
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
|
||||||
|
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
|
||||||
|
|
||||||
|
if (!movedBackward) {
|
||||||
|
this.destroyPreviousPreloader();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!movedForward) {
|
||||||
|
this.destroyNextPreloader();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movedForward) {
|
||||||
|
this.nextPreloader = this.startPreloader(newCursor.nextAsset);
|
||||||
|
} else if (movedBackward) {
|
||||||
|
this.previousPreloader = this.startPreloader(newCursor.previousAsset);
|
||||||
|
} else {
|
||||||
|
this.previousPreloader = this.startPreloader(newCursor.previousAsset);
|
||||||
|
this.nextPreloader = this.startPreloader(newCursor.nextAsset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializePreloads(cursor: AssetCursor) {
|
||||||
|
if (cursor.nextAsset) {
|
||||||
|
this.nextPreloader = this.startPreloader(cursor.nextAsset);
|
||||||
|
}
|
||||||
|
if (cursor.previousAsset) {
|
||||||
|
this.previousPreloader = this.startPreloader(cursor.previousAsset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.destroyNextPreloader();
|
||||||
|
this.destroyPreviousPreloader();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const preloadManager = new PreloadManager();
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
import { loadImage } from '$lib/actions/image-loader.svelte';
|
|
||||||
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||||
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
|
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
|
||||||
|
import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte';
|
||||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { getSharedLink, handlePromiseError } from '$lib/utils';
|
import { getSharedLink, handlePromiseError } from '$lib/utils';
|
||||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||||
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
|
|
||||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||||
@@ -171,8 +170,7 @@
|
|||||||
activityManager.reset();
|
activityManager.reset();
|
||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
syncAssetViewerOpenClass(false);
|
syncAssetViewerOpenClass(false);
|
||||||
destroyNextPreloader();
|
preloadManager.destroy();
|
||||||
destroyPreviousPreloader();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeViewer = () => {
|
const closeViewer = () => {
|
||||||
@@ -188,62 +186,6 @@
|
|||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
let nextPreloader: AdaptiveImageLoader | undefined;
|
|
||||||
let previousPreloader: AdaptiveImageLoader | undefined;
|
|
||||||
|
|
||||||
const startPreloader = (asset: AssetResponseDto | undefined) => {
|
|
||||||
if (!asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const loader = new AdaptiveImageLoader(asset, undefined, undefined, loadImage);
|
|
||||||
loader.start();
|
|
||||||
return loader;
|
|
||||||
};
|
|
||||||
|
|
||||||
const destroyPreviousPreloader = () => {
|
|
||||||
previousPreloader?.destroy();
|
|
||||||
previousPreloader = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const destroyNextPreloader = () => {
|
|
||||||
nextPreloader?.destroy();
|
|
||||||
nextPreloader = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => {
|
|
||||||
if (direction === 'next') {
|
|
||||||
destroyPreviousPreloader();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
destroyNextPreloader();
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePreloadsAfterNavigation = (oldCursor: AssetCursor, newCursor: AssetCursor) => {
|
|
||||||
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
|
|
||||||
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
|
|
||||||
|
|
||||||
const shouldDestroyPrevious = !movedBackward;
|
|
||||||
const shouldDestroyNext = !movedForward;
|
|
||||||
|
|
||||||
if (shouldDestroyPrevious) {
|
|
||||||
destroyPreviousPreloader();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldDestroyNext) {
|
|
||||||
destroyNextPreloader();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (movedForward) {
|
|
||||||
nextPreloader = startPreloader(newCursor.nextAsset);
|
|
||||||
} else if (movedBackward) {
|
|
||||||
previousPreloader = startPreloader(newCursor.previousAsset);
|
|
||||||
} else {
|
|
||||||
// Non-adjacent navigation (e.g., slideshow random)
|
|
||||||
previousPreloader = startPreloader(newCursor.previousAsset);
|
|
||||||
nextPreloader = startPreloader(newCursor.nextAsset);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tracker = new InvocationTracker();
|
const tracker = new InvocationTracker();
|
||||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
@@ -254,7 +196,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelPreloadsBeforeNavigation(order);
|
preloadManager.cancelBeforeNavigation(order);
|
||||||
|
|
||||||
if (tracker.isActive()) {
|
if (tracker.isActive()) {
|
||||||
return;
|
return;
|
||||||
@@ -333,16 +275,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
|
||||||
if (isMouseOver) {
|
|
||||||
previewStackedAsset = stackedAsset;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStackedAssetsMouseLeave = () => {
|
|
||||||
previewStackedAsset = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreAction = (action: Action) => {
|
const handlePreAction = (action: Action) => {
|
||||||
preAction?.(action);
|
preAction?.(action);
|
||||||
};
|
};
|
||||||
@@ -436,17 +368,10 @@
|
|||||||
if (lastCursor) {
|
if (lastCursor) {
|
||||||
selectedStackAsset = undefined;
|
selectedStackAsset = undefined;
|
||||||
previewStackedAsset = undefined;
|
previewStackedAsset = undefined;
|
||||||
// After navigation completes, reconcile preloads with full state information
|
preloadManager.updateAfterNavigation(lastCursor, cursor);
|
||||||
updatePreloadsAfterNavigation(lastCursor, cursor);
|
|
||||||
}
|
}
|
||||||
if (!lastCursor) {
|
if (!lastCursor) {
|
||||||
// "first time" load, start preloads
|
preloadManager.initializePreloads(cursor);
|
||||||
if (cursor.nextAsset) {
|
|
||||||
nextPreloader = startPreloader(cursor.nextAsset);
|
|
||||||
}
|
|
||||||
if (cursor.previousAsset) {
|
|
||||||
previousPreloader = startPreloader(cursor.previousAsset);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
lastCursor = cursor;
|
lastCursor = cursor;
|
||||||
});
|
});
|
||||||
@@ -661,7 +586,7 @@
|
|||||||
<div
|
<div
|
||||||
role="presentation"
|
role="presentation"
|
||||||
class="relative inline-flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
|
class="relative inline-flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
|
||||||
onmouseleave={handleStackedAssetsMouseLeave}
|
onmouseleave={() => (previewStackedAsset = undefined)}
|
||||||
>
|
>
|
||||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||||
<div
|
<div
|
||||||
@@ -677,7 +602,7 @@
|
|||||||
selectedStackAsset = stackedAsset;
|
selectedStackAsset = stackedAsset;
|
||||||
previewStackedAsset = undefined;
|
previewStackedAsset = undefined;
|
||||||
}}
|
}}
|
||||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
onMouseEvent={({ isMouseOver }) => isMouseOver && (previewStackedAsset = stackedAsset)}
|
||||||
readonly
|
readonly
|
||||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||||
showStackedIcon={false}
|
showStackedIcon={false}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import { type ContentMetrics, getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
@@ -163,7 +163,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AssetViewerEvents {onCopy} {onZoom} />
|
<AssetViewerEvents {onCopy} {onZoom} />
|
||||||
@@ -190,7 +189,7 @@
|
|||||||
{asset}
|
{asset}
|
||||||
{sharedLink}
|
{sharedLink}
|
||||||
{container}
|
{container}
|
||||||
imageClass={`${$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook]}`}
|
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
||||||
{onUrlChange}
|
{onUrlChange}
|
||||||
onImageReady={() => {
|
onImageReady={() => {
|
||||||
visibleImageReady = true;
|
visibleImageReady = true;
|
||||||
@@ -207,20 +206,23 @@
|
|||||||
{#if blurredSlideshow}
|
{#if blurredSlideshow}
|
||||||
<canvas
|
<canvas
|
||||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
|
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
|
||||||
class="-z-1 absolute top-0 left-0 start-0 h-dvh w-dvw"
|
class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw"
|
||||||
></canvas>
|
></canvas>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet overlays()}
|
{#snippet overlays()}
|
||||||
{#if !isFaceEditMode.value && !ocrManager.showOverlay}
|
{#if !isFaceEditMode.value && !ocrManager.showOverlay}
|
||||||
{#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
{#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
||||||
<div
|
<button
|
||||||
class="absolute pointer-events-auto"
|
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;"
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
role="presentation"
|
aria-label="{$t('person')}: {faceToNameMap.get(faces[index]) || $t('unknown')}"
|
||||||
onmouseenter={() => { $boundingBoxesArray = [faces[index]]; }}
|
onmouseenter={() => ($boundingBoxesArray = [faces[index]])}
|
||||||
onmouseleave={() => { $boundingBoxesArray = []; }}
|
onmouseleave={() => ($boundingBoxesArray = [])}
|
||||||
></div>
|
onfocus={() => ($boundingBoxesArray = [faces[index]])}
|
||||||
|
onblur={() => ($boundingBoxesArray = [])}
|
||||||
|
></button>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -231,6 +233,7 @@
|
|||||||
></div>
|
></div>
|
||||||
{#if faceToNameMap.get($boundingBoxesArray[index])}
|
{#if faceToNameMap.get($boundingBoxesArray[index])}
|
||||||
<div
|
<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"
|
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 +
|
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
|
||||||
boundingbox.width}px; transform: translateX(-100%);"
|
boundingbox.width}px; transform: translateX(-100%);"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { getAssetMediaUrl } from '$lib/utils';
|
import { getAssetMediaUrl } from '$lib/utils';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
@@ -44,9 +44,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !imageLoaded}
|
{#if !imageLoaded}
|
||||||
<div id="spinner" class="flex h-full items-center justify-center">
|
<DelayedLoadingSpinner />
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
{:else if imageLoaded}
|
{:else if imageLoaded}
|
||||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
||||||
<img
|
<img
|
||||||
@@ -57,15 +55,3 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes delayedVisibility {
|
|
||||||
to {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#spinner {
|
|
||||||
visibility: hidden;
|
|
||||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -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,62 +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';
|
|
||||||
|
|
||||||
type AssetLoadState = 'loading' | 'cancelled';
|
|
||||||
|
|
||||||
class ImageManager {
|
|
||||||
private assetStates = new Map<string, AssetLoadState>();
|
|
||||||
private readonly MAX_TRACKED_ASSETS = 10;
|
|
||||||
|
|
||||||
private trackAction(asset: AssetResponseDto, action: AssetLoadState) {
|
|
||||||
this.assetStates.delete(asset.id);
|
|
||||||
this.assetStates.set(asset.id, action);
|
|
||||||
|
|
||||||
if (this.assetStates.size > this.MAX_TRACKED_ASSETS) {
|
|
||||||
const firstKey = this.assetStates.keys().next().value!;
|
|
||||||
this.assetStates.delete(firstKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isCanceled(asset: AssetResponseDto) {
|
|
||||||
return 'cancelled' === this.assetStates.get(asset.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
trackLoad(asset: AssetResponseDto) {
|
|
||||||
this.trackAction(asset, 'loading');
|
|
||||||
}
|
|
||||||
|
|
||||||
trackCancelled(asset: AssetResponseDto) {
|
|
||||||
this.trackAction(asset, 'cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
|
|
||||||
if (!asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const src = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
|
||||||
this.trackLoad(asset);
|
|
||||||
const img = new Image();
|
|
||||||
img.src = src;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
|
|
||||||
if (!asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.trackCancelled(asset);
|
|
||||||
|
|
||||||
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();
|
|
||||||
@@ -186,6 +186,14 @@ export const getAssetUrl = ({
|
|||||||
return getAssetMediaUrl({ id, size, cacheKey });
|
return getAssetMediaUrl({ id, size, cacheKey });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getAssetUrls(asset: AssetResponseDto, sharedLink?: SharedLinkResponseDto) {
|
||||||
|
return {
|
||||||
|
thumbnail: getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail }),
|
||||||
|
preview: getAssetUrl({ asset, sharedLink })!,
|
||||||
|
original: getAssetUrl({ asset, sharedLink, forceOriginal: true })!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const forceUseOriginal = (asset: AssetResponseDto) => {
|
const forceUseOriginal = (asset: AssetResponseDto) => {
|
||||||
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
|
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,290 +1,175 @@
|
|||||||
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
|
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
|
||||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||||
import { getAssetMediaUrl, getAssetUrl } from '$lib/utils';
|
|
||||||
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
|
||||||
|
|
||||||
/**
|
export type ImageQuality = 'thumbnail' | 'preview' | 'original';
|
||||||
* Quality levels for progressive image loading
|
|
||||||
*/
|
|
||||||
type ImageQuality =
|
|
||||||
| 'basic'
|
|
||||||
| 'loading-thumbnail'
|
|
||||||
| 'thumbnail'
|
|
||||||
| 'loading-preview'
|
|
||||||
| 'preview'
|
|
||||||
| 'loading-original'
|
|
||||||
| 'original';
|
|
||||||
|
|
||||||
const qualityOrder: Record<ImageQuality, number> = {
|
export type ImageStatus = 'unloaded' | 'success' | 'error';
|
||||||
basic: 0,
|
|
||||||
'loading-thumbnail': 1,
|
export type ImageLoaderStatus = {
|
||||||
thumbnail: 2,
|
urls: Record<ImageQuality, string | undefined>;
|
||||||
'loading-preview': 3,
|
quality: Record<ImageQuality, ImageStatus>;
|
||||||
preview: 4,
|
started: boolean;
|
||||||
'loading-original': 5,
|
hasError: boolean;
|
||||||
original: 6,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ImageLoaderState {
|
type ImageLoaderCallbacks = {
|
||||||
previewUrl?: string;
|
onUrlChange?: (url: string) => void;
|
||||||
thumbnailUrl?: string;
|
onImageReady?: () => void;
|
||||||
originalUrl?: string;
|
onError?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QualityConfig = {
|
||||||
|
url: string;
|
||||||
quality: ImageQuality;
|
quality: ImageQuality;
|
||||||
hasError: boolean;
|
checkCanceled: boolean;
|
||||||
thumbnailImage: ImageStatus;
|
onAfterLoad?: (loader: AdaptiveImageLoader) => void;
|
||||||
previewImage: ImageStatus;
|
onAfterError?: (loader: AdaptiveImageLoader) => void;
|
||||||
originalImage: ImageStatus;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export enum ImageStatus {
|
const MAX_TRACKED_ASSETS = 10;
|
||||||
Unloaded = 'Unloaded',
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
Success = 'Success',
|
const tracker = new Map<string, 'loading' | 'canceled'>();
|
||||||
Error = 'Error',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const updateTracker = (id: string, action: 'loading' | 'canceled') => {
|
||||||
* Coordinates adaptive loading of a single asset image:
|
tracker.delete(id);
|
||||||
* thumbhash → thumbnail → preview → original (on zoom)
|
tracker.set(id, action);
|
||||||
*
|
|
||||||
*/
|
if (tracker.size > MAX_TRACKED_ASSETS) {
|
||||||
let nextLoaderId = 0;
|
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 {
|
export class AdaptiveImageLoader {
|
||||||
readonly id = nextLoaderId++;
|
private destroyFunctions: (() => void)[] = [];
|
||||||
|
private qualityConfigs: Record<ImageQuality, QualityConfig>;
|
||||||
|
private highestLoadedQualityIndex = -1;
|
||||||
|
private destroyed = false;
|
||||||
|
|
||||||
private internalState = $state<ImageLoaderState>({
|
status = $state<ImageLoaderStatus>({
|
||||||
quality: 'basic',
|
started: false,
|
||||||
hasError: false,
|
hasError: false,
|
||||||
thumbnailImage: ImageStatus.Unloaded,
|
urls: { thumbnail: undefined, preview: undefined, original: undefined },
|
||||||
previewImage: ImageStatus.Unloaded,
|
quality: { thumbnail: 'unloaded', preview: 'unloaded', original: 'unloaded' },
|
||||||
originalImage: ImageStatus.Unloaded,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly currentZoomFn?: () => number;
|
|
||||||
private readonly imageLoader?: LoadImageFunction;
|
|
||||||
private readonly destroyFunctions: (() => void)[] = [];
|
|
||||||
readonly thumbnailUrl: string;
|
|
||||||
readonly previewUrl: string;
|
|
||||||
readonly originalUrl: string;
|
|
||||||
readonly asset: AssetResponseDto;
|
|
||||||
readonly sharedLink?: SharedLinkResponseDto;
|
|
||||||
readonly callbacks?: {
|
|
||||||
currentZoomFn: () => number;
|
|
||||||
onUrlChange?: (url: string) => void;
|
|
||||||
onImageReady?: () => void;
|
|
||||||
onError?: () => void;
|
|
||||||
};
|
|
||||||
destroyed = false;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
asset: AssetResponseDto,
|
private readonly id: string,
|
||||||
sharedLink: SharedLinkResponseDto | undefined,
|
private readonly qualityList: QualityList,
|
||||||
callbacks?: {
|
private readonly callbacks?: ImageLoaderCallbacks,
|
||||||
currentZoomFn: () => number;
|
private readonly imageLoader?: LoadImageFunction,
|
||||||
onUrlChange?: (url: string) => void;
|
|
||||||
onImageReady?: () => void;
|
|
||||||
onError?: () => void;
|
|
||||||
},
|
|
||||||
imageLoader?: LoadImageFunction,
|
|
||||||
) {
|
) {
|
||||||
imageManager.trackLoad(asset);
|
this.qualityConfigs = {
|
||||||
this.asset = asset;
|
thumbnail: qualityList[0],
|
||||||
this.callbacks = callbacks;
|
preview: qualityList[1],
|
||||||
this.imageLoader = imageLoader;
|
original: qualityList[2],
|
||||||
this.thumbnailUrl = getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail });
|
};
|
||||||
this.previewUrl = getAssetUrl({ asset, sharedLink })!;
|
this.status.urls.thumbnail = qualityList[0].url;
|
||||||
this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true })!;
|
setLoading(id);
|
||||||
this.internalState.thumbnailUrl = this.thumbnailUrl;
|
|
||||||
this.sharedLink = sharedLink;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
if (!this.imageLoader) {
|
if (!this.imageLoader) {
|
||||||
throw new Error('Start requires imageLoader to be specified');
|
throw new Error('Start requires imageLoader to be specified');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.destroyFunctions.push(
|
this.destroyFunctions.push(
|
||||||
this.imageLoader(
|
this.imageLoader(
|
||||||
this.thumbnailUrl,
|
this.qualityList[0].url,
|
||||||
() => this.onThumbnailLoad(),
|
() => this.onLoad('thumbnail'),
|
||||||
() => this.onThumbnailError(),
|
() => this.onError('thumbnail'),
|
||||||
() => this.onThumbnailStart(),
|
() => this.onStart('thumbnail'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get state(): ImageLoaderState {
|
onStart(quality: ImageQuality) {
|
||||||
return this.internalState;
|
const config = this.qualityConfigs[quality];
|
||||||
|
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.status.started = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldUpdateQuality(newQuality: ImageQuality): boolean {
|
onLoad(quality: ImageQuality) {
|
||||||
const currentLevel = qualityOrder[this.internalState.quality];
|
const config = this.qualityConfigs[quality];
|
||||||
const newLevel = qualityOrder[newQuality];
|
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
|
||||||
return newLevel > currentLevel;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onThumbnailStart() {
|
if (!this.status.urls[quality]) {
|
||||||
if (this.destroyed) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.shouldUpdateQuality('loading-thumbnail')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.internalState.quality = 'loading-thumbnail';
|
|
||||||
}
|
|
||||||
|
|
||||||
onThumbnailLoad() {
|
const index = this.qualityList.indexOf(config);
|
||||||
if (this.destroyed) {
|
if (index <= this.highestLoadedQualityIndex) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.shouldUpdateQuality('thumbnail')) {
|
|
||||||
return;
|
this.highestLoadedQualityIndex = index;
|
||||||
}
|
this.status.quality[quality] = 'success';
|
||||||
this.internalState.quality = 'thumbnail';
|
this.callbacks?.onUrlChange?.(this.qualityConfigs[quality].url);
|
||||||
this.internalState.thumbnailImage = ImageStatus.Success;
|
|
||||||
this.callbacks?.onUrlChange?.(this.thumbnailUrl);
|
|
||||||
this.callbacks?.onImageReady?.();
|
this.callbacks?.onImageReady?.();
|
||||||
this.triggerMainImage();
|
|
||||||
|
config.onAfterLoad?.(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onThumbnailError() {
|
onError(quality: ImageQuality) {
|
||||||
if (this.destroyed) {
|
const config = this.qualityConfigs[quality];
|
||||||
|
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.internalState.hasError = true;
|
|
||||||
this.internalState.thumbnailUrl = undefined;
|
this.status.hasError = true;
|
||||||
this.internalState.thumbnailImage = ImageStatus.Error;
|
this.status.quality[quality] = 'error';
|
||||||
|
this.status.urls[quality] = undefined;
|
||||||
this.callbacks?.onError?.();
|
this.callbacks?.onError?.();
|
||||||
this.triggerMainImage();
|
|
||||||
|
config.onAfterError?.(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerMainImage() {
|
trigger(quality: ImageQuality) {
|
||||||
const wantsOriginal = (this.currentZoomFn?.() ?? 1) > 1;
|
if (this.destroyed) {
|
||||||
return wantsOriginal ? this.triggerOriginal() : this.triggerPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerPreview() {
|
|
||||||
if (!this.previewUrl) {
|
|
||||||
// no preview, try original?
|
|
||||||
this.triggerOriginal();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (this.internalState.previewUrl) {
|
|
||||||
// Already triggered
|
const url = this.qualityConfigs[quality].url;
|
||||||
|
if (!url) {
|
||||||
|
this.qualityConfigs[quality].onAfterError?.(this);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.status.urls[quality]) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.internalState.hasError = false;
|
|
||||||
this.internalState.previewUrl = this.previewUrl;
|
this.status.hasError = false;
|
||||||
|
this.status.urls[quality] = url;
|
||||||
if (this.imageLoader) {
|
if (this.imageLoader) {
|
||||||
this.destroyFunctions.push(
|
this.destroyFunctions.push(
|
||||||
this.imageLoader(
|
this.imageLoader(
|
||||||
this.previewUrl,
|
url,
|
||||||
|
() => this.onLoad(quality),
|
||||||
() => this.onPreviewLoad(),
|
() => this.onError(quality),
|
||||||
() => this.onPreviewError(),
|
() => this.onStart(quality),
|
||||||
() => this.onPreviewStart(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onPreviewStart() {
|
destroy() {
|
||||||
if (this.destroyed) {
|
setCanceled(this.id);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.shouldUpdateQuality('loading-preview')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.internalState.quality = 'loading-preview';
|
|
||||||
}
|
|
||||||
|
|
||||||
onPreviewLoad() {
|
|
||||||
if (this.destroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.internalState.previewUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.shouldUpdateQuality('preview')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.internalState.quality = 'preview';
|
|
||||||
this.internalState.previewImage = ImageStatus.Success;
|
|
||||||
this.callbacks?.onUrlChange?.(this.previewUrl);
|
|
||||||
this.callbacks?.onImageReady?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
onPreviewError() {
|
|
||||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.internalState.hasError = true;
|
|
||||||
this.internalState.previewImage = ImageStatus.Error;
|
|
||||||
this.internalState.previewUrl = undefined;
|
|
||||||
this.callbacks?.onError?.();
|
|
||||||
this.triggerOriginal();
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerOriginal() {
|
|
||||||
if (!this.originalUrl) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.internalState.originalUrl) {
|
|
||||||
// Already triggered
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
this.internalState.hasError = false;
|
|
||||||
this.internalState.originalUrl = this.originalUrl;
|
|
||||||
|
|
||||||
if (this.imageLoader) {
|
|
||||||
this.destroyFunctions.push(
|
|
||||||
this.imageLoader(
|
|
||||||
this.originalUrl,
|
|
||||||
|
|
||||||
() => this.onOriginalLoad(),
|
|
||||||
() => this.onOriginalError(),
|
|
||||||
() => this.onOriginalStart(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onOriginalStart() {
|
|
||||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.shouldUpdateQuality('loading-original')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.internalState.quality = 'loading-original';
|
|
||||||
}
|
|
||||||
|
|
||||||
onOriginalLoad() {
|
|
||||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.internalState.originalUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.shouldUpdateQuality('original')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.internalState.quality = 'original';
|
|
||||||
this.internalState.originalImage = ImageStatus.Success;
|
|
||||||
this.callbacks?.onUrlChange?.(this.originalUrl);
|
|
||||||
this.callbacks?.onImageReady?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
onOriginalError() {
|
|
||||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.internalState.hasError = true;
|
|
||||||
this.internalState.originalImage = ImageStatus.Error;
|
|
||||||
this.internalState.originalUrl = undefined;
|
|
||||||
this.callbacks?.onError?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy(): void {
|
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
if (this.imageLoader) {
|
if (this.imageLoader) {
|
||||||
for (const destroy of this.destroyFunctions) {
|
for (const destroy of this.destroyFunctions) {
|
||||||
@@ -292,9 +177,9 @@ export class AdaptiveImageLoader {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.cancel(this.asset);
|
|
||||||
}
|
for (const config of Object.values(this.qualityConfigs)) {
|
||||||
cancel(asset: AssetResponseDto | undefined) {
|
cancelImageUrl(config.url);
|
||||||
imageManager.cancel(asset);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ export interface ContentMetrics {
|
|||||||
offsetY: number;
|
offsetY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const scaleToCover = (
|
||||||
|
dimensions: { width: number; height: number },
|
||||||
|
container: { width: number; height: number },
|
||||||
|
): { width: number; height: number } => {
|
||||||
|
const scaleX = container.width / dimensions.width;
|
||||||
|
const scaleY = container.height / dimensions.height;
|
||||||
|
const scale = Math.max(scaleX, scaleY);
|
||||||
|
return {
|
||||||
|
width: dimensions.width * scale,
|
||||||
|
height: dimensions.height * scale,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const scaleToFit = (
|
export const scaleToFit = (
|
||||||
dimensions: { width: number; height: number },
|
dimensions: { width: number; height: number },
|
||||||
container: { width: number; height: number },
|
container: { width: number; height: number },
|
||||||
|
|||||||
Reference in New Issue
Block a user