feat: web - swipe feedback - show image while swiping/dragging left/right

This commit is contained in:
midzelis
2025-12-09 19:04:41 +00:00
parent 45a5d41fdf
commit 5066203d07
7 changed files with 765 additions and 144 deletions

View File

@@ -0,0 +1,473 @@
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
export interface SwipeFeedbackOptions {
disabled?: boolean;
onSwipeEnd?: (offsetX: number) => void;
onSwipeMove?: (offsetX: number) => void;
/** Preview shown on left when swiping right */
leftPreviewUrl?: string | null;
/** Preview shown on right when swiping left */
rightPreviewUrl?: string | null;
/** Called after animation completes when threshold exceeded */
onSwipeCommit?: (direction: 'left' | 'right') => void;
/** Minimum pixels to activate swipe (default: 45) */
swipeThreshold?: number;
/** When changed, preview containers are reset */
currentAssetUrl?: string | null;
/** Element to apply swipe transforms to */
target?: HTMLElement | null;
}
interface SwipeAnimations {
currentImageAnimation: Animation;
previewAnimation: Animation | null;
}
/**
* Horizontal swipe gesture with visual feedback and optional preview images.
* Requires swipeSubject to be provided in options.
*/
export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions) => {
const ANIMATION_DURATION_MS = 300;
const ENABLE_SCALE_ANIMATION = false;
let target = options?.target;
let isDragging = false;
let startX = 0;
let currentOffsetX = 0;
let lastAssetUrl = options?.currentAssetUrl;
let dragStartTime: Date | null = null;
let swipeAmount = 0;
let leftAnimations: SwipeAnimations | null = null;
let rightAnimations: SwipeAnimations | null = null;
node.style.cursor = 'grab';
const getContainersForDirection = (direction: 'left' | 'right') => ({
animations: direction === 'left' ? leftAnimations : rightAnimations,
previewContainer: direction === 'left' ? rightPreviewContainer : leftPreviewContainer,
oppositeAnimations: direction === 'left' ? rightAnimations : leftAnimations,
oppositeContainer: direction === 'left' ? leftPreviewContainer : rightPreviewContainer,
});
const isValidPointerEvent = (event: PointerEvent) =>
event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0);
const cancelAnimations = (animations: SwipeAnimations | null) => {
animations?.currentImageAnimation?.cancel();
animations?.previewAnimation?.cancel();
};
const resetContainerStyle = (container: HTMLElement | null) => {
if (!container) {
return;
}
container.style.transform = '';
container.style.transition = '';
container.style.zIndex = '-1';
container.style.display = 'none';
};
const resetPreviewContainers = () => {
cancelAnimations(leftAnimations);
cancelAnimations(rightAnimations);
leftAnimations = null;
rightAnimations = null;
resetContainerStyle(leftPreviewContainer);
resetContainerStyle(rightPreviewContainer);
if (target) {
target.style.transform = '';
target.style.transition = '';
target.style.opacity = '';
}
currentOffsetX = 0;
};
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 },
];
};
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
if (!target) {
return null;
}
target.style.transformOrigin = 'center';
const currentImageAnimation = target.animate(createAnimationKeyframes(direction, false), {
duration: ANIMATION_DURATION_MS,
easing: 'linear',
fill: 'both',
});
const { previewContainer } = getContainersForDirection(direction);
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();
return { currentImageAnimation, previewAnimation };
};
let leftPreviewContainer: HTMLDivElement | null = null;
let rightPreviewContainer: HTMLDivElement | null = null;
let leftPreviewImg: HTMLImageElement | null = null;
let rightPreviewImg: HTMLImageElement | null = null;
const createPreviewContainer = (): { container: HTMLDivElement; img: HTMLImageElement } => {
const container = document.createElement('div');
container.style.position = 'absolute';
container.style.pointerEvents = 'none';
container.style.display = 'none';
container.style.zIndex = '-1';
const img = document.createElement('img');
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'contain';
img.draggable = false;
img.alt = '';
container.append(img);
node.parentElement?.append(container);
return { container, img };
};
const ensurePreviewCreated = (
url: string | null | undefined,
container: HTMLDivElement | null,
img: HTMLImageElement | null,
) => {
if (!url || container) {
return { container, img };
}
const preview = createPreviewContainer();
preview.img.src = url;
return preview;
};
const ensurePreviewsCreated = () => {
if (options?.leftPreviewUrl && !leftPreviewContainer) {
const preview = ensurePreviewCreated(options.leftPreviewUrl, leftPreviewContainer, leftPreviewImg);
leftPreviewContainer = preview.container;
leftPreviewImg = preview.img;
}
if (options?.rightPreviewUrl && !rightPreviewContainer) {
const preview = ensurePreviewCreated(options.rightPreviewUrl, rightPreviewContainer, rightPreviewImg);
rightPreviewContainer = preview.container;
rightPreviewImg = preview.img;
}
};
const positionContainer = (container: HTMLElement | null, width: number, height: number) => {
if (!container) {
return;
}
Object.assign(container.style, {
width: `${width}px`,
height: `${height}px`,
left: '0px',
top: '0px',
});
};
const updatePreviewPositions = () => {
const parentElement = node.parentElement;
if (!parentElement) {
return;
}
const { width, height } = globalThis.getComputedStyle(parentElement);
const viewportWidth = Number.parseFloat(width);
const viewportHeight = Number.parseFloat(height);
positionContainer(leftPreviewContainer, viewportWidth, viewportHeight);
positionContainer(rightPreviewContainer, viewportWidth, viewportHeight);
};
const calculateAnimationProgress = (dragPixels: number) => Math.min(dragPixels / window.innerWidth, 1);
const pointerDown = (event: PointerEvent) => {
if (options?.disabled || !target || !isValidPointerEvent(event)) {
return;
}
isDragging = true;
startX = event.clientX;
swipeAmount = 0;
node.style.cursor = 'grabbing';
node.setPointerCapture(event.pointerId);
dragStartTime = new Date();
document.addEventListener('pointerup', pointerUp);
document.addEventListener('pointercancel', pointerUp);
ensurePreviewsCreated();
updatePreviewPositions();
event.preventDefault();
};
const setContainerVisibility = (container: HTMLElement | null, visible: boolean) => {
if (!container) {
return;
}
container.style.display = visible ? 'block' : 'none';
if (visible) {
container.style.zIndex = '1';
}
};
const updateAnimationTime = (animations: SwipeAnimations, time: number) => {
animations.currentImageAnimation.currentTime = time;
if (animations.previewAnimation) {
animations.previewAnimation.currentTime = time;
}
};
const pointerMove = (event: PointerEvent) => {
if (options?.disabled || !target || !isDragging) {
return;
}
currentOffsetX = event.clientX - startX;
swipeAmount = currentOffsetX;
const direction = currentOffsetX < 0 ? 'left' : 'right';
const animationTime = calculateAnimationProgress(Math.abs(currentOffsetX)) * ANIMATION_DURATION_MS;
const { animations, previewContainer, oppositeAnimations, oppositeContainer } =
getContainersForDirection(direction);
if (!animations) {
if (direction === 'left') {
leftAnimations = createSwipeAnimations('left');
} else {
rightAnimations = createSwipeAnimations('right');
}
setContainerVisibility(previewContainer, true);
}
const currentAnimations = direction === 'left' ? leftAnimations : rightAnimations;
if (currentAnimations) {
setContainerVisibility(previewContainer, true);
updateAnimationTime(currentAnimations, animationTime);
if (oppositeAnimations) {
cancelAnimations(oppositeAnimations);
if (direction === 'left') {
rightAnimations = null;
} else {
leftAnimations = null;
}
setContainerVisibility(oppositeContainer, false);
}
}
options?.onSwipeMove?.(currentOffsetX);
event.preventDefault();
};
const setPlaybackRate = (animations: SwipeAnimations, rate: number) => {
animations.currentImageAnimation.playbackRate = rate;
if (animations.previewAnimation) {
animations.previewAnimation.playbackRate = rate;
}
};
const playAnimations = (animations: SwipeAnimations) => {
animations.currentImageAnimation.play();
animations.previewAnimation?.play();
};
const resetPosition = () => {
if (!target) {
return;
}
const direction = currentOffsetX < 0 ? 'left' : 'right';
const { animations, previewContainer } = getContainersForDirection(direction);
if (!animations) {
currentOffsetX = 0;
return;
}
setPlaybackRate(animations, -1);
playAnimations(animations);
const handleFinish = () => {
animations.currentImageAnimation.removeEventListener('finish', handleFinish);
cancelAnimations(animations);
resetContainerStyle(previewContainer);
};
animations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true });
currentOffsetX = 0;
};
const commitSwipe = (direction: 'left' | 'right') => {
if (!target) {
return;
}
target.style.opacity = '0';
const { previewContainer } = getContainersForDirection(direction);
if (previewContainer) {
previewContainer.style.zIndex = '1';
}
options?.onSwipeCommit?.(direction);
};
const completeTransition = (direction: 'left' | 'right') => {
if (!target) {
return;
}
const { animations } = getContainersForDirection(direction);
if (!animations) {
return;
}
const currentTime = Number(animations.currentImageAnimation.currentTime) || 0;
if (currentTime >= ANIMATION_DURATION_MS - 5) {
commitSwipe(direction);
return;
}
setPlaybackRate(animations, 1);
playAnimations(animations);
const handleFinish = () => {
if (target) {
commitSwipe(direction);
}
};
animations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true });
};
const pointerUp = (event: PointerEvent) => {
if (!isDragging || !isValidPointerEvent(event) || !target) {
return;
}
isDragging = false;
node.style.cursor = 'grab';
if (node.hasPointerCapture(event.pointerId)) {
node.releasePointerCapture(event.pointerId);
}
document.removeEventListener('pointerup', pointerUp);
document.removeEventListener('pointercancel', pointerUp);
const threshold = options?.swipeThreshold ?? 45;
const velocity = Math.abs(swipeAmount) / (Date.now() - (dragStartTime?.getTime() ?? 0));
const progress = calculateAnimationProgress(Math.abs(currentOffsetX));
if (Math.abs(swipeAmount) < threshold || (velocity < 0.11 && progress <= 0.25)) {
resetPosition();
return;
}
options?.onSwipeEnd?.(currentOffsetX);
completeTransition(currentOffsetX > 0 ? 'right' : 'left');
};
node.addEventListener('pointerdown', pointerDown);
node.addEventListener('pointermove', pointerMove);
node.addEventListener('pointerup', pointerUp);
node.addEventListener('pointercancel', pointerUp);
return {
update(newOptions?: SwipeFeedbackOptions) {
if (newOptions?.target && newOptions.target !== target) {
resetPreviewContainers();
target = newOptions.target;
}
if (newOptions?.currentAssetUrl && newOptions.currentAssetUrl !== lastAssetUrl) {
resetPreviewContainers();
lastAssetUrl = newOptions.currentAssetUrl;
}
const lastLeftPreviewUrl = options?.leftPreviewUrl;
const lastRightPreviewUrl = options?.rightPreviewUrl;
if (
lastLeftPreviewUrl &&
lastLeftPreviewUrl != newOptions?.leftPreviewUrl &&
lastLeftPreviewUrl !== newOptions?.currentAssetUrl
) {
preloadManager.cancelPreloadUrl(lastLeftPreviewUrl);
}
if (
lastRightPreviewUrl &&
lastRightPreviewUrl != newOptions?.rightPreviewUrl &&
lastRightPreviewUrl !== newOptions?.currentAssetUrl
) {
preloadManager.cancelPreloadUrl(lastRightPreviewUrl);
}
options = newOptions;
if (options?.leftPreviewUrl) {
if (leftPreviewImg) {
leftPreviewImg.src = options.leftPreviewUrl;
} else {
const preview = ensurePreviewCreated(options.leftPreviewUrl, leftPreviewContainer, leftPreviewImg);
leftPreviewContainer = preview.container;
leftPreviewImg = preview.img;
}
}
if (options?.rightPreviewUrl) {
if (rightPreviewImg) {
rightPreviewImg.src = options.rightPreviewUrl;
} else {
const preview = ensurePreviewCreated(options.rightPreviewUrl, rightPreviewContainer, rightPreviewImg);
rightPreviewContainer = preview.container;
rightPreviewImg = preview.img;
}
}
},
destroy() {
cancelAnimations(leftAnimations);
cancelAnimations(rightAnimations);
node.removeEventListener('pointerdown', pointerDown);
node.removeEventListener('pointermove', pointerMove);
node.removeEventListener('pointerup', pointerUp);
node.removeEventListener('pointercancel', pointerUp);
document.removeEventListener('pointerup', pointerUp);
document.removeEventListener('pointercancel', pointerUp);
leftPreviewContainer?.remove();
rightPreviewContainer?.remove();
node.style.cursor = '';
},
};
};

