mirror of
https://github.com/immich-app/immich.git
synced 2026-01-22 01:18:54 -08:00
feat: swipe feedback
refactor: replace onPreviousAsset/onNextAsset with onSwipe
This commit is contained in:
@@ -23,32 +23,44 @@ test.describe('Photo Viewer', () => {
|
||||
|
||||
test('loads original photo when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await page.getByTestId('thumbnail').boundingBox();
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await thumbnail.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect(page.getByTestId('original')).toBeInViewport();
|
||||
await expect(page.getByTestId('original')).toHaveAttribute('src', /original/);
|
||||
await expect(original).toBeInViewport();
|
||||
await expect(original).toHaveAttribute('src', /original/);
|
||||
});
|
||||
|
||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||
await page.goto(`/photos/${rawAsset.id}`);
|
||||
await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await page.getByTestId('thumbnail').boundingBox();
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await thumbnail.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect(page.getByTestId('original')).toHaveAttribute('src', /fullsize/);
|
||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||
});
|
||||
|
||||
test('reloads photo when checksum changes', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
|
||||
const initialSrc = await page.getByTestId('thumbnail').getAttribute('src');
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const initialSrc = await thumbnail.getAttribute('src');
|
||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||
await expect(page.getByTestId('preview')).not.toHaveAttribute('src', initialSrc!);
|
||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,6 +156,11 @@ export function imageLoader(
|
||||
const handleElementCreated = (img: HTMLImageElement) => {
|
||||
if (img) {
|
||||
node.append(img);
|
||||
// const a = document.createElement('p');
|
||||
|
||||
// a.classList.add('absolute', 'h-full', 'w-full', 'top-0');
|
||||
// a.textContent = img.src;
|
||||
// node.append(a);
|
||||
currentCallbacks.onElementCreated?.(img);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,10 +9,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
initialState: state,
|
||||
});
|
||||
|
||||
let isUpdatingFromInstance = false;
|
||||
let isUpdatingFromStore = false;
|
||||
|
||||
const unsubscribes = [
|
||||
photoZoomState.subscribe((state) => zoomInstance.setState(state)),
|
||||
photoZoomState.subscribe((state) => {
|
||||
if (isUpdatingFromInstance || options?.disabled) {
|
||||
return;
|
||||
}
|
||||
isUpdatingFromStore = true;
|
||||
zoomInstance.setState(state);
|
||||
isUpdatingFromStore = false;
|
||||
}),
|
||||
zoomInstance.subscribe(({ state }) => {
|
||||
if (isUpdatingFromStore || options?.disabled) {
|
||||
return;
|
||||
}
|
||||
isUpdatingFromInstance = true;
|
||||
photoZoomState.set(state);
|
||||
isUpdatingFromInstance = false;
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -28,11 +28,13 @@
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
imgElement?: HTMLImageElement;
|
||||
imgContainerElement?: HTMLElement;
|
||||
overlays?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
imgElement = $bindable<HTMLImageElement | undefined>(),
|
||||
imgContainerElement = $bindable<HTMLElement | undefined>(),
|
||||
asset,
|
||||
sharedLink,
|
||||
zoomDisabled = false,
|
||||
@@ -125,6 +127,7 @@
|
||||
style:top={renderDimensions.top}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
bind:this={imgContainerElement}
|
||||
>
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
|
||||
@@ -198,12 +198,30 @@
|
||||
|
||||
let nextPreloader: AdaptiveImageLoader | undefined;
|
||||
let previousPreloader: AdaptiveImageLoader | undefined;
|
||||
let nextPreviewUrl = $state<string | undefined>();
|
||||
let previousPreviewUrl = $state<string | undefined>();
|
||||
|
||||
const startPreloader = (asset: AssetResponseDto | undefined) => {
|
||||
const setPreviewUrl = (direction: 'next' | 'previous', url: string | undefined) => {
|
||||
if (direction === 'next') {
|
||||
nextPreviewUrl = url;
|
||||
} else {
|
||||
previousPreviewUrl = url;
|
||||
}
|
||||
};
|
||||
|
||||
const startPreloader = (asset: AssetResponseDto | undefined, direction: 'next' | 'previous') => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const loader = new AdaptiveImageLoader(asset, undefined, undefined, loadImage);
|
||||
const loader = new AdaptiveImageLoader(
|
||||
asset,
|
||||
undefined,
|
||||
{
|
||||
currentZoomFn: () => 1,
|
||||
onQualityUpgrade: (url) => setPreviewUrl(direction, url),
|
||||
},
|
||||
loadImage,
|
||||
);
|
||||
loader.start();
|
||||
return loader;
|
||||
};
|
||||
@@ -211,14 +229,17 @@
|
||||
const destroyPreviousPreloader = () => {
|
||||
previousPreloader?.destroy();
|
||||
previousPreloader = undefined;
|
||||
previousPreviewUrl = undefined;
|
||||
};
|
||||
|
||||
const destroyNextPreloader = () => {
|
||||
nextPreloader?.destroy();
|
||||
nextPreloader = undefined;
|
||||
nextPreviewUrl = undefined;
|
||||
};
|
||||
|
||||
const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => {
|
||||
setPreviewUrl(direction, undefined);
|
||||
if (direction === 'next') {
|
||||
destroyPreviousPreloader();
|
||||
return;
|
||||
@@ -233,22 +254,30 @@
|
||||
const shouldDestroyPrevious = movedForward || !movedBackward;
|
||||
const shouldDestroyNext = movedBackward || !movedForward;
|
||||
|
||||
if (shouldDestroyPrevious) {
|
||||
destroyPreviousPreloader();
|
||||
}
|
||||
|
||||
if (shouldDestroyNext) {
|
||||
destroyNextPreloader();
|
||||
}
|
||||
|
||||
if (movedForward) {
|
||||
nextPreloader = startPreloader(newCursor.nextAsset);
|
||||
// When moving forward: old next becomes current, shift preview URLs
|
||||
const oldNextUrl = nextPreviewUrl;
|
||||
destroyPreviousPreloader();
|
||||
previousPreviewUrl = oldNextUrl;
|
||||
destroyNextPreloader();
|
||||
nextPreloader = startPreloader(newCursor.nextAsset, 'next');
|
||||
} else if (movedBackward) {
|
||||
previousPreloader = startPreloader(newCursor.previousAsset);
|
||||
// When moving backward: old previous becomes current, shift preview URLs
|
||||
const oldPreviousUrl = previousPreviewUrl;
|
||||
destroyNextPreloader();
|
||||
nextPreviewUrl = oldPreviousUrl;
|
||||
destroyPreviousPreloader();
|
||||
previousPreloader = startPreloader(newCursor.previousAsset, 'previous');
|
||||
} else {
|
||||
// Non-adjacent navigation (e.g., slideshow random)
|
||||
previousPreloader = startPreloader(newCursor.previousAsset);
|
||||
nextPreloader = startPreloader(newCursor.nextAsset);
|
||||
// Non-adjacent navigation (e.g., slideshow random) - clear everything
|
||||
if (shouldDestroyPrevious) {
|
||||
destroyPreviousPreloader();
|
||||
}
|
||||
if (shouldDestroyNext) {
|
||||
destroyNextPreloader();
|
||||
}
|
||||
previousPreloader = startPreloader(newCursor.previousAsset, 'previous');
|
||||
nextPreloader = startPreloader(newCursor.nextAsset, 'next');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -441,10 +470,10 @@
|
||||
if (!lastCursor && cursor) {
|
||||
// "first time" load, start preloads
|
||||
if (cursor.nextAsset) {
|
||||
nextPreloader = startPreloader(cursor.nextAsset);
|
||||
nextPreloader = startPreloader(cursor.nextAsset, 'next');
|
||||
}
|
||||
if (cursor.previousAsset) {
|
||||
previousPreloader = startPreloader(cursor.previousAsset);
|
||||
previousPreloader = startPreloader(cursor.previousAsset, 'previous');
|
||||
}
|
||||
}
|
||||
lastCursor = cursor;
|
||||
@@ -557,15 +586,21 @@
|
||||
<!-- Asset Viewer -->
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer bind:zoomToggle bind:copyImage cursor={{ ...cursor, current: previewStackedAsset! }} {sharedLink} />
|
||||
<PhotoViewer
|
||||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
{sharedLink}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'previous' : 'next')}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
assetId={previewStackedAsset!.id}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||
loopVideo={true}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
@@ -573,12 +608,13 @@
|
||||
/>
|
||||
{:else if viewerKind === 'LiveVideoViewer'}
|
||||
<VideoViewer
|
||||
{cursor}
|
||||
assetId={asset.livePhotoVideoId!}
|
||||
{sharedLink}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
@@ -587,15 +623,22 @@
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer bind:zoomToggle bind:copyImage {cursor} {sharedLink} />
|
||||
<PhotoViewer
|
||||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
{cursor}
|
||||
{sharedLink}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{cursor}
|
||||
assetId={asset.id}
|
||||
{sharedLink}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.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 SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
@@ -17,7 +18,7 @@
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
@@ -26,6 +27,7 @@
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
onReady?: () => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
copyImage?: () => Promise<void>;
|
||||
zoomToggle?: () => void;
|
||||
}
|
||||
@@ -35,6 +37,7 @@
|
||||
element = $bindable(),
|
||||
sharedLink,
|
||||
onReady,
|
||||
onSwipe,
|
||||
copyImage = $bindable(),
|
||||
zoomToggle = $bindable(),
|
||||
}: Props = $props();
|
||||
@@ -118,6 +121,15 @@
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
let imgContainerElement = $state<HTMLElement | undefined>();
|
||||
let swipeFeedbackReset = $state<(() => void) | undefined>();
|
||||
|
||||
$effect(() => {
|
||||
// Reset swipe feedback when asset changes
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset.id;
|
||||
untrack(() => swipeFeedbackReset?.());
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -129,11 +141,14 @@
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
<SwipeFeedback
|
||||
bind:element
|
||||
class="relative h-full w-full select-none"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
disabled={isOcrActive || $photoZoomState.currentZoom > 1}
|
||||
{onSwipe}
|
||||
bind:reset={swipeFeedbackReset}
|
||||
>
|
||||
<AdaptiveImage
|
||||
{asset}
|
||||
@@ -146,6 +161,7 @@
|
||||
onImageReady={() => onReady?.()}
|
||||
onError={() => onReady?.()}
|
||||
bind:imgElement={$photoViewerImgElement}
|
||||
bind:imgContainerElement
|
||||
>
|
||||
{#snippet overlays()}
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
@@ -165,4 +181,32 @@
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet leftPreview()}
|
||||
{#if cursor.previousAsset}
|
||||
<AdaptiveImage
|
||||
asset={cursor.previousAsset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
zoomDisabled={true}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rightPreview()}
|
||||
{#if cursor.nextAsset}
|
||||
<AdaptiveImage
|
||||
asset={cursor.nextAsset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
zoomDisabled={true}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SwipeFeedback>
|
||||
|
||||
370
web/src/lib/components/asset-viewer/swipe-feedback.svelte
Normal file
370
web/src/lib/components/asset-viewer/swipe-feedback.svelte
Normal file
@@ -0,0 +1,370 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
onSwipeEnd?: (offsetX: number) => void;
|
||||
onSwipeMove?: (offsetX: number) => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
swipeThreshold?: number;
|
||||
class?: string;
|
||||
transitionName?: string;
|
||||
element?: HTMLDivElement;
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
reset?: () => void;
|
||||
children: Snippet;
|
||||
leftPreview?: Snippet;
|
||||
rightPreview?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
disabled = false,
|
||||
onSwipeEnd,
|
||||
onSwipeMove,
|
||||
onSwipe,
|
||||
swipeThreshold = 45,
|
||||
class: className = '',
|
||||
transitionName,
|
||||
element = $bindable(),
|
||||
clientWidth = $bindable(),
|
||||
clientHeight = $bindable(),
|
||||
reset = $bindable(),
|
||||
children,
|
||||
leftPreview,
|
||||
rightPreview,
|
||||
}: Props = $props();
|
||||
|
||||
interface SwipeAnimations {
|
||||
currentImageAnimation: Animation;
|
||||
previewAnimation: Animation | null;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION_MS = 300;
|
||||
// Tolerance to avoid edge cases where animation is nearly complete but not exactly at end
|
||||
const ANIMATION_COMPLETION_TOLERANCE_MS = 5;
|
||||
// Minimum velocity to trigger swipe (tuned for natural flick gesture)
|
||||
const MIN_SWIPE_VELOCITY = 0.11; // pixels per millisecond
|
||||
// Require 25% drag progress if velocity is too low (prevents accidental swipes)
|
||||
const MIN_PROGRESS_THRESHOLD = 0.25;
|
||||
const ENABLE_SCALE_ANIMATION = false;
|
||||
|
||||
let contentElement: HTMLElement | undefined = $state();
|
||||
let leftPreviewContainer: HTMLDivElement | undefined = $state();
|
||||
let rightPreviewContainer: HTMLDivElement | undefined = $state();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let startX = $state(0);
|
||||
let currentOffsetX = $state(0);
|
||||
let dragStartTime: number | null = $state(null);
|
||||
|
||||
let leftAnimations: SwipeAnimations | null = $state(null);
|
||||
let rightAnimations: SwipeAnimations | null = $state(null);
|
||||
let isSwipeInProgress = $state(false);
|
||||
|
||||
const cursorStyle = $derived(disabled ? '' : isSwipeInProgress ? 'wait' : isDragging ? 'grabbing' : 'grab');
|
||||
|
||||
const isValidPointerEvent = (event: PointerEvent) =>
|
||||
event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0);
|
||||
|
||||
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
|
||||
if (!contentElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createAnimationKeyframes = (direction: 'left' | 'right', isPreview: boolean) => {
|
||||
const scale = (s: number) => (ENABLE_SCALE_ANIMATION ? ` scale(${s})` : '');
|
||||
const sign = direction === 'left' ? -1 : 1;
|
||||
|
||||
if (isPreview) {
|
||||
return [
|
||||
{ transform: `translateX(${sign * -100}vw)${scale(0)}`, opacity: '0', offset: 0 },
|
||||
{ transform: `translateX(${sign * -80}vw)${scale(0.2)}`, opacity: '0', offset: 0.2 },
|
||||
{ transform: `translateX(${sign * -50}vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
|
||||
{ transform: `translateX(${sign * -20}vw)${scale(0.8)}`, opacity: '1', offset: 0.8 },
|
||||
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
|
||||
{ transform: `translateX(${sign * 100}vw)${scale(0)}`, opacity: '0', offset: 1 },
|
||||
];
|
||||
};
|
||||
|
||||
contentElement.style.transformOrigin = 'center';
|
||||
|
||||
const currentImageAnimation = contentElement.animate(createAnimationKeyframes(direction, false), {
|
||||
duration: ANIMATION_DURATION_MS,
|
||||
easing: 'linear',
|
||||
fill: 'both',
|
||||
});
|
||||
|
||||
// Preview slides in from opposite side of swipe direction
|
||||
const previewContainer = direction === 'left' ? rightPreviewContainer : leftPreviewContainer;
|
||||
let previewAnimation: Animation | null = null;
|
||||
|
||||
if (previewContainer) {
|
||||
previewContainer.style.transformOrigin = 'center';
|
||||
previewAnimation = previewContainer.animate(createAnimationKeyframes(direction, true), {
|
||||
duration: ANIMATION_DURATION_MS,
|
||||
easing: 'linear',
|
||||
fill: 'both',
|
||||
});
|
||||
}
|
||||
|
||||
currentImageAnimation.pause();
|
||||
previewAnimation?.pause();
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
return { currentImageAnimation, previewAnimation, abortController };
|
||||
};
|
||||
|
||||
const setAnimationTime = (animations: SwipeAnimations, time: number) => {
|
||||
animations.currentImageAnimation.currentTime = time;
|
||||
if (animations.previewAnimation) {
|
||||
animations.previewAnimation.currentTime = time;
|
||||
}
|
||||
};
|
||||
|
||||
const playAnimation = (animations: SwipeAnimations, playbackRate: number) => {
|
||||
animations.currentImageAnimation.playbackRate = playbackRate;
|
||||
if (animations.previewAnimation) {
|
||||
animations.previewAnimation.playbackRate = playbackRate;
|
||||
}
|
||||
animations.currentImageAnimation.play();
|
||||
animations.previewAnimation?.play();
|
||||
};
|
||||
|
||||
const cancelAnimations = (animations: SwipeAnimations | null) => {
|
||||
if (!animations) {
|
||||
return;
|
||||
}
|
||||
animations.abortController.abort();
|
||||
animations.currentImageAnimation.cancel();
|
||||
animations.previewAnimation?.cancel();
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (disabled || !contentElement || !isValidPointerEvent(event) || !element || isSwipeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
startDrag(event);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const startDrag = (event: PointerEvent) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
startX = event.clientX;
|
||||
currentOffsetX = 0;
|
||||
|
||||
element.setPointerCapture(event.pointerId);
|
||||
dragStartTime = Date.now();
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (disabled || !contentElement || !isDragging || isSwipeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentOffsetX = event.clientX - startX;
|
||||
|
||||
const direction = currentOffsetX < 0 ? 'left' : 'right';
|
||||
const animationTime = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1) * ANIMATION_DURATION_MS;
|
||||
|
||||
if (direction === 'left') {
|
||||
if (!leftAnimations) {
|
||||
leftAnimations = createSwipeAnimations('left');
|
||||
}
|
||||
if (leftAnimations) {
|
||||
setAnimationTime(leftAnimations, animationTime);
|
||||
}
|
||||
if (rightAnimations) {
|
||||
cancelAnimations(rightAnimations);
|
||||
rightAnimations = null;
|
||||
}
|
||||
} else {
|
||||
if (!rightAnimations) {
|
||||
rightAnimations = createSwipeAnimations('right');
|
||||
}
|
||||
if (rightAnimations) {
|
||||
setAnimationTime(rightAnimations, animationTime);
|
||||
}
|
||||
if (leftAnimations) {
|
||||
cancelAnimations(leftAnimations);
|
||||
leftAnimations = null;
|
||||
}
|
||||
}
|
||||
onSwipeMove?.(currentOffsetX);
|
||||
event.preventDefault(); // Prevent scrolling during drag
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (!isDragging || !isValidPointerEvent(event) || !contentElement || !element) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
if (element.hasPointerCapture(event.pointerId)) {
|
||||
element.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const dragDuration = dragStartTime ? Date.now() - dragStartTime : 0;
|
||||
const velocity = dragDuration > 0 ? Math.abs(currentOffsetX) / dragDuration : 0;
|
||||
const progress = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1);
|
||||
|
||||
if (
|
||||
Math.abs(currentOffsetX) < swipeThreshold ||
|
||||
(velocity < MIN_SWIPE_VELOCITY && progress <= MIN_PROGRESS_THRESHOLD)
|
||||
) {
|
||||
resetPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
isSwipeInProgress = true;
|
||||
|
||||
onSwipeEnd?.(currentOffsetX);
|
||||
completeTransition(currentOffsetX > 0 ? 'right' : 'left');
|
||||
};
|
||||
|
||||
const resetPosition = () => {
|
||||
if (!contentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = currentOffsetX < 0 ? 'left' : 'right';
|
||||
const animations = direction === 'left' ? leftAnimations : rightAnimations;
|
||||
|
||||
if (!animations) {
|
||||
currentOffsetX = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
playAnimation(animations, -1);
|
||||
|
||||
const handleFinish = () => {
|
||||
cancelAnimations(animations);
|
||||
if (direction === 'left') {
|
||||
leftAnimations = null;
|
||||
} else {
|
||||
rightAnimations = null;
|
||||
}
|
||||
};
|
||||
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
|
||||
signal: animations.abortController.signal,
|
||||
});
|
||||
|
||||
currentOffsetX = 0;
|
||||
};
|
||||
|
||||
const completeTransition = (direction: 'left' | 'right') => {
|
||||
if (!contentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animations = direction === 'left' ? leftAnimations : rightAnimations;
|
||||
if (!animations) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Number(animations.currentImageAnimation.currentTime) || 0;
|
||||
|
||||
if (currentTime >= ANIMATION_DURATION_MS - ANIMATION_COMPLETION_TOLERANCE_MS) {
|
||||
onSwipe?.(direction);
|
||||
return;
|
||||
}
|
||||
|
||||
playAnimation(animations, 1);
|
||||
|
||||
const handleFinish = () => {
|
||||
if (contentElement) {
|
||||
onSwipe?.(direction);
|
||||
}
|
||||
};
|
||||
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
|
||||
signal: animations.abortController.signal,
|
||||
});
|
||||
};
|
||||
|
||||
const resetPreviewContainers = () => {
|
||||
cancelAnimations(leftAnimations);
|
||||
cancelAnimations(rightAnimations);
|
||||
leftAnimations = null;
|
||||
rightAnimations = null;
|
||||
|
||||
if (contentElement) {
|
||||
contentElement.style.transform = '';
|
||||
contentElement.style.transition = '';
|
||||
contentElement.style.opacity = '';
|
||||
}
|
||||
currentOffsetX = 0;
|
||||
};
|
||||
|
||||
const resetSwipeFeedback = () => {
|
||||
resetPreviewContainers();
|
||||
isSwipeInProgress = false;
|
||||
};
|
||||
|
||||
reset = resetSwipeFeedback;
|
||||
|
||||
onDestroy(() => {
|
||||
resetSwipeFeedback();
|
||||
if (element) {
|
||||
element.style.cursor = '';
|
||||
}
|
||||
if (contentElement) {
|
||||
contentElement.style.cursor = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Listen on window to catch pointer release outside element (due to setPointerCapture) -->
|
||||
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
bind:clientWidth
|
||||
bind:clientHeight
|
||||
class={className}
|
||||
style:cursor={cursorStyle}
|
||||
style:view-transition-name={transitionName}
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
role="presentation"
|
||||
>
|
||||
{#if leftPreview}
|
||||
<!-- Swiping right reveals left preview -->
|
||||
<div
|
||||
bind:this={leftPreviewContainer}
|
||||
class="absolute inset-0"
|
||||
style:pointer-events="none"
|
||||
style:display={rightAnimations ? 'block' : 'none'}
|
||||
>
|
||||
{@render leftPreview()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rightPreview}
|
||||
<!-- Swiping left reveals right preview -->
|
||||
<div
|
||||
bind:this={rightPreviewContainer}
|
||||
class="absolute inset-0"
|
||||
style:pointer-events="none"
|
||||
style:display={leftAnimations ? 'block' : 'none'}
|
||||
>
|
||||
{@render rightPreview()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div bind:this={contentElement} class="h-full w-full" style:cursor={cursorStyle}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte';
|
||||
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
|
||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
@@ -10,54 +13,87 @@
|
||||
videoViewerMuted,
|
||||
videoViewerVolume,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { getDimensions } from '$lib/utils/asset-utils';
|
||||
import { scaleToFit } from '$lib/utils/layout-utils';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string;
|
||||
cursor: AssetCursor;
|
||||
assetId: string;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
loopVideo: boolean;
|
||||
cacheKey: string | null;
|
||||
playOriginalVideo: boolean;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
cursor,
|
||||
assetId,
|
||||
sharedLink,
|
||||
loopVideo,
|
||||
cacheKey,
|
||||
playOriginalVideo,
|
||||
onPreviousAsset = () => {},
|
||||
onNextAsset = () => {},
|
||||
onSwipe,
|
||||
onVideoEnded = () => {},
|
||||
onVideoStarted = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const asset = $derived(cursor.current);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
let isLoading = $state(true);
|
||||
let swipeFeedbackReset: (() => void) | undefined = $state();
|
||||
let assetFileUrl = $derived(
|
||||
playOriginalVideo ? getAssetOriginalUrl({ id: assetId, cacheKey }) : getAssetPlaybackUrl({ id: assetId, cacheKey }),
|
||||
);
|
||||
let previousAssetFileUrl = $state<string | undefined>();
|
||||
let isScrubbing = $state(false);
|
||||
let showVideo = $state(false);
|
||||
|
||||
let containerWidth = $state(document.documentElement.clientWidth);
|
||||
let containerHeight = $state(document.documentElement.clientHeight);
|
||||
|
||||
const exifDimensions = $derived(
|
||||
asset?.exifInfo?.exifImageHeight && asset?.exifInfo.exifImageHeight
|
||||
? (getDimensions(asset.exifInfo) as { width: number; height: number })
|
||||
: null,
|
||||
);
|
||||
const container = $derived({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
let dimensions = $derived(exifDimensions ?? { width: 1, height: 1 });
|
||||
const scaledDimensions = $derived(scaleToFit(dimensions, container));
|
||||
|
||||
onMount(() => {
|
||||
// Show video after mount to ensure fading in.
|
||||
showVideo = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// reactive on `assetFileUrl` changes
|
||||
if (assetFileUrl) {
|
||||
videoPlayer?.load();
|
||||
if (assetFileUrl && assetFileUrl !== previousAssetFileUrl) {
|
||||
previousAssetFileUrl = assetFileUrl;
|
||||
untrack(() => {
|
||||
isLoading = true;
|
||||
videoPlayer?.load();
|
||||
swipeFeedbackReset?.();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,6 +103,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
dimensions = {
|
||||
width: videoPlayer?.videoWidth ?? 1,
|
||||
height: videoPlayer?.videoHeight ?? 1,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||
try {
|
||||
if (!video.paused && !isScrubbing) {
|
||||
@@ -98,76 +141,120 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
if (event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
};
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value) {
|
||||
videoPlayer?.pause();
|
||||
}
|
||||
});
|
||||
|
||||
const calculateSize = () => {
|
||||
const { width, height } = scaledDimensions;
|
||||
|
||||
const size = {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
};
|
||||
|
||||
return size;
|
||||
};
|
||||
|
||||
const box = $derived(calculateSize());
|
||||
</script>
|
||||
|
||||
{#if showVideo}
|
||||
<div
|
||||
transition:fade={{ duration: assetViewerFadeDuration }}
|
||||
class="flex h-full select-none place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
{onVideoEnded}
|
||||
{assetFileUrl}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
playsinline
|
||||
controls
|
||||
disablePictureInPicture
|
||||
class="h-full object-contain"
|
||||
{...useSwipe(onSwipe)}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={$videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
<SwipeFeedback
|
||||
class="flex select-none h-full w-full place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
bind:reset={swipeFeedbackReset}
|
||||
{onSwipe}
|
||||
>
|
||||
{#if showVideo}
|
||||
<div
|
||||
in:fade={{ duration: assetViewerFadeDuration }}
|
||||
class="flex h-full w-full place-content-center place-items-center"
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
{onVideoEnded}
|
||||
{assetFileUrl}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<video
|
||||
style:view-transition-name={transitionName}
|
||||
style:height={box.height}
|
||||
style:width={box.width}
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
playsinline
|
||||
controls
|
||||
disablePictureInPicture
|
||||
onloadedmetadata={() => handleLoadedMetadata()}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={$videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="absolute flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
{#if isLoading}
|
||||
<div class="absolute inset-0 flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet leftPreview()}
|
||||
{#if previousAsset}
|
||||
<AdaptiveImage
|
||||
asset={previousAsset}
|
||||
{sharedLink}
|
||||
container={{ width: containerWidth, height: containerHeight }}
|
||||
zoomDisabled={true}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rightPreview()}
|
||||
{#if nextAsset}
|
||||
<AdaptiveImage
|
||||
asset={nextAsset}
|
||||
{sharedLink}
|
||||
container={{ width: containerWidth, height: containerHeight }}
|
||||
zoomDisabled={true}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SwipeFeedback>
|
||||
|
||||
<style>
|
||||
video:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
|
||||
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import type { SharedLinkResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
assetId: string;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
projectionType: string | null | undefined;
|
||||
cacheKey: string | null;
|
||||
loopVideo: boolean;
|
||||
playOriginalVideo: boolean;
|
||||
onClose?: () => void;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
assetId,
|
||||
sharedLink,
|
||||
projectionType,
|
||||
cacheKey,
|
||||
loopVideo,
|
||||
playOriginalVideo,
|
||||
onPreviousAsset,
|
||||
onSwipe,
|
||||
onClose,
|
||||
onNextAsset,
|
||||
onVideoEnded,
|
||||
onVideoStarted,
|
||||
}: Props = $props();
|
||||
@@ -36,10 +40,11 @@
|
||||
<VideoNativeViewer
|
||||
{loopVideo}
|
||||
{cacheKey}
|
||||
{cursor}
|
||||
{assetId}
|
||||
{sharedLink}
|
||||
{playOriginalVideo}
|
||||
{onPreviousAsset}
|
||||
{onNextAsset}
|
||||
{onSwipe}
|
||||
{onVideoEnded}
|
||||
{onVideoStarted}
|
||||
{onClose}
|
||||
|
||||
@@ -9,14 +9,15 @@ class ImageManager {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
this.preloadImageUrl(getAssetUrlForKind(asset, kind));
|
||||
}
|
||||
|
||||
const url = getAssetUrlForKind(asset, kind);
|
||||
if (!url) {
|
||||
preloadImageUrl(src: string | undefined) {
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
cancel(asset: AssetResponseDto | undefined, kind: CancelImageKind = 'preview') {
|
||||
|
||||
@@ -48,6 +48,7 @@ export class AdaptiveImageLoader {
|
||||
private readonly currentZoomFn?: () => number;
|
||||
private readonly onImageReady?: () => void;
|
||||
private readonly onError?: () => void;
|
||||
private readonly onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
|
||||
private readonly imageLoader?: LoadImageFunction;
|
||||
private readonly destroyFunctions: (() => void)[] = [];
|
||||
readonly thumbnailUrl: string;
|
||||
@@ -61,6 +62,7 @@ export class AdaptiveImageLoader {
|
||||
currentZoomFn: () => number;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
|
||||
},
|
||||
imageLoader?: LoadImageFunction,
|
||||
) {
|
||||
@@ -68,6 +70,7 @@ export class AdaptiveImageLoader {
|
||||
this.currentZoomFn = callbacks?.currentZoomFn;
|
||||
this.onImageReady = callbacks?.onImageReady;
|
||||
this.onError = callbacks?.onError;
|
||||
this.onQualityUpgrade = callbacks?.onQualityUpgrade;
|
||||
this.imageLoader = imageLoader;
|
||||
|
||||
this.thumbnailUrl = getAssetUrlForKind(asset, 'thumbnail');
|
||||
@@ -103,6 +106,7 @@ export class AdaptiveImageLoader {
|
||||
this.state.quality = 'thumbnail';
|
||||
this.state.thumbnailImage = ImageStatus.Success;
|
||||
this.onImageReady?.();
|
||||
this.onQualityUpgrade?.(this.thumbnailUrl, 'thumbnail');
|
||||
this.triggerMainImage();
|
||||
}
|
||||
|
||||
@@ -145,6 +149,7 @@ export class AdaptiveImageLoader {
|
||||
this.state.quality = 'preview';
|
||||
this.state.previewImage = ImageStatus.Success;
|
||||
this.onImageReady?.();
|
||||
this.onQualityUpgrade?.(this.previewUrl, 'preview');
|
||||
}
|
||||
|
||||
onPreviewError() {
|
||||
|
||||
Reference in New Issue
Block a user