mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
fix z-ordering, refresh selected in stacked viewer, ocr/face in stacked viewer
This commit is contained in:
@@ -31,8 +31,8 @@ test.describe('Photo Viewer', () => {
|
|||||||
test('loads original photo when zoomed', async ({ page }) => {
|
test('loads original photo when zoomed', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
|
||||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
await expect(preview).toHaveAttribute('src', /.+/);
|
||||||
|
|
||||||
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
|
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ test.describe('Photo Viewer', () => {
|
|||||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||||
await page.goto(`/photos/${rawAsset.id}`);
|
await page.goto(`/photos/${rawAsset.id}`);
|
||||||
|
|
||||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
await expect(preview).toHaveAttribute('src', /.+/);
|
||||||
|
|
||||||
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
|
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
|
||||||
|
|
||||||
@@ -67,15 +67,14 @@ test.describe('Photo Viewer', () => {
|
|||||||
test('reloads photo when checksum changes', async ({ page }) => {
|
test('reloads photo when checksum changes', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
|
||||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
await expect(preview).toHaveAttribute('src', /.+/);
|
||||||
const initialSrc = await thumbnail.getAttribute('src');
|
const initialSrc = await preview.getAttribute('src');
|
||||||
|
|
||||||
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
||||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||||
await websocketEvent;
|
await websocketEvent;
|
||||||
|
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
|
||||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
|
|||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
await page.waitForSelector('#immich-asset-viewer');
|
||||||
|
|
||||||
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]');
|
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
|
||||||
await expect(viewerBrokenAsset).toBeVisible();
|
await expect(viewerBrokenAsset).toBeVisible();
|
||||||
|
|
||||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||||
|
|||||||
@@ -59,17 +59,15 @@
|
|||||||
{
|
{
|
||||||
quality: 'thumbnail',
|
quality: 'thumbnail',
|
||||||
url: assetUrls.thumbnail,
|
url: assetUrls.thumbnail,
|
||||||
checkCanceled: false,
|
|
||||||
onAfterLoad: afterThumbnail,
|
onAfterLoad: afterThumbnail,
|
||||||
onAfterError: afterThumbnail,
|
onAfterError: afterThumbnail,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
quality: 'preview',
|
quality: 'preview',
|
||||||
url: assetUrls.preview,
|
url: assetUrls.preview,
|
||||||
checkCanceled: true,
|
|
||||||
onAfterError: (loader) => loader.trigger('original'),
|
onAfterError: (loader) => loader.trigger('original'),
|
||||||
},
|
},
|
||||||
{ quality: 'original', url: assetUrls.original, checkCanceled: true },
|
{ quality: 'original', url: assetUrls.original },
|
||||||
];
|
];
|
||||||
return qualityList;
|
return qualityList;
|
||||||
};
|
};
|
||||||
@@ -81,7 +79,7 @@
|
|||||||
|
|
||||||
return untrack(
|
return untrack(
|
||||||
() =>
|
() =>
|
||||||
new AdaptiveImageLoader(asset.id, buildQualityList(), {
|
new AdaptiveImageLoader(buildQualityList(), {
|
||||||
onImageReady,
|
onImageReady,
|
||||||
onError,
|
onError,
|
||||||
onUrlChange,
|
onUrlChange,
|
||||||
@@ -131,7 +129,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (assetViewerManager.zoom > 1 && status.quality.preview === 'success' && status.quality.original !== 'success') {
|
if (assetViewerManager.zoom > 1 && status.quality.original !== 'success') {
|
||||||
untrack(() => void adaptiveImageLoader.trigger('original'));
|
untrack(() => void adaptiveImageLoader.trigger('original'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,19 +23,17 @@ export class PreloadManager {
|
|||||||
{
|
{
|
||||||
quality: 'thumbnail',
|
quality: 'thumbnail',
|
||||||
url: urls.thumbnail,
|
url: urls.thumbnail,
|
||||||
checkCanceled: false,
|
|
||||||
onAfterLoad: afterThumbnail,
|
onAfterLoad: afterThumbnail,
|
||||||
onAfterError: afterThumbnail,
|
onAfterError: afterThumbnail,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
quality: 'preview',
|
quality: 'preview',
|
||||||
url: urls.preview,
|
url: urls.preview,
|
||||||
checkCanceled: true,
|
|
||||||
onAfterError: (loader) => loader.trigger('original'),
|
onAfterError: (loader) => loader.trigger('original'),
|
||||||
},
|
},
|
||||||
{ quality: 'original', url: urls.original, checkCanceled: true },
|
{ quality: 'original', url: urls.original },
|
||||||
];
|
];
|
||||||
const loader = new AdaptiveImageLoader(asset.id, qualityList, undefined, loadImage);
|
const loader = new AdaptiveImageLoader(qualityList, undefined, loadImage);
|
||||||
loader.start();
|
loader.start();
|
||||||
return loader;
|
return loader;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
|
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
import DetailPanel from './detail-panel.svelte';
|
import DetailPanel from './detail-panel.svelte';
|
||||||
import EditorPanel from './editor/editor-panel.svelte';
|
import EditorPanel from './editor/editor-panel.svelte';
|
||||||
import CropArea from './editor/transform-tool/crop-area.svelte';
|
import CropArea from './editor/transform-tool/crop-area.svelte';
|
||||||
|
import FaceEditor from './face-editor/face-editor.svelte';
|
||||||
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
|
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
|
||||||
import OcrButton from './ocr-button.svelte';
|
import OcrButton from './ocr-button.svelte';
|
||||||
import PhotoViewer from './photo-viewer.svelte';
|
import PhotoViewer from './photo-viewer.svelte';
|
||||||
@@ -116,6 +118,10 @@
|
|||||||
playOriginalVideo = value;
|
playOriginalVideo = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectStackedAsset = async (id: string) => {
|
||||||
|
selectedStackAsset = await assetCacheManager.getAsset({ id });
|
||||||
|
};
|
||||||
|
|
||||||
const refreshStack = async () => {
|
const refreshStack = async () => {
|
||||||
if (authManager.isSharedLink || !withStacked) {
|
if (authManager.isSharedLink || !withStacked) {
|
||||||
return;
|
return;
|
||||||
@@ -128,7 +134,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
stack = await getStack({ id: cursor.current.stack.id });
|
stack = await getStack({ id: cursor.current.stack.id });
|
||||||
selectedStackAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
|
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
|
||||||
|
if (primaryAsset) {
|
||||||
|
await selectStackedAsset(primaryAsset.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFavorite = async () => {
|
const handleFavorite = async () => {
|
||||||
@@ -179,11 +188,21 @@
|
|||||||
onClose?.(asset);
|
onClose?.(asset);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshPreservingSelection = async () => {
|
||||||
|
const id = asset.id;
|
||||||
|
assetCacheManager.invalidateAsset(id);
|
||||||
|
if (selectedStackAsset) {
|
||||||
|
await selectStackedAsset(id);
|
||||||
|
} else {
|
||||||
|
const asset = await assetCacheManager.getAsset({ id });
|
||||||
|
assetViewingStore.setAsset(asset);
|
||||||
|
}
|
||||||
|
onAssetChange?.(asset);
|
||||||
|
};
|
||||||
|
|
||||||
const closeEditor = async () => {
|
const closeEditor = async () => {
|
||||||
if (editManager.hasAppliedEdits) {
|
if (editManager.hasAppliedEdits) {
|
||||||
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
await refreshPreservingSelection();
|
||||||
onAssetChange?.(refreshedAsset);
|
|
||||||
assetViewingStore.setAsset(refreshedAsset);
|
|
||||||
}
|
}
|
||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
};
|
};
|
||||||
@@ -454,6 +473,9 @@
|
|||||||
navigateAsset('previous');
|
navigateAsset('previous');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
let containerHeight = $state(0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
||||||
@@ -465,6 +487,8 @@
|
|||||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||||
use:focusTrap
|
use:focusTrap
|
||||||
bind:this={assetViewerHtmlElement}
|
bind:this={assetViewerHtmlElement}
|
||||||
|
bind:clientWidth={containerWidth}
|
||||||
|
bind:clientHeight={containerHeight}
|
||||||
>
|
>
|
||||||
<!-- Top navigation bar -->
|
<!-- Top navigation bar -->
|
||||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||||
@@ -570,6 +594,62 @@
|
|||||||
<OcrButton />
|
<OcrButton />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||||
|
{@const stackedAssets = stack.assets}
|
||||||
|
<div
|
||||||
|
id="stack-slideshow"
|
||||||
|
class="absolute bottom-0 max-w-[calc(100%-5rem)] col-span-4 col-start-1 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
class="relative inline-flex flex-row flex-nowrap max-w-full 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']}
|
||||||
|
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||||
|
>
|
||||||
|
<Thumbnail
|
||||||
|
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
||||||
|
brokenAssetClass="text-xs"
|
||||||
|
dimmed={stackedAsset.id !== asset.id}
|
||||||
|
asset={toTimelineAsset(stackedAsset)}
|
||||||
|
onClick={async () => {
|
||||||
|
await selectStackedAsset(stackedAsset.id);
|
||||||
|
previewStackedAsset = undefined;
|
||||||
|
}}
|
||||||
|
onMouseEvent={async ({ isMouseOver }) => {
|
||||||
|
if (isMouseOver) {
|
||||||
|
previewStackedAsset = stackedAsset;
|
||||||
|
previewStackedAsset = await assetCacheManager.getAsset({ id: stackedAsset.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
readonly
|
||||||
|
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||||
|
showStackedIcon={false}
|
||||||
|
disableLinkMouseOver
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||||
|
<FaceEditor
|
||||||
|
htmlElement={assetViewerManager.imgRef}
|
||||||
|
{containerWidth}
|
||||||
|
{containerHeight}
|
||||||
|
assetId={asset.id}
|
||||||
|
onTagFace={refreshPreservingSelection}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||||
@@ -587,7 +667,7 @@
|
|||||||
>
|
>
|
||||||
{#if showDetailPanel}
|
{#if showDetailPanel}
|
||||||
<div class="w-90 h-full">
|
<div class="w-90 h-full">
|
||||||
<DetailPanel {asset} currentAlbum={album} />
|
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
|
||||||
</div>
|
</div>
|
||||||
{:else if assetViewerManager.isShowEditor}
|
{:else if assetViewerManager.isShowEditor}
|
||||||
<div class="w-100 h-full">
|
<div class="w-100 h-full">
|
||||||
@@ -597,44 +677,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#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
|
|
||||||
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']}
|
|
||||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
|
||||||
>
|
|
||||||
<Thumbnail
|
|
||||||
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
|
||||||
brokenAssetClass="text-xs"
|
|
||||||
dimmed={stackedAsset.id !== asset.id}
|
|
||||||
asset={toTimelineAsset(stackedAsset)}
|
|
||||||
onClick={() => {
|
|
||||||
selectedStackAsset = stackedAsset;
|
|
||||||
previewStackedAsset = undefined;
|
|
||||||
}}
|
|
||||||
onMouseEvent={({ isMouseOver }) => isMouseOver && (previewStackedAsset = stackedAsset)}
|
|
||||||
readonly
|
|
||||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
|
||||||
showStackedIcon={false}
|
|
||||||
disableLinkMouseOver
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
|
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
|
|||||||
@@ -20,13 +20,7 @@
|
|||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { getParentPath } from '$lib/utils/tree-utils';
|
import { getParentPath } from '$lib/utils/tree-utils';
|
||||||
import {
|
import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||||
AssetMediaSize,
|
|
||||||
getAllAlbums,
|
|
||||||
getAssetInfo,
|
|
||||||
type AlbumResponseDto,
|
|
||||||
type AssetResponseDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiCalendar,
|
mdiCalendar,
|
||||||
@@ -52,9 +46,10 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
currentAlbum?: AlbumResponseDto | null;
|
currentAlbum?: AlbumResponseDto | null;
|
||||||
|
onRefreshPeople?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { asset, currentAlbum = null }: Props = $props();
|
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
|
||||||
|
|
||||||
let showAssetPath = $state(false);
|
let showAssetPath = $state(false);
|
||||||
let showEditFaces = $state(false);
|
let showEditFaces = $state(false);
|
||||||
@@ -120,11 +115,6 @@
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshPeople = async () => {
|
|
||||||
asset = await getAssetInfo({ id: asset.id });
|
|
||||||
showEditFaces = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||||
// Remove the last part of the path to get the parent path
|
// Remove the last part of the path to get the parent path
|
||||||
return Route.folders({ path: getParentPath(asset.originalPath) });
|
return Route.folders({ path: getParentPath(asset.originalPath) });
|
||||||
@@ -575,6 +565,6 @@
|
|||||||
assetId={asset.id}
|
assetId={asset.id}
|
||||||
assetType={asset.type}
|
assetType={asset.type}
|
||||||
onClose={() => (showEditFaces = false)}
|
onClose={() => (showEditFaces = false)}
|
||||||
onRefresh={handleRefreshPeople}
|
onRefresh={() => void onRefreshPeople?.()}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||||
@@ -17,9 +16,10 @@
|
|||||||
containerWidth: number;
|
containerWidth: number;
|
||||||
containerHeight: number;
|
containerHeight: number;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
onTagFace?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
|
||||||
|
|
||||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||||
let canvas: Canvas | undefined = $state();
|
let canvas: Canvas | undefined = $state();
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await assetViewingStore.setAssetId(assetId);
|
await onTagFace?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, 'Error tagging face');
|
handleError(error, 'Error tagging face');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { thumbhash } from '$lib/actions/thumbhash';
|
import { thumbhash } from '$lib/actions/thumbhash';
|
||||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
import 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 OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
@@ -217,10 +216,8 @@
|
|||||||
{@const face = faces[index]}
|
{@const face = faces[index]}
|
||||||
{@const name = faceToNameMap.get(face)}
|
{@const name = faceToNameMap.get(face)}
|
||||||
{@const isActive = $boundingBoxesArray.includes(face)}
|
{@const isActive = $boundingBoxesArray.includes(face)}
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
|
||||||
role="region"
|
|
||||||
class={[
|
class={[
|
||||||
'absolute pointer-events-auto outline-none rounded-lg',
|
'absolute pointer-events-auto outline-none rounded-lg',
|
||||||
isActive && 'border-solid border-white border-3',
|
isActive && 'border-solid border-white border-3',
|
||||||
@@ -229,8 +226,6 @@
|
|||||||
aria-label="{$t('person')}: {name || $t('unknown')}"
|
aria-label="{$t('person')}: {name || $t('unknown')}"
|
||||||
onmouseenter={() => ($boundingBoxesArray = [face])}
|
onmouseenter={() => ($boundingBoxesArray = [face])}
|
||||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
onmouseleave={() => ($boundingBoxesArray = [])}
|
||||||
onfocus={() => ($boundingBoxesArray = [face])}
|
|
||||||
onblur={() => ($boundingBoxesArray = [])}
|
|
||||||
>
|
>
|
||||||
{#if isActive && name}
|
{#if isActive && name}
|
||||||
<div
|
<div
|
||||||
@@ -250,8 +245,4 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</AdaptiveImage>
|
</AdaptiveImage>
|
||||||
|
|
||||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
|
||||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||||
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
|
||||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
@@ -25,7 +25,6 @@
|
|||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
@@ -179,7 +178,10 @@
|
|||||||
|
|
||||||
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
|
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
|
||||||
|
|
||||||
await assetViewingStore.setAssetId(assetId);
|
onRefresh();
|
||||||
|
if (peopleWithFaces.length === 0) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('error_delete_face'));
|
handleError(error, $t('error_delete_face'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||||
|
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||||
|
|
||||||
|
vi.mock('$lib/utils/sw-messaging', () => ({
|
||||||
|
cancelImageUrl: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createQualityList(overrides?: {
|
||||||
|
onAfterLoad?: Record<string, (loader: AdaptiveImageLoader) => void>;
|
||||||
|
onAfterError?: Record<string, (loader: AdaptiveImageLoader) => void>;
|
||||||
|
}): QualityList {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
quality: 'thumbnail',
|
||||||
|
url: '/thumbnail.jpg',
|
||||||
|
onAfterLoad: overrides?.onAfterLoad?.thumbnail,
|
||||||
|
onAfterError: overrides?.onAfterError?.thumbnail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quality: 'preview',
|
||||||
|
url: '/preview.jpg',
|
||||||
|
onAfterLoad: overrides?.onAfterLoad?.preview,
|
||||||
|
onAfterError: overrides?.onAfterError?.preview,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quality: 'original',
|
||||||
|
url: '/original.jpg',
|
||||||
|
onAfterLoad: overrides?.onAfterLoad?.original,
|
||||||
|
onAfterError: overrides?.onAfterError?.original,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AdaptiveImageLoader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('initializes with thumbnail URL set', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
expect(loader.status.urls.thumbnail).toBe('/thumbnail.jpg');
|
||||||
|
expect(loader.status.urls.preview).toBeUndefined();
|
||||||
|
expect(loader.status.urls.original).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes all qualities as unloaded', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
expect(loader.status.quality.thumbnail).toBe('unloaded');
|
||||||
|
expect(loader.status.quality.preview).toBe('unloaded');
|
||||||
|
expect(loader.status.quality.original).toBe('unloaded');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onStart', () => {
|
||||||
|
it('sets started to true', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
expect(loader.status.started).toBe(false);
|
||||||
|
loader.onStart('thumbnail');
|
||||||
|
expect(loader.status.started).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op after destroy', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
loader.destroy();
|
||||||
|
loader.onStart('thumbnail');
|
||||||
|
expect(loader.status.started).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onLoad', () => {
|
||||||
|
it('sets quality to success and calls callbacks', () => {
|
||||||
|
const onUrlChange = vi.fn();
|
||||||
|
const onImageReady = vi.fn();
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList(), { onUrlChange, onImageReady });
|
||||||
|
|
||||||
|
loader.onLoad('thumbnail');
|
||||||
|
|
||||||
|
expect(loader.status.quality.thumbnail).toBe('success');
|
||||||
|
expect(onUrlChange).toHaveBeenCalledWith('/thumbnail.jpg');
|
||||||
|
expect(onImageReady).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onAfterLoad callback', () => {
|
||||||
|
const onAfterLoad = vi.fn();
|
||||||
|
const qualityList = createQualityList({ onAfterLoad: { thumbnail: onAfterLoad } });
|
||||||
|
const loader = new AdaptiveImageLoader(qualityList);
|
||||||
|
|
||||||
|
loader.onLoad('thumbnail');
|
||||||
|
|
||||||
|
expect(onAfterLoad).toHaveBeenCalledWith(loader);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores load if URL is not set', () => {
|
||||||
|
const onImageReady = vi.fn();
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList(), { onImageReady });
|
||||||
|
|
||||||
|
loader.onLoad('preview');
|
||||||
|
|
||||||
|
expect(loader.status.quality.preview).toBe('unloaded');
|
||||||
|
expect(onImageReady).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores load if a higher quality is already loaded', () => {
|
||||||
|
const onUrlChange = vi.fn();
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList(), { onUrlChange });
|
||||||
|
|
||||||
|
loader.onLoad('thumbnail');
|
||||||
|
loader.trigger('preview');
|
||||||
|
loader.onLoad('preview');
|
||||||
|
|
||||||
|
onUrlChange.mockClear();
|
||||||
|
loader.onLoad('thumbnail');
|
||||||
|
|
||||||
|
expect(onUrlChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op after destroy', () => {
|
||||||
|
const onImageReady = vi.fn();
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList(), { onImageReady });
|
||||||
|
|
||||||
|
loader.destroy();
|
||||||
|
loader.onLoad('thumbnail');
|
||||||
|
|
||||||
|
expect(onImageReady).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onError', () => {
|
||||||
|
it('sets quality to error and clears URL', () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList(), { onError });
|
||||||
|
|
||||||
|
loader.onError('thumbnail');
|
||||||
|
|
||||||
|
expect(loader.status.quality.thumbnail).toBe('error');
|
||||||
|
expect(loader.status.urls.thumbnail).toBeUndefined();
|
||||||
|
expect(loader.status.hasError).toBe(true);
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onAfterError callback', () => {
|
||||||
|
const onAfterError = vi.fn();
|
||||||
|
const qualityList = createQualityList({ onAfterError: { thumbnail: onAfterError } });
|
||||||
|
const loader = new AdaptiveImageLoader(qualityList);
|
||||||
|
|
||||||
|
loader.onError('thumbnail');
|
||||||
|
|
||||||
|
expect(onAfterError).toHaveBeenCalledWith(loader);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op after destroy', () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList(), { onError });
|
||||||
|
|
||||||
|
loader.destroy();
|
||||||
|
loader.onError('thumbnail');
|
||||||
|
|
||||||
|
expect(onError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trigger', () => {
|
||||||
|
it('sets the URL for the quality', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
|
||||||
|
loader.trigger('preview');
|
||||||
|
|
||||||
|
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true if URL is already set', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
|
||||||
|
expect(loader.trigger('thumbnail')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when triggering a new quality', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
|
||||||
|
expect(loader.trigger('preview')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears hasError when triggering', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
|
||||||
|
loader.onError('thumbnail');
|
||||||
|
expect(loader.status.hasError).toBe(true);
|
||||||
|
|
||||||
|
loader.trigger('preview');
|
||||||
|
expect(loader.status.hasError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls imageLoader when provided', () => {
|
||||||
|
const imageLoader = vi.fn(() => vi.fn());
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
||||||
|
|
||||||
|
loader.trigger('preview');
|
||||||
|
|
||||||
|
expect(imageLoader).toHaveBeenCalledWith(
|
||||||
|
'/preview.jpg',
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false after destroy', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
|
||||||
|
loader.destroy();
|
||||||
|
|
||||||
|
expect(loader.trigger('preview')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onAfterError if URL is empty', () => {
|
||||||
|
const onAfterError = vi.fn();
|
||||||
|
const qualityList = createQualityList({ onAfterError: { preview: onAfterError } });
|
||||||
|
(qualityList[1] as { url: string }).url = '';
|
||||||
|
const loader = new AdaptiveImageLoader(qualityList);
|
||||||
|
|
||||||
|
expect(loader.trigger('preview')).toBe(false);
|
||||||
|
expect(onAfterError).toHaveBeenCalledWith(loader);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('start', () => {
|
||||||
|
it('throws if no imageLoader is provided', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
expect(() => loader.start()).toThrow('Start requires imageLoader to be specified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls imageLoader with thumbnail URL', () => {
|
||||||
|
const imageLoader = vi.fn(() => vi.fn());
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
||||||
|
|
||||||
|
loader.start();
|
||||||
|
|
||||||
|
expect(imageLoader).toHaveBeenCalledWith(
|
||||||
|
'/thumbnail.jpg',
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('cancels all image URLs when no imageLoader', () => {
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList());
|
||||||
|
|
||||||
|
loader.destroy();
|
||||||
|
|
||||||
|
expect(cancelImageUrl).toHaveBeenCalledWith('/thumbnail.jpg');
|
||||||
|
expect(cancelImageUrl).toHaveBeenCalledWith('/preview.jpg');
|
||||||
|
expect(cancelImageUrl).toHaveBeenCalledWith('/original.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls destroy functions when imageLoader is provided', () => {
|
||||||
|
const destroyFn = vi.fn();
|
||||||
|
const imageLoader = vi.fn(() => destroyFn);
|
||||||
|
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
||||||
|
|
||||||
|
loader.start();
|
||||||
|
loader.destroy();
|
||||||
|
|
||||||
|
expect(destroyFn).toHaveBeenCalledOnce();
|
||||||
|
expect(cancelImageUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('progressive loading flow', () => {
|
||||||
|
it('thumbnail load triggers preview via onAfterLoad', () => {
|
||||||
|
const triggerSpy = vi.fn();
|
||||||
|
const qualityList = createQualityList({
|
||||||
|
onAfterLoad: {
|
||||||
|
thumbnail: (loader) => {
|
||||||
|
triggerSpy();
|
||||||
|
loader.trigger('preview');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const loader = new AdaptiveImageLoader(qualityList);
|
||||||
|
|
||||||
|
loader.onLoad('thumbnail');
|
||||||
|
|
||||||
|
expect(triggerSpy).toHaveBeenCalledOnce();
|
||||||
|
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thumbnail error triggers preview via onAfterError', () => {
|
||||||
|
const qualityList = createQualityList({
|
||||||
|
onAfterError: {
|
||||||
|
thumbnail: (loader) => loader.trigger('preview'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const loader = new AdaptiveImageLoader(qualityList);
|
||||||
|
|
||||||
|
loader.onError('thumbnail');
|
||||||
|
|
||||||
|
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,29 +21,10 @@ type ImageLoaderCallbacks = {
|
|||||||
export type QualityConfig = {
|
export type QualityConfig = {
|
||||||
url: string;
|
url: string;
|
||||||
quality: ImageQuality;
|
quality: ImageQuality;
|
||||||
checkCanceled: boolean;
|
|
||||||
onAfterLoad?: (loader: AdaptiveImageLoader) => void;
|
onAfterLoad?: (loader: AdaptiveImageLoader) => void;
|
||||||
onAfterError?: (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 = [
|
export type QualityList = [
|
||||||
QualityConfig & { quality: 'thumbnail' },
|
QualityConfig & { quality: 'thumbnail' },
|
||||||
QualityConfig & { quality: 'preview' },
|
QualityConfig & { quality: 'preview' },
|
||||||
@@ -64,7 +45,6 @@ export class AdaptiveImageLoader {
|
|||||||
});
|
});
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly id: string,
|
|
||||||
private readonly qualityList: QualityList,
|
private readonly qualityList: QualityList,
|
||||||
private readonly callbacks?: ImageLoaderCallbacks,
|
private readonly callbacks?: ImageLoaderCallbacks,
|
||||||
private readonly imageLoader?: LoadImageFunction,
|
private readonly imageLoader?: LoadImageFunction,
|
||||||
@@ -75,7 +55,6 @@ export class AdaptiveImageLoader {
|
|||||||
original: qualityList[2],
|
original: qualityList[2],
|
||||||
};
|
};
|
||||||
this.status.urls.thumbnail = qualityList[0].url;
|
this.status.urls.thumbnail = qualityList[0].url;
|
||||||
setLoading(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
@@ -93,20 +72,20 @@ export class AdaptiveImageLoader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onStart(quality: ImageQuality) {
|
onStart(_: ImageQuality) {
|
||||||
const config = this.qualityConfigs[quality];
|
if (this.destroyed) {
|
||||||
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.status.started = true;
|
this.status.started = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad(quality: ImageQuality) {
|
onLoad(quality: ImageQuality) {
|
||||||
const config = this.qualityConfigs[quality];
|
if (this.destroyed) {
|
||||||
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const config = this.qualityConfigs[quality];
|
||||||
|
|
||||||
if (!this.status.urls[quality]) {
|
if (!this.status.urls[quality]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,11 +104,12 @@ export class AdaptiveImageLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onError(quality: ImageQuality) {
|
onError(quality: ImageQuality) {
|
||||||
const config = this.qualityConfigs[quality];
|
if (this.destroyed) {
|
||||||
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const config = this.qualityConfigs[quality];
|
||||||
|
|
||||||
this.status.hasError = true;
|
this.status.hasError = true;
|
||||||
this.status.quality[quality] = 'error';
|
this.status.quality[quality] = 'error';
|
||||||
this.status.urls[quality] = undefined;
|
this.status.urls[quality] = undefined;
|
||||||
@@ -169,7 +149,6 @@ export class AdaptiveImageLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
setCanceled(this.id);
|
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
if (this.imageLoader) {
|
if (this.imageLoader) {
|
||||||
for (const destroy of this.destroyFunctions) {
|
for (const destroy of this.destroyFunctions) {
|
||||||
|
|||||||
Reference in New Issue
Block a user