View File

@@ -3,46 +3,63 @@ import { useZoomImageWheel } from '@zoom-image/svelte';
import { get } from 'svelte/store';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(node, {
maxZoom: 10,
});
const state = get(photoZoomState);
if (state) {
setZoomImageState(state);
}
// Store original event handlers so we can prevent them when disabled
const wheelHandler = (event: WheelEvent) => {
let unsubscribes: (() => void)[] = [];
const createZoomAction = (newOptions?: { disabled?: boolean }) => {
options = newOptions;
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
const pointerDownHandler = (event: PointerEvent) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
// Add handlers at capture phase with higher priority
node.addEventListener('wheel', wheelHandler, { capture: true });
node.addEventListener('pointerdown', pointerDownHandler, { capture: true });
const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)];
return {
update(newOptions?: { disabled?: boolean }) {
options = newOptions;
},
destroy() {
node.removeEventListener('wheel', wheelHandler, { capture: true });
node.removeEventListener('pointerdown', pointerDownHandler, { capture: true });
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
unsubscribes = [];
} else {
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(node, {
maxZoom: 10,
});
const state = get(photoZoomState);
if (state) {
setZoomImageState(state);
}
node.style.overflow = 'visible';
// Store original event handlers so we can prevent them when disabled
const wheelHandler = (event: WheelEvent) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
const pointerDownHandler = (event: PointerEvent) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
// Add handlers at capture phase with higher priority
node.addEventListener('wheel', wheelHandler, { capture: true });
node.addEventListener('pointerdown', pointerDownHandler, { capture: true });
unsubscribes = [
photoZoomState.subscribe(setZoomImageState),
zoomImageState.subscribe(photoZoomState.set),
() => node.removeEventListener('wheel', wheelHandler, { capture: true }),
() => node.removeEventListener('pointerdown', pointerDownHandler, { capture: true }),
];
}
};
createZoomAction();
return {
update(newOptions?: { disabled?: boolean }) {
createZoomAction(newOptions);
},
destroy() {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
unsubscribes = [];
},
};
};

