feat: swipe feedback

refactor: replace onPreviousAsset/onNextAsset with onSwipe

refactor: make InvocationTracker.invoke accept catch/finally callbacks
This commit is contained in:
midzelis 2026-01-12 23:43:41 +00:00
parent 6ea7235e3c
commit 1d0760b4cd
7 changed files with 672 additions and 170 deletions

View File

@ -37,7 +37,6 @@
} 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';
@ -186,53 +185,57 @@
assetViewerManager.closeEditor();
};
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next') => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
} else {
return;
const getNavigationTarget = () => {
if ($slideshowState === SlideshowState.PlaySlideshow) {
return $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
} else {
return 'skip';
}
};
const completeNavigation = async (target: 'previous' | 'next') => {
preloadManager.cancelBeforeNavigation(target);
let hasNext: boolean;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = target === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else {
hasNext =
target === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
}
preloadManager.cancelBeforeNavigation(order);
if (tracker.isActive()) {
if ($slideshowState !== SlideshowState.PlaySlideshow) {
return;
}
void tracker.invoke(async () => {
let hasNext: boolean;
if (hasNext) {
$restartSlideshowProgress = true;
} else if ($slideshowRepeat && slideshowStartAssetId) {
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
};
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else {
hasNext =
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
}
const tracker = new InvocationTracker();
const navigateAsset = (target: 'previous' | 'next' | 'skip') => {
if (target === 'skip' || tracker.isActive()) {
return;
}
if ($slideshowState !== SlideshowState.PlaySlideshow) {
return;
}
if (hasNext) {
$restartSlideshowProgress = true;
} else if ($slideshowRepeat && slideshowStartAssetId) {
// Loop back to starting asset
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}, $t('error_while_navigating'));
void tracker.invoke(
() => completeNavigation(target),
(error: unknown) => handleError(error, $t('error_while_navigating')),
() => eventManager.emit('ViewerFinishNavigate'),
);
};
/**
@ -419,24 +422,6 @@
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]} />
@ -492,26 +477,26 @@
<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!}
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()}
onVideoEnded={() => navigateAsset(getNavigationTarget())}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{asset}
{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}
/>
@ -520,17 +505,21 @@
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
<PhotoViewer
cursor={{ ...cursor, current: asset }}
{sharedLink}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{asset}
{cursor}
{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()}
onVideoEnded={() => navigateAsset(getNavigationTarget())}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>

View File

@ -5,6 +5,7 @@
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 SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
@ -21,7 +22,7 @@
import { type SharedLinkResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fromAction } from 'svelte/attachments';
import { t } from 'svelte-i18n';
import type { AssetCursor } from './asset-viewer.svelte';
@ -31,7 +32,7 @@
sharedLink?: SharedLinkResponseDto;
onReady?: () => void;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
onSwipe?: (direction: 'left' | 'right') => void;
}
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
@ -163,6 +164,13 @@
});
const faces = $derived(Array.from(faceToNameMap.keys()));
let swipeFeedbackReset = $state<(() => void) | undefined>();
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
untrack(() => swipeFeedbackReset?.());
});
</script>
<AssetViewerEvents {onCopy} {onZoom} />
@ -176,14 +184,17 @@
]}
/>
<div
bind:this={element}
<SwipeFeedback
bind:element
class="relative h-full w-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
role="presentation"
use:zoomImageAction={{ disabled: isFaceEditMode.value, zoomTarget: adaptiveImage }}
{...useSwipe((event) => onSwipe?.(event))}
disabled={!onSwipe || ocrManager.showOverlay || assetViewerManager.zoom > 1}
disableSwipeLeft={!cursor.nextAsset}
disableSwipeRight={!cursor.previousAsset}
bind:reset={swipeFeedbackReset}
onSwipe={onSwipe ?? (() => {})}
{@attach fromAction(zoomImageAction, () => ({ disabled: isFaceEditMode.value, zoomTarget: adaptiveImage }))}
>
<AdaptiveImage
{asset}
@ -252,4 +263,30 @@
{#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
</div>
{#snippet leftPreview()}
{#if cursor.previousAsset}
<AdaptiveImage
asset={cursor.previousAsset}
{sharedLink}
{container}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
{#snippet rightPreview()}
{#if cursor.nextAsset}
<AdaptiveImage
asset={cursor.nextAsset}
{sharedLink}
{container}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
</SwipeFeedback>

View File

@ -0,0 +1,393 @@
<script lang="ts">
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { Snippet } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
type SwipeProps = {
disabled?: boolean;
disableSwipeLeft?: boolean;
disableSwipeRight?: boolean;
onSwipeEnd?: (offsetX: number) => void;
onSwipeMove?: (offsetX: number) => void;
onSwipe: (direction: 'left' | 'right') => void;
element?: HTMLDivElement;
clientWidth?: number;
clientHeight?: number;
reset?: () => void;
children: Snippet;
leftPreview?: Snippet;
rightPreview?: Snippet;
} & HTMLAttributes<HTMLDivElement>;
let {
disabled = false,
disableSwipeLeft = false,
disableSwipeRight = false,
onSwipeEnd,
onSwipeMove,
onSwipe,
element = $bindable(),
clientWidth = $bindable(),
clientHeight = $bindable(),
// eslint-disable-next-line no-useless-assignment
reset = $bindable(),
children,
leftPreview,
rightPreview,
...restProps
}: SwipeProps = $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;
const SWIPE_THRESHOLD = 45;
// 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 ? '' : 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;
}
const rawOffsetX = event.clientX - startX;
const direction = rawOffsetX < 0 ? 'left' : 'right';
if ((direction === 'left' && disableSwipeLeft) || (direction === 'right' && disableSwipeRight)) {
currentOffsetX = 0;
cancelAnimations(leftAnimations);
cancelAnimations(rightAnimations);
leftAnimations = null;
rightAnimations = null;
return;
}
currentOffsetX = rawOffsetX;
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) < SWIPE_THRESHOLD ||
(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 finishSwipeInProgress = () => {
isSwipeInProgress = false;
};
const resetSwipeFeedback = () => {
resetPreviewContainers();
finishSwipeInProgress();
};
// eslint-disable-next-line no-useless-assignment
reset = resetSwipeFeedback;
onMount(() =>
eventManager.on({
ViewerFinishNavigate: finishSwipeInProgress,
ResetSwipeFeedback: 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
{...restProps}
bind:this={element}
bind:clientWidth
bind:clientHeight
style:cursor={cursorStyle}
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>

View File

@ -1,5 +1,8 @@
<script lang="ts">
import AdaptiveImage from '$lib/components/AdaptiveImage.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,56 +13,83 @@
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { scaleToFit } from '$lib/utils/container-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 {
assetId: 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 {
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 effectiveAssetId = $derived(assetId ?? asset.id);
const { slideshowState, slideshowLook } = slideshowStore;
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $derived(
playOriginalVideo
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
? getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: effectiveAssetId, 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 assetDimensions = $derived(
(asset.width ?? 0) > 0 && (asset.height ?? 0) > 0 ? { width: asset.width!, height: asset.height! } : null,
);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
let dimensions = $derived(assetDimensions ?? { 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();
});
}
});
@ -69,6 +99,13 @@
}
});
const handleLoadedMetadata = () => {
dimensions = {
width: videoPlayer?.videoWidth ?? 1,
height: videoPlayer?.videoHeight ?? 1,
};
};
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
@ -100,76 +137,116 @@
}
};
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={getAssetMediaUrl({ 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={getAssetMediaUrl({ 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}
{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={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
/>
</div>
{:else}
<div class="relative">
<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={getAssetMediaUrl({ id: effectiveAssetId, 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={effectiveAssetId} />
{/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 }}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
</div>
{/if}
{/snippet}
{#snippet rightPreview()}
{#if nextAsset}
<AdaptiveImage
asset={nextAsset}
{sharedLink}
container={{ width: containerWidth, height: containerHeight }}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
</SwipeFeedback>
<style>
video:focus {
outline: none;
}
</style>

View File

@ -1,50 +1,50 @@
<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 { AssetResponseDto } from '@immich/sdk';
import type { SharedLinkResponseDto } from '@immich/sdk';
interface Props {
asset: AssetResponseDto;
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 {
asset,
cursor,
assetId,
sharedLink,
projectionType,
cacheKey,
loopVideo,
playOriginalVideo,
onPreviousAsset,
onSwipe,
onClose,
onNextAsset,
onVideoEnded,
onVideoStarted,
}: Props = $props();
const effectiveAssetId = $derived(assetId ?? asset.id);
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {asset} />
<VideoPanoramaViewer asset={cursor.current} />
{:else}
<VideoNativeViewer
{loopVideo}
{cacheKey}
assetId={effectiveAssetId}
{cursor}
{assetId}
{sharedLink}
{playOriginalVideo}
{onPreviousAsset}
{onNextAsset}
{onSwipe}
{onVideoEnded}
{onVideoStarted}
{onClose}

View File

@ -21,6 +21,9 @@ import type {
export type Events = {
AppInit: [];
ResetSwipeFeedback: [];
ViewerFinishNavigate: [];
AuthLogin: [LoginResponseDto];
AuthLogout: [];
AuthUserLoaded: [UserAdminResponseDto];

View File

@ -1,5 +1,3 @@
import { handleError } from '$lib/utils/handle-error';
/**
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
* This class helps manage concurrent operations by tracking which invocations are active
@ -53,14 +51,19 @@ export class InvocationTracker {
return this.invocationsStarted !== this.invocationsEnded;
}
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
const invocation = this.startInvocation();
try {
return await invocable();
} catch (error: unknown) {
handleError(error, localizedMessage);
if (catchCallback) {
catchCallback(error);
} else {
console.error(error);
}
} finally {
invocation.endInvocation();
finallyCallback?.();
}
}
}