mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: adaptive progressive image loading for photo viewer (#26636)
* feat(web): adaptive progressive image loading for photo viewer Replace ImageManager with a new AdaptiveImageLoader that progressively loads images through quality tiers (thumbnail → preview → original). New components and utilities: - AdaptiveImage: layered image renderer with thumbhash, thumbnail, preview, and original layers with visibility managed by load state - AdaptiveImageLoader: state machine driving the quality progression with per-quality callbacks and error handling - ImageLayer/Image: low-level image elements with load/error lifecycle - PreloadManager: preloads adjacent assets for instant navigation - AlphaBackground/DelayedLoadingSpinner: loading state UI Zoom is handled via a derived CSS transform applied to the content wrapper in AdaptiveImage, with the zoom library (zoomTarget: null) only tracking state without manipulating the DOM directly. Also adds scaleToCover to container-utils and getAssetUrls to utils. * fix: don't partially render images in firefox * add passive loading indicator to asset-viewer --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import ImageLayer from '$lib/components/ImageLayer.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { getAssetUrls } from '$lib/utils';
|
||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
objectFit?: 'contain' | 'cover';
|
||||
container: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
onUrlChange?: (url: string) => void;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
ref?: HTMLDivElement;
|
||||
imgRef?: HTMLImageElement;
|
||||
backdrop?: Snippet;
|
||||
overlays?: Snippet;
|
||||
};
|
||||
|
||||
let {
|
||||
ref = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgRef = $bindable(),
|
||||
asset,
|
||||
sharedLink,
|
||||
objectFit = 'contain',
|
||||
container,
|
||||
onUrlChange,
|
||||
onImageReady,
|
||||
onError,
|
||||
backdrop,
|
||||
overlays,
|
||||
}: Props = $props();
|
||||
|
||||
const afterThumbnail = (loader: AdaptiveImageLoader) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
loader.trigger('original');
|
||||
} else {
|
||||
loader.trigger('preview');
|
||||
}
|
||||
};
|
||||
|
||||
const buildQualityList = () => {
|
||||
const assetUrls = getAssetUrls(asset, sharedLink);
|
||||
const qualityList: QualityList = [
|
||||
{
|
||||
quality: 'thumbnail',
|
||||
url: assetUrls.thumbnail,
|
||||
onAfterLoad: afterThumbnail,
|
||||
onAfterError: afterThumbnail,
|
||||
},
|
||||
{
|
||||
quality: 'preview',
|
||||
url: assetUrls.preview,
|
||||
onAfterError: (loader) => loader.trigger('original'),
|
||||
},
|
||||
{ quality: 'original', url: assetUrls.original },
|
||||
];
|
||||
return qualityList;
|
||||
};
|
||||
|
||||
const loaderKey = $derived(`${asset.id}:${asset.thumbhash}:${sharedLink?.id}`);
|
||||
|
||||
const adaptiveImageLoader = $derived.by(() => {
|
||||
void loaderKey;
|
||||
|
||||
return untrack(
|
||||
() =>
|
||||
new AdaptiveImageLoader(buildQualityList(), {
|
||||
onImageReady,
|
||||
onError,
|
||||
onUrlChange,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
const loader = adaptiveImageLoader;
|
||||
untrack(() => assetViewerManager.resetZoomState());
|
||||
return () => loader.destroy();
|
||||
});
|
||||
|
||||
const imageDimensions = $derived.by(() => {
|
||||
const { width, height } = asset;
|
||||
if (width && width > 0 && height && height > 0) {
|
||||
return { width, height };
|
||||
}
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const { width, height, left, top } = $derived.by(() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
left: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
};
|
||||
});
|
||||
|
||||
const { status } = $derived(adaptiveImageLoader);
|
||||
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
|
||||
|
||||
const show = $derived.by(() => {
|
||||
const { quality, started, hasError, urls } = status;
|
||||
return {
|
||||
alphaBackground: !hasError && started,
|
||||
spinner: !asset.thumbhash && !started,
|
||||
brokenAsset: hasError,
|
||||
thumbhash: quality.thumbnail !== 'success' && quality.preview !== 'success' && quality.original !== 'success',
|
||||
thumbnail: quality.thumbnail !== 'error' && quality.preview !== 'success' && quality.original !== 'success',
|
||||
preview: quality.preview !== 'error' && quality.original !== 'success',
|
||||
original: quality.original !== 'error' && urls.original !== undefined,
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
assetViewerManager.imageLoaderStatus = status;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (assetViewerManager.zoom > 1 && status.quality.original !== 'success') {
|
||||
untrack(() => void adaptiveImageLoader.trigger('original'));
|
||||
}
|
||||
});
|
||||
|
||||
let thumbnailElement = $state<HTMLImageElement>();
|
||||
let previewElement = $state<HTMLImageElement>();
|
||||
let originalElement = $state<HTMLImageElement>();
|
||||
|
||||
$effect(() => {
|
||||
const quality = status.quality;
|
||||
imgRef =
|
||||
(quality.original === 'success' ? originalElement : undefined) ??
|
||||
(quality.preview === 'success' ? previewElement : undefined) ??
|
||||
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
|
||||
});
|
||||
|
||||
const zoomTransform = $derived.by(() => {
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
if (currentZoom === 1 && currentPositionX === 0 && currentPositionY === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `translate(${currentPositionX}px, ${currentPositionY}px) scale(${currentZoom})`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
||||
{@render backdrop?.()}
|
||||
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style:transform={zoomTransform}
|
||||
style:transform-origin={zoomTransform ? '0 0' : undefined}
|
||||
>
|
||||
<div class="absolute" style:left style:top style:width style:height>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
|
||||
{#if show.thumbhash}
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
||||
{:else if show.spinner}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{width}
|
||||
{height}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
role="presentation"
|
||||
bind:ref={thumbnailElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||
{/if}
|
||||
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
bind:ref={previewElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.original}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
bind:ref={originalElement}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
</script>
|
||||
|
||||
<div class="delayed-spinner absolute flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.delayed-spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isFirefox } from '$lib/utils/asset-utils';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||
@@ -14,6 +15,7 @@
|
||||
let { src, onStart, onLoad, onError, ref = $bindable(), ...rest }: Props = $props();
|
||||
|
||||
let capturedSource: string | undefined = $state();
|
||||
let loaded = $state(false);
|
||||
let destroyed = false;
|
||||
|
||||
$effect(() => {
|
||||
@@ -32,11 +34,25 @@
|
||||
}
|
||||
});
|
||||
|
||||
const completeLoad = () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
loaded = true;
|
||||
onLoad?.();
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
if (destroyed || !src) {
|
||||
return;
|
||||
}
|
||||
onLoad?.();
|
||||
|
||||
if (isFirefox && ref) {
|
||||
ref.decode().then(completeLoad, completeLoad);
|
||||
return;
|
||||
}
|
||||
|
||||
completeLoad();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
@@ -49,6 +65,13 @@
|
||||
|
||||
{#if capturedSource}
|
||||
{#key capturedSource}
|
||||
<img bind:this={ref} src={capturedSource} {...rest} onload={handleLoad} onerror={handleError} />
|
||||
<img
|
||||
bind:this={ref}
|
||||
src={capturedSource}
|
||||
{...rest}
|
||||
style:visibility={isFirefox && !loaded ? 'hidden' : undefined}
|
||||
onload={handleLoad}
|
||||
onerror={handleError}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
@@ -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 bg-transparent"
|
||||
{alt}
|
||||
{role}
|
||||
draggable={false}
|
||||
data-testid={quality}
|
||||
/>
|
||||
{@render overlays?.()}
|
||||
</div>
|
||||
{/key}
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="delayed inline-flex items-center gap-1 {className}">
|
||||
{#each [0, 1, 2] as i (i)}
|
||||
<span class="dot block size-1.5 rounded-full bg-white shadow-[0_0_3px_rgba(0,0,0,0.6)]" style:--delay="{i * 0.25}s"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.delayed {
|
||||
visibility: hidden;
|
||||
animation: delayed-visibility 0s linear 0.4s forwards;
|
||||
}
|
||||
|
||||
@keyframes delayed-visibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
animation: dot-stream 1.6s var(--delay, 0s) ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dot-stream {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
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, SharedLinkResponseDto } 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,
|
||||
sharedlink: SharedLinkResponseDto | undefined,
|
||||
): AdaptiveImageLoader | undefined {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const urls = getAssetUrls(asset, sharedlink);
|
||||
const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview');
|
||||
const qualityList: QualityList = [
|
||||
{
|
||||
quality: 'thumbnail',
|
||||
url: urls.thumbnail,
|
||||
onAfterLoad: afterThumbnail,
|
||||
onAfterError: afterThumbnail,
|
||||
},
|
||||
{
|
||||
quality: 'preview',
|
||||
url: urls.preview,
|
||||
onAfterError: (loader) => loader.trigger('original'),
|
||||
},
|
||||
{ quality: 'original', url: urls.original },
|
||||
];
|
||||
const loader = new AdaptiveImageLoader(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, sharedlink: SharedLinkResponseDto | undefined) {
|
||||
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, sharedlink);
|
||||
} else if (movedBackward) {
|
||||
this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink);
|
||||
} else {
|
||||
this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink);
|
||||
this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink);
|
||||
}
|
||||
}
|
||||
|
||||
initializePreloads(cursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) {
|
||||
if (cursor.nextAsset) {
|
||||
this.nextPreloader = this.startPreloader(cursor.nextAsset, sharedlink);
|
||||
}
|
||||
if (cursor.previousAsset) {
|
||||
this.previousPreloader = this.startPreloader(cursor.previousAsset, sharedlink);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyNextPreloader();
|
||||
this.destroyPreviousPreloader();
|
||||
}
|
||||
}
|
||||
|
||||
export const preloadManager = new PreloadManager();
|
||||
@@ -34,7 +34,9 @@
|
||||
type PersonResponseDto,
|
||||
type StackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
|
||||
import LoadingDots from '$lib/components/LoadingDots.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiArrowRight,
|
||||
@@ -104,7 +106,16 @@
|
||||
<ActionButton action={Close} />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
||||
<div class="flex items-center gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
||||
{#if assetViewerManager.isImageLoading}
|
||||
<Tooltip text={$t('loading')}>
|
||||
{#snippet child({ props })}
|
||||
<div {...props} role="status" aria-label={$t('loading')}>
|
||||
<LoadingDots class="me-1" />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<ActionButton action={Cast} />
|
||||
<ActionButton action={Actions.Share} />
|
||||
<ActionButton action={Actions.Offline} />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
|
||||
import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
@@ -12,9 +13,9 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
@@ -37,6 +38,7 @@
|
||||
} from '@immich/sdk';
|
||||
import { CommandPaletteDefaultProvider } from '@immich/ui';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
@@ -93,20 +95,19 @@
|
||||
stopProgress: stopSlideshowProgress,
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowTransition,
|
||||
slideshowRepeat,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
const asset = $derived(cursor.current);
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
const asset = $derived(previewStackedAsset ?? cursor.current);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
let sharedLink = getSharedLink();
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
let slideshowStartAssetId = $state<string>();
|
||||
@@ -116,7 +117,7 @@
|
||||
};
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
if (authManager.isSharedLink || !withStacked) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,19 +128,17 @@
|
||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||
stack = null;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
imageManager.preload(stack?.assets[1]);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (album && album.isActivityEnabled) {
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
if (!album || !album.isActivityEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,33 +150,34 @@
|
||||
|
||||
onMount(() => {
|
||||
syncAssetViewerOpenClass(true);
|
||||
unsubscribes.push(
|
||||
slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
}),
|
||||
slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
}),
|
||||
);
|
||||
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
});
|
||||
|
||||
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
slideshowStateUnsubscribe();
|
||||
slideshowNavigationUnsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
activityManager.reset();
|
||||
assetViewerManager.closeEditor();
|
||||
syncAssetViewerOpenClass(false);
|
||||
preloadManager.destroy();
|
||||
});
|
||||
|
||||
const closeViewer = () => {
|
||||
@@ -194,8 +194,7 @@
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
|
||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
@@ -204,16 +203,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
e?.stopPropagation();
|
||||
imageManager.cancel(asset);
|
||||
preloadManager.cancelBeforeNavigation(order);
|
||||
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void tracker.invoke(async () => {
|
||||
const isShuffle =
|
||||
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
|
||||
|
||||
let hasNext: boolean;
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||
if (isShuffle) {
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom?.();
|
||||
@@ -227,17 +229,22 @@
|
||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
// Loop back to starting asset
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await handleStopSlideshow();
|
||||
}, $t('error_while_navigating'));
|
||||
};
|
||||
|
||||
@@ -281,12 +288,14 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? asset : undefined;
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
preAction?.(action);
|
||||
};
|
||||
|
||||
const handleAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case AssetAction.DELETE:
|
||||
@@ -359,17 +368,31 @@
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset;
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
imageManager.preload(cursor.nextAsset);
|
||||
imageManager.preload(cursor.previousAsset);
|
||||
});
|
||||
|
||||
let lastCursor = $state<AssetCursor>();
|
||||
|
||||
$effect(() => {
|
||||
if (cursor.current.id === lastCursor?.current.id) {
|
||||
return;
|
||||
}
|
||||
if (lastCursor) {
|
||||
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
|
||||
}
|
||||
if (!lastCursor) {
|
||||
preloadManager.initializePreloads(cursor, sharedLink);
|
||||
}
|
||||
lastCursor = cursor;
|
||||
});
|
||||
|
||||
const viewerKind = $derived.by(() => {
|
||||
if (previewStackedAsset) {
|
||||
return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
|
||||
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
||||
}
|
||||
if (asset.type === AssetTypeEnum.Video) {
|
||||
return 'VideoViewer';
|
||||
@@ -410,6 +433,24 @@
|
||||
assetViewerManager.isShowDetailPanel &&
|
||||
!assetViewerManager.isShowEditor,
|
||||
);
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.detail.direction === 'left') {
|
||||
navigateAsset('next');
|
||||
}
|
||||
|
||||
if (event.detail.direction === 'right') {
|
||||
navigateAsset('previous');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
|
||||
@@ -456,23 +497,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
haveFadeTransition={false}
|
||||
{sharedLink}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
asset={previewStackedAsset!}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
@@ -502,13 +535,7 @@
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||
/>
|
||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
@@ -543,7 +570,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
|
||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
@@ -81,15 +81,20 @@
|
||||
await getPeople();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const metrics = getContentMetrics(htmlElement);
|
||||
|
||||
const imageBoundingBox = {
|
||||
top: metrics.offsetY,
|
||||
left: metrics.offsetX,
|
||||
width: metrics.contentWidth,
|
||||
height: metrics.contentHeight,
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
const container = { width: containerWidth, height: containerHeight };
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (containerWidth - contentWidth) / 2,
|
||||
offsetY: (containerHeight - contentHeight) / 2,
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const { offsetX, offsetY } = imageContentMetrics;
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
@@ -105,8 +110,8 @@
|
||||
}
|
||||
|
||||
faceRect.set({
|
||||
top: imageBoundingBox.top + 200,
|
||||
left: imageBoundingBox.left + 200,
|
||||
top: offsetY + 200,
|
||||
left: offsetX + 200,
|
||||
});
|
||||
|
||||
faceRect.setCoords();
|
||||
@@ -214,13 +219,13 @@
|
||||
}
|
||||
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const metrics = getContentMetrics(htmlElement);
|
||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
|
||||
const scaleX = natural.width / metrics.contentWidth;
|
||||
const scaleY = natural.height / metrics.contentHeight;
|
||||
const imageX = (left - metrics.offsetX) * scaleX;
|
||||
const imageY = (top - metrics.offsetY) * scaleY;
|
||||
const scaleX = natural.width / contentWidth;
|
||||
const scaleY = natural.height / contentHeight;
|
||||
const imageX = (left - offsetX) * scaleX;
|
||||
const imageY = (top - offsetY) * scaleY;
|
||||
|
||||
return {
|
||||
imageWidth: natural.width,
|
||||
|
||||
@@ -1,66 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { getContentMetrics, type ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
}: Props = $props();
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
let visibleImageReady: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
let previousAssetId: string | undefined;
|
||||
$effect.pre(() => {
|
||||
void asset.id;
|
||||
const id = asset.id;
|
||||
if (id === previousAssetId) {
|
||||
return;
|
||||
}
|
||||
previousAssetId = id;
|
||||
untrack(() => {
|
||||
assetViewerManager.resetZoomState();
|
||||
visibleImageReady = false;
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
});
|
||||
@@ -69,28 +59,30 @@
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
const container = $derived({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
const overlayMetrics = $derived.by((): ContentMetrics => {
|
||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
[containerWidth, containerHeight]; // trigger recomputation on resize
|
||||
|
||||
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const scaled = scaleToFit(natural, container);
|
||||
return {
|
||||
contentWidth: contentWidth * currentZoom,
|
||||
contentHeight: contentHeight * currentZoom,
|
||||
offsetX: offsetX * currentZoom + currentPositionX,
|
||||
offsetY: offsetY * currentZoom + currentPositionY,
|
||||
contentWidth: scaled.width,
|
||||
contentHeight: scaled.height,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||
|
||||
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
||||
@@ -128,29 +120,15 @@
|
||||
handlePromiseError(onCopy());
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
return;
|
||||
}
|
||||
let currentPreviewUrl = $state<string>();
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onNextAsset && event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
|
||||
if (onPreviousAsset && event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
const onUrlChange = (url: string) => {
|
||||
currentPreviewUrl = url;
|
||||
};
|
||||
|
||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
|
||||
|
||||
$effect(() => {
|
||||
if (imageLoaderUrl) {
|
||||
void cast(imageLoaderUrl);
|
||||
if (currentPreviewUrl) {
|
||||
void cast(currentPreviewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,38 +146,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
imageLoaded = true;
|
||||
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
||||
};
|
||||
|
||||
const onerror = () => {
|
||||
imageError = imageLoaded = true;
|
||||
};
|
||||
|
||||
onDestroy(() => imageManager.cancel(asset, targetImageSize));
|
||||
|
||||
let imageLoaderUrl = $derived(
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
||||
const blurredSlideshow = $derived(
|
||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||
);
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
let lastUrl: string | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||
untrack(() => {
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
visibleImageReady = false;
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
});
|
||||
|
||||
const faceToNameMap = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<Faces, string>();
|
||||
@@ -219,9 +169,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const scaled = scaleToFit(natural, container);
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
const contentOffsetX = (container.width - scaled.width) / 2;
|
||||
const contentOffsetY = (container.height - scaled.height) / 2;
|
||||
|
||||
const containerRect = element.getBoundingClientRect();
|
||||
const mouseX = event.clientX - containerRect.left;
|
||||
const mouseY = event.clientY - containerRect.top;
|
||||
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
|
||||
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
|
||||
|
||||
const faceBoxes = getBoundingBox(faces, overlayMetrics);
|
||||
|
||||
@@ -247,12 +204,7 @@
|
||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||
]}
|
||||
/>
|
||||
{#if imageError}
|
||||
<div id="broken-asset" class="h-full w-full">
|
||||
<BrokenAsset class="text-xl h-full w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
class="relative h-full w-full select-none"
|
||||
@@ -262,36 +214,34 @@
|
||||
ondblclick={onZoom}
|
||||
onmousemove={handleImageMouseMove}
|
||||
onmouseleave={handleImageMouseLeave}
|
||||
use:zoomImageAction={{ disabled: isFaceEditMode.value || ocrManager.showOverlay }}
|
||||
{...useSwipe((event) => onSwipe?.(event))}
|
||||
>
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if !imageError}
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||
>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
src={imageLoaderUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
<AdaptiveImage
|
||||
{asset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
||||
{onUrlChange}
|
||||
onImageReady={() => {
|
||||
visibleImageReady = true;
|
||||
onReady?.();
|
||||
}}
|
||||
onError={() => {
|
||||
onError?.();
|
||||
onReady?.();
|
||||
}}
|
||||
bind:imgRef={assetViewerManager.imgRef}
|
||||
>
|
||||
{#snippet backdrop()}
|
||||
{#if blurredSlideshow}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
|
||||
class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw"
|
||||
></canvas>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={assetViewerManager.imgRef}
|
||||
src={imageLoaderUrl}
|
||||
onload={() => (visibleImageReady = true)}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet overlays()}
|
||||
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
@@ -311,23 +261,10 @@
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#broken-asset,
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -44,9 +44,7 @@
|
||||
{/if}
|
||||
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<DelayedLoadingSpinner />
|
||||
{:else if imageLoaded}
|
||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
||||
<img
|
||||
@@ -57,15 +55,3 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user