View File

@@ -96,7 +96,6 @@
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
slideshowTransition,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
@@ -327,6 +326,7 @@
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? asset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
@@ -506,13 +506,14 @@
cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
nextAsset={cursor.nextAsset}
previousAsset={cursor.previousAsset}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
@@ -525,6 +526,9 @@
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
assetId={asset.livePhotoVideoId!}
nextAsset={cursor.nextAsset}
previousAsset={cursor.previousAsset}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
@@ -545,12 +549,14 @@
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
onFree={() => eventManager.emit('AssetViewerFree')}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
assetId={asset.id}
nextAsset={cursor.nextAsset}
previousAsset={cursor.previousAsset}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { swipeFeedback } from '$lib/actions/swipe-feedback';
import { zoomImageAction } from '$lib/actions/zoom-image';
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 { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
@@ -14,8 +14,9 @@
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { canCopyImageToClipboard, copyImageToClipboard, getDimensions } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { scaleToFit } from '$lib/utils/layout-utils';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
@@ -23,16 +24,13 @@
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount, 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;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
onPreviousAsset?: (() => void) | null;
onFree?: (() => void) | null;
onBusy?: (() => void) | null;
@@ -46,8 +44,7 @@
let {
cursor,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
sharedLink,
onPreviousAsset = null,
onNextAsset = null,
onFree = null,
@@ -67,6 +64,16 @@
let loader = $state<HTMLImageElement>();
const box = $derived.by(() => {
const { width, height } = scaledDimensions;
return {
width: width + 'px',
height: height + 'px',
left: (containerWidth - width) / 2 + 'px',
top: (containerHeight - height) / 2 + 'px',
};
});
photoZoomState.set({
currentRotation: 0,
currentZoom: 1,
@@ -122,21 +129,12 @@
event.preventDefault();
handlePromiseError(copyImage());
};
const onSwipe = (event: SwipeCustomEvent) => {
if ($photoZoomState.currentZoom > 1) {
return;
}
if (ocrManager.showOverlay) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
const handleSwipeCommit = (direction: 'left' | 'right') => {
if (direction === 'left' && onNextAsset) {
// Swiped left, go to next asset
onNextAsset();
}
if (onPreviousAsset && event.detail.direction === 'right') {
} else if (direction === 'right' && onPreviousAsset) {
// Swiped right, go to previous asset
onPreviousAsset();
}
};
@@ -167,12 +165,20 @@
onLoad?.();
onFree?.();
imageLoaded = true;
dimensions = {
width: loader?.naturalWidth ?? 1,
height: loader?.naturalHeight ?? 1,
};
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
onError?.();
onFree?.();
dimensions = {
width: loader?.naturalWidth ?? 1,
height: loader?.naturalHeight ?? 1,
};
imageError = imageLoaded = true;
};
@@ -181,18 +187,36 @@
if (!imageLoaded && !imageError) {
onFree?.();
}
preloadManager.cancelPreloadUrl(imageLoaderUrl);
if (imageLoaderUrl) {
preloadManager.cancelPreloadUrl(imageLoaderUrl);
}
};
});
let imageLoaderUrl = $derived(
const imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
);
const previousAssetUrl = $derived(getAssetUrl({ asset: cursor.previousAsset, sharedLink }));
const nextAssetUrl = $derived(getAssetUrl({ asset: cursor.nextAsset, sharedLink }));
const exifDimensions = $derived(
asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageHeight
? (getDimensions(asset.exifInfo) as { width: number; height: number })
: null,
);
let containerWidth = $state(0);
let containerHeight = $state(0);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
let dimensions = $derived(exifDimensions ?? { width: 1, height: 1 });
const scaledDimensions = $derived(scaleToFit(dimensions, container));
let lastUrl: string | undefined | null;
let lastUrl: string | undefined | null | null;
let lastPreviousUrl: string | undefined | null;
let lastNextUrl: string | undefined | null;
$effect(() => {
if (!lastUrl) {
@@ -200,13 +224,21 @@
}
if (lastUrl && lastUrl !== imageLoaderUrl) {
untrack(() => {
imageLoaded = false;
const isPreviewedImage = imageLoaderUrl === lastPreviousUrl || imageLoaderUrl === lastNextUrl;
if (!isPreviewedImage) {
// It is a previewed image - prevent flicker - skip spinner but still let loader go through lifecycle
imageLoaded = false;
}
originalImageLoaded = false;
imageError = false;
onBusy?.();
});
}
lastUrl = imageLoaderUrl;
lastPreviousUrl = previousAssetUrl;
lastNextUrl = nextAssetUrl;
});
</script>
@@ -230,18 +262,21 @@
class="relative h-full w-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
use:swipeFeedback={{
disabled: isOcrActive || $photoZoomState.currentZoom > 1,
onSwipeCommit: handleSwipeCommit,
leftPreviewUrl: previousAssetUrl,
rightPreviewUrl: nextAssetUrl,
currentAssetUrl: imageLoaderUrl,
target: $photoViewerImgElement,
}}
>
{#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 }}
>
<div class="absolute" style:width={box.width} style:height={box.height} style:left={box.left} style:top={box.top}>
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !imageError}
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={imageLoaderUrl}
@@ -250,32 +285,34 @@
draggable="false"
/>
{/if}
<img
bind:this={$photoViewerImgElement}
src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{/each}
<div use:zoomImageAction={{ disabled: isOcrActive }} style:width={box.width} style:height={box.height}>
<img
bind:this={$photoViewerImgElement}
src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{/each}
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{/if}
{/if}
</div>
</div>
<style>

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import { swipeFeedback } from '$lib/actions/swipe-feedback';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import {
autoPlayVideo,
@@ -10,15 +13,19 @@
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl, getAssetUrl } from '$lib/utils';
import { getDimensions } from '$lib/utils/asset-utils';
import { scaleToFit } from '$lib/utils/layout-utils';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition';
interface Props {
assetId: string;
previousAsset?: AssetResponseDto | null | undefined;
nextAsset?: AssetResponseDto | undefined | null | undefined;
sharedLink?: SharedLinkResponseDto;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
@@ -31,6 +38,9 @@
let {
assetId,
previousAsset,
nextAsset,
sharedLink,
loopVideo,
cacheKey,
playOriginalVideo,
@@ -41,6 +51,8 @@
onClose = () => {},
}: Props = $props();
let asset = $state<AssetResponseDto | null>(null);
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $derived(
@@ -49,11 +61,31 @@
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(
() =>
void assetCacheManager.getAsset({ key: cacheKey ?? assetId, id: assetId }).then((assetDto) => (asset = assetDto)),
);
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
@@ -67,6 +99,14 @@
}
});
const handleLoadedMetadata = () => {
dimensions = {
width: videoPlayer?.videoWidth ?? 1,
height: videoPlayer?.videoHeight ?? 1,
};
eventManager.emit('AssetViewerFree');
};
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
@@ -98,31 +138,50 @@
}
};
const onSwipe = (event: SwipeCustomEvent) => {
if (event.detail.direction === 'left') {
const handleSwipeCommit = (direction: 'left' | 'right') => {
if (direction === 'left' && onNextAsset) {
onNextAsset();
}
if (event.detail.direction === 'right') {
} else if (direction === 'right' && onPreviousAsset) {
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());
const previousAssetUrl = $derived(getAssetUrl({ asset: previousAsset, sharedLink }));
const nextAssetUrl = $derived(getAssetUrl({ asset: nextAsset, sharedLink }));
</script>
{#if showVideo}
<div
transition:fade={{ duration: assetViewerFadeDuration }}
class="flex h-full select-none place-content-center place-items-center"
in:fade={{ duration: assetViewerFadeDuration }}
class="flex select-none h-full w-full place-content-center place-items-center"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
use:swipeFeedback={{
onSwipeCommit: handleSwipeCommit,
leftPreviewUrl: previousAssetUrl,
rightPreviewUrl: nextAssetUrl,
currentAssetUrl: assetFileUrl,
target: videoPlayer,
}}
>
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
@@ -134,40 +193,43 @@
/>
</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>
<div>
<video
style:height={box.height}
style:width={box.width}
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
onloadedmetadata={() => handleLoadedMetadata()}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
</div>
{/if}
</div>
{/if}

View File

@@ -2,9 +2,13 @@
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
interface Props {
assetId: string;
previousAsset?: AssetResponseDto | null | undefined;
nextAsset?: AssetResponseDto | null | undefined;
sharedLink?: SharedLinkResponseDto;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
@@ -18,6 +22,9 @@
let {
assetId,
previousAsset,
nextAsset,
sharedLink,
projectionType,
cacheKey,
loopVideo,
@@ -37,6 +44,9 @@
{loopVideo}
{cacheKey}
{assetId}
{nextAsset}
{sharedLink}
{previousAsset}
{playOriginalVideo}
{onPreviousAsset}
{onNextAsset}

View File

@@ -130,3 +130,19 @@ export type CommonPosition = {
width: number;
height: number;
};
// Scales dimensions to fit within a container (like object-fit: contain)
export const scaleToFit = (
dimensions: { width: number; height: number },
container: { width: number; height: number },
) => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.min(scaleX, scaleY);
return {
width: dimensions.width * scale,
height: dimensions.height * scale,
};
};