swipeFeedback

This commit is contained in:
midzelis
2025-12-09 19:04:41 +00:00
parent 06eebfa9c2
commit 1f1d4bb7fd
8 changed files with 561 additions and 46 deletions

View File

@@ -0,0 +1,429 @@
export interface SwipeFeedbackOptions {
/** Whether the swipe feedback is disabled */
disabled?: boolean;
/** Callback when swipe ends with the final offset */
onSwipeEnd?: (offsetX: number) => void;
/** Callback during swipe with current offset */
onSwipeMove?: (offsetX: number) => void;
/** URL for the preview image shown on the left when swiping right (previous) */
leftPreviewUrl?: string | null;
/** URL for the preview image shown on the right when swiping left (next) */
rightPreviewUrl?: string | null;
/** Callback called before swipe commit animation starts - includes direction and preview image dimensions */
onPreCommit?: (direction: 'left' | 'right', naturalWidth: number, naturalHeight: number) => void;
/** Callback when swipe is committed (threshold exceeded) after animation completes */
onSwipeCommit?: (direction: 'left' | 'right') => void;
/** Threshold as a percentage of container width to commit the swipe (default: 0.25 = 25%) */
commitThreshold?: number;
/** Current asset URL - when this changes, preview containers are reset */
currentAssetUrl?: string | null;
}
/**
* Action that provides visual feedback for horizontal swipe gestures.
* Allows the user to drag an element left or right (horizontal only),
* and resets the position when the drag ends.
* Optionally shows preview images on the left/right during swipe.
*/
export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions) => {
// Find the image element to apply custom transforms
const imgElement = node.querySelector('img');
if (!imgElement) {
console.warn('swipeFeedback: No img element found in node');
return {
update() {},
destroy() {},
};
}
let isDragging = false;
let startX = 0;
let currentOffsetX = 0;
let thresholdCrossed = false;
let lastAssetUrl = options?.currentAssetUrl;
// Set initial cursor
node.style.cursor = 'grab';
const resetPreviewContainers = () => {
// Reset transforms and opacity
if (leftPreviewContainer) {
leftPreviewContainer.style.transform = '';
leftPreviewContainer.style.transition = '';
leftPreviewContainer.style.zIndex = '-1';
leftPreviewContainer.style.display = 'none';
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transform = '';
rightPreviewContainer.style.transition = '';
rightPreviewContainer.style.zIndex = '-1';
rightPreviewContainer.style.display = 'none';
}
// Reset main image
imgElement.style.transform = '';
imgElement.style.transition = '';
imgElement.style.opacity = '';
currentOffsetX = 0;
};
// Create preview image containers
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 ensurePreviewsCreated = () => {
// Create left preview if needed and URL is available
if (options?.leftPreviewUrl && !leftPreviewContainer) {
const preview = createPreviewContainer();
leftPreviewContainer = preview.container;
leftPreviewImg = preview.img;
leftPreviewImg.src = options.leftPreviewUrl;
}
// Create right preview if needed and URL is available
if (options?.rightPreviewUrl && !rightPreviewContainer) {
const preview = createPreviewContainer();
rightPreviewContainer = preview.container;
rightPreviewImg = preview.img;
rightPreviewImg.src = options.rightPreviewUrl;
}
};
const updatePreviewPositions = () => {
// Get the parent container dimensions (full viewport area)
const parentElement = node.parentElement;
if (!parentElement) {
return;
}
const parentComputedStyle = globalThis.getComputedStyle(parentElement);
const viewportWidth = Number.parseFloat(parentComputedStyle.width);
const viewportHeight = Number.parseFloat(parentComputedStyle.height);
// Preview containers should be full viewport size
if (leftPreviewContainer) {
leftPreviewContainer.style.width = `${viewportWidth}px`;
leftPreviewContainer.style.height = `${viewportHeight}px`;
leftPreviewContainer.style.left = `${-viewportWidth}px`;
leftPreviewContainer.style.top = `0px`;
}
if (rightPreviewContainer) {
rightPreviewContainer.style.width = `${viewportWidth}px`;
rightPreviewContainer.style.height = `${viewportHeight}px`;
rightPreviewContainer.style.left = `${viewportWidth}px`;
rightPreviewContainer.style.top = `0px`;
}
};
const updatePreviewVisibility = () => {
// Show left preview when swiping right (offsetX > 0)
if (leftPreviewContainer) {
leftPreviewContainer.style.display = currentOffsetX > 0 ? 'block' : 'none';
}
// Show right preview when swiping left (offsetX < 0)
if (rightPreviewContainer) {
rightPreviewContainer.style.display = currentOffsetX < 0 ? 'block' : 'none';
}
};
const pointerDown = (event: PointerEvent) => {
if (options?.disabled) {
return;
}
// Only handle single pointer (mouse or single touch)
if (event.isPrimary && imgElement) {
isDragging = true;
startX = event.clientX;
thresholdCrossed = false;
// Change cursor to grabbing
node.style.cursor = 'grabbing';
// Capture pointer so we continue to receive events even if mouse moves outside element
node.setPointerCapture(event.pointerId);
// Also add document listeners as fallback
document.addEventListener('pointerup', pointerUp);
document.addEventListener('pointercancel', pointerUp);
ensurePreviewsCreated();
updatePreviewPositions();
event.preventDefault();
}
};
const pointerMove = (event: PointerEvent) => {
if (options?.disabled) {
return;
}
if (isDragging && imgElement) {
currentOffsetX = event.clientX - startX;
// Check threshold crossing for cursor feedback
const containerWidth = Number.parseFloat(globalThis.getComputedStyle(node).width);
const threshold = containerWidth * (options?.commitThreshold ?? 0.25);
const crossed = Math.abs(currentOffsetX) >= threshold;
// Update cursor if threshold state changed
if (crossed !== thresholdCrossed) {
thresholdCrossed = crossed;
// Change cursor to indicate threshold crossed (pointer means clickable/actionable)
node.style.cursor = crossed ? 'pointer' : 'grabbing';
}
// Apply transform directly to the image element
// Only translate horizontally (no vertical movement)
imgElement.style.transform = `translate(${currentOffsetX}px, 0px)`;
// Apply same transform to preview containers so they move with the swipe
if (leftPreviewContainer) {
leftPreviewContainer.style.transform = `translate(${currentOffsetX}px, 0px)`;
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transform = `translate(${currentOffsetX}px, 0px)`;
}
// Update preview visibility
updatePreviewVisibility();
// Notify about swipe movement
options?.onSwipeMove?.(currentOffsetX);
event.preventDefault();
}
};
const resetPosition = () => {
// Add smooth transition
const transitionStyle = 'transform 0.3s ease-out';
imgElement.style.transition = transitionStyle;
if (leftPreviewContainer) {
leftPreviewContainer.style.transition = transitionStyle;
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transition = transitionStyle;
}
// Reset transforms
imgElement.style.transform = 'translate(0px, 0px)';
if (leftPreviewContainer) {
leftPreviewContainer.style.transform = 'translate(0px, 0px)';
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transform = 'translate(0px, 0px)';
}
// Remove transition after animation completes
setTimeout(() => {
imgElement.style.transition = '';
if (leftPreviewContainer) {
leftPreviewContainer.style.transition = '';
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transition = '';
}
}, 300);
currentOffsetX = 0;
updatePreviewVisibility();
};
const completeTransition = (direction: 'left' | 'right') => {
// Get the active preview image and its dimensions
const activePreviewImg = direction === 'right' ? leftPreviewImg : rightPreviewImg;
const naturalWidth = activePreviewImg?.naturalWidth ?? 1;
const naturalHeight = activePreviewImg?.naturalHeight ?? 1;
// Call pre-commit callback BEFORE starting the animation
// This allows the parent component to update state with the preview dimensions
options?.onPreCommit?.(direction, naturalWidth, naturalHeight);
// Add smooth transition
const transitionStyle = 'transform 0.3s ease-out';
imgElement.style.transition = transitionStyle;
if (leftPreviewContainer) {
leftPreviewContainer.style.transition = transitionStyle;
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transition = transitionStyle;
}
// Calculate the final offset to center the preview
const parentElement = node.parentElement;
if (!parentElement) {
return;
}
const viewportWidth = Number.parseFloat(globalThis.getComputedStyle(parentElement).width);
// Slide everything to complete the transition
// If swiping right (direction='right'), slide everything right by viewport width
// If swiping left (direction='left'), slide everything left by viewport width
const finalOffset = direction === 'right' ? viewportWidth : -viewportWidth;
// Listen for transition end
const handleTransitionEnd = () => {
imgElement.removeEventListener('transitionend', handleTransitionEnd);
// Keep the preview visible by hiding the main image but showing the preview
// The preview is now centered, and we want it to stay visible while the new component loads
imgElement.style.opacity = '0';
// Show the preview that's now in the center
const activePreview = direction === 'right' ? leftPreviewContainer : rightPreviewContainer;
if (activePreview) {
activePreview.style.zIndex = '1'; // Bring to front
}
// Remove transitions
imgElement.style.transition = '';
if (leftPreviewContainer) {
leftPreviewContainer.style.transition = '';
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transition = '';
}
// Trigger navigation (dimensions were already passed in onPreCommit)
options?.onSwipeCommit?.(direction);
};
imgElement.addEventListener('transitionend', handleTransitionEnd, { once: true });
// Apply the final transform to trigger animation
imgElement.style.transform = `translate(${finalOffset}px, 0px)`;
if (leftPreviewContainer) {
leftPreviewContainer.style.transform = `translate(${finalOffset}px, 0px)`;
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transform = `translate(${finalOffset}px, 0px)`;
}
};
const pointerUp = (event: PointerEvent) => {
if (isDragging && imgElement) {
isDragging = false;
// Reset cursor
node.style.cursor = 'grab';
// Release pointer capture
if (node.hasPointerCapture(event.pointerId)) {
node.releasePointerCapture(event.pointerId);
}
// Remove document listeners
document.removeEventListener('pointerup', pointerUp);
document.removeEventListener('pointercancel', pointerUp);
// Get container width to calculate threshold
const containerWidth = Number.parseFloat(globalThis.getComputedStyle(node).width);
const threshold = containerWidth * (options?.commitThreshold ?? 0.25);
// Check if swipe exceeded threshold
let committed = false;
let commitDirection: 'left' | 'right' | null = null;
if (currentOffsetX > threshold) {
// Swiped right (show previous)
committed = true;
commitDirection = 'right';
} else if (currentOffsetX < -threshold) {
// Swiped left (show next)
committed = true;
commitDirection = 'left';
}
// Call onSwipeEnd callback
options?.onSwipeEnd?.(currentOffsetX);
// If committed, complete the transition animation
if (committed && commitDirection) {
completeTransition(commitDirection);
} else {
// If not committed, reset position with animation
resetPosition();
}
thresholdCrossed = false;
}
};
// Add event listeners
node.addEventListener('pointerdown', pointerDown);
node.addEventListener('pointermove', pointerMove);
node.addEventListener('pointerup', pointerUp);
node.addEventListener('pointercancel', pointerUp);
return {
update(newOptions?: SwipeFeedbackOptions) {
// Check if asset URL changed - if so, reset everything
if (newOptions?.currentAssetUrl && newOptions.currentAssetUrl !== lastAssetUrl) {
resetPreviewContainers();
lastAssetUrl = newOptions.currentAssetUrl;
}
options = newOptions;
// Update or create left preview
if (options?.leftPreviewUrl) {
if (leftPreviewImg) {
// Update existing
leftPreviewImg.src = options.leftPreviewUrl;
} else if (!leftPreviewContainer) {
// Create if doesn't exist
const preview = createPreviewContainer();
leftPreviewContainer = preview.container;
leftPreviewImg = preview.img;
leftPreviewImg.src = options.leftPreviewUrl;
}
}
// Update or create right preview
if (options?.rightPreviewUrl) {
if (rightPreviewImg) {
// Update existing
rightPreviewImg.src = options.rightPreviewUrl;
} else if (!rightPreviewContainer) {
// Create if doesn't exist
const preview = createPreviewContainer();
rightPreviewContainer = preview.container;
rightPreviewImg = preview.img;
rightPreviewImg.src = options.rightPreviewUrl;
}
}
},
destroy() {
node.removeEventListener('pointerdown', pointerDown);
node.removeEventListener('pointermove', pointerMove);
node.removeEventListener('pointerup', pointerUp);
node.removeEventListener('pointercancel', pointerUp);
// Clean up document listeners in case they weren't removed
document.removeEventListener('pointerup', pointerUp);
document.removeEventListener('pointercancel', pointerUp);
// Clean up preview elements
leftPreviewContainer?.remove();
rightPreviewContainer?.remove();
// Reset cursor
node.style.cursor = '';
},
};
};

View File

@@ -14,6 +14,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
setZoomImageState(state);
}
node.style.overflow = 'visible';
// Store original event handlers so we can prevent them when disabled
const wheelHandler = (event: WheelEvent) => {
if (options?.disabled) {
@@ -21,15 +23,15 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
}
};
const pointerDownHandler = (event: PointerEvent) => {
const disabledPointerDownHandler = (event: PointerEvent) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
// Add handlers at capture phase with higher priority
// Add handlers at capture phase with higher priority for disabled state
node.addEventListener('wheel', wheelHandler, { capture: true });
node.addEventListener('pointerdown', pointerDownHandler, { capture: true });
node.addEventListener('pointerdown', disabledPointerDownHandler, { capture: true });
const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)];
@@ -39,7 +41,7 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
},
destroy() {
node.removeEventListener('wheel', wheelHandler, { capture: true });
node.removeEventListener('pointerdown', pointerDownHandler, { capture: true });
node.removeEventListener('pointerdown', disabledPointerDownHandler, { capture: true });
for (const unsubscribe of unsubscribes) {
unsubscribe();
}

View File

@@ -159,10 +159,14 @@
}
};
let transitionName = $state<string | null>('hero');
let equirectangularTransitionName = $state<string | null>('hero');
let transitionName = $state<string | null>(null);
let equirectangularTransitionName = $state<string | null>();
let detailPanelTransitionName = $state<string | null>(null);
if (viewTransitionManager.activeViewTransition) {
transitionName = 'hero';
equirectangularTransitionName = 'hero';
}
let addInfoTransition;
let finished;
onMount(async () => {
@@ -277,7 +281,7 @@
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
const navigateAsset = (order?: 'previous' | 'next', skipTransition?: boolean = false) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
@@ -286,7 +290,6 @@
}
}
e?.stopPropagation();
if (tracker.isActive()) {
return;
}
@@ -295,7 +298,9 @@
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
startTransition(null, undefined);
if (!skipTransition) {
startTransition(null, undefined);
}
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
@@ -307,7 +312,7 @@
} else if (onNavigateToAsset) {
// only transition if the target is already preloaded, and is in a secure context
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
if (!!targetAsset && globalThis.isSecureContext && preloadManager.isPreloaded(targetAsset)) {
if (!skipTransition && !!targetAsset && globalThis.isSecureContext && preloadManager.isPreloaded(targetAsset)) {
const targetTransition = $slideshowState === SlideshowState.PlaySlideshow ? null : order;
startTransition(targetTransition, targetAsset);
}
@@ -468,7 +473,7 @@
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') {
if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer' && viewerKind !== 'VideoViewer') {
eventManager.emit('AssetViewerFree');
}
});
@@ -570,9 +575,10 @@
bind:copyImage
{transitionName}
asset={previewStackedAsset!}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{nextAsset}
{previousAsset}
onPreviousAsset={() => navigateAsset('previous', true)}
onNextAsset={() => navigateAsset('next', true)}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
@@ -611,10 +617,11 @@
bind:zoomToggle
bind:copyImage
{asset}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{nextAsset}
{previousAsset}
onPreviousAsset={() => navigateAsset('previous', true)}
onNextAsset={() => navigateAsset('next', true)}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
onFree={() => eventManager.emit('AssetViewerFree')}
/>
{:else if viewerKind === 'VideoViewer'}

View File

@@ -1,5 +1,6 @@
<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';
@@ -22,14 +23,15 @@
import { AssetMediaSize, type AssetResponseDto, 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 { type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
interface Props {
transitionName?: string | null;
asset: AssetResponseDto;
previousAsset?: AssetResponseDto;
nextAsset?: AssetResponseDto;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
onPreviousAsset?: (() => void) | null;
onNextAsset?: (() => void) | null;
@@ -44,8 +46,9 @@
let {
transitionName,
asset,
previousAsset,
nextAsset,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
@@ -58,7 +61,7 @@
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
haveFadeTransition = true;
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
@@ -72,13 +75,6 @@
});
const calculateSize = () => {
// Recalculate size when image is loaded/errored
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
imageLoaded || imageError;
const naturalWidth = loader?.naturalWidth ?? 1;
const naturalHeight = loader?.naturalHeight ?? 1;
const scaleX = containerWidth / naturalWidth;
const scaleY = containerHeight / naturalHeight;
@@ -88,12 +84,14 @@
const scaledWidth = naturalWidth * scale;
const scaledHeight = naturalHeight * scale;
return {
const result = {
width: scaledWidth,
height: scaledHeight,
left: (containerWidth - scaledWidth) / 2,
top: (containerHeight - scaledHeight) / 2,
};
return result;
};
const box = $derived(calculateSize());
@@ -106,6 +104,34 @@
let isOcrActive = $derived(ocrManager.showOverlay);
// Swipe preview state
let swipeOffsetX = $state(0);
const handleSwipeMove = (offsetX: number) => {
swipeOffsetX = offsetX;
};
const handleSwipeEnd = () => {
swipeOffsetX = 0;
};
const handlePreCommit = (direction: 'left' | 'right', previewNaturalWidth: number, previewNaturalHeight: number) => {
// Set the natural dimensions from the preview image BEFORE navigation starts
// This ensures calculateSize has the correct dimensions immediately when the component reloads
naturalWidth = previewNaturalWidth;
naturalHeight = previewNaturalHeight;
};
const handleSwipeCommit = (direction: 'left' | 'right') => {
if (direction === 'left' && onNextAsset) {
// Swiped left, go to next asset
onNextAsset();
} else if (direction === 'right' && onPreviousAsset) {
// Swiped right, go to previous asset
onPreviousAsset();
}
};
copyImage = async () => {
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
return;
@@ -186,12 +212,17 @@
onLoad?.();
onFree?.();
imageLoaded = true;
loadingCachedPreview = false;
naturalWidth = loader?.naturalWidth ?? 1;
naturalHeight = loader?.naturalHeight ?? 1;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
onError?.();
onFree?.();
naturalWidth = loader?.naturalWidth ?? 1;
naturalHeight = loader?.naturalHeight ?? 1;
imageError = imageLoaded = true;
};
@@ -200,18 +231,26 @@
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: previousAsset, sharedLink }));
const nextAssetUrl = $derived(getAssetUrl({ asset: nextAsset, sharedLink }));
let containerWidth = $state(0);
let containerHeight = $state(0);
let naturalWidth = $state(1);
let naturalHeight = $state(1);
let lastUrl: string | undefined;
let lastUrl: string | undefined | null;
let lastPreviousUrl: string | undefined | null;
let lastNextUrl: string | undefined | null;
$effect(() => {
if (!lastUrl) {
@@ -219,13 +258,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>
@@ -265,11 +312,21 @@
{/if}
<div
use:zoomImageAction={{ disabled: isOcrActive }}
{...useSwipe(onSwipe)}
use:swipeFeedback={{
disabled: isOcrActive || $photoZoomState.currentZoom > 1,
onSwipeMove: handleSwipeMove,
onSwipeEnd: handleSwipeEnd,
onPreCommit: handlePreCommit,
onSwipeCommit: handleSwipeCommit,
leftPreviewUrl: previousAssetUrl,
rightPreviewUrl: nextAssetUrl,
currentAssetUrl: imageLoaderUrl,
}}
style:width={box.width + 'px'}
style:height={box.height + 'px'}
style:left={box.left + 'px'}
style:top={box.top + 'px'}
style:overflow="visible"
class="absolute"
>
<img

View File

@@ -3,6 +3,7 @@
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
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,
@@ -69,6 +70,11 @@
}
});
const handleLoadedMetadata = () => {
box = calculateSize();
eventManager.emit('AssetViewerFree');
};
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
@@ -165,7 +171,7 @@
controls
disablePictureInPicture
{...useSwipe(onSwipe)}
onloadedmetadata={() => (box = calculateSize())}
onloadedmetadata={() => handleLoadedMetadata()}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}

View File

@@ -724,21 +724,22 @@
// tag target on the 'old' snapshot
toAssetViewerTransitionId = asset.id;
viewTransitionManager.startTransition(
new Promise((resolve) =>
eventManager.once('AssetViewerFree', () => {
eventManager.emit('TransitionToAssetViewer');
resolve();
}),
),
);
eventManager.once('StartViewTransition', () => {
// remove target on the 'old' view,
// asset-viewer will tag new target element for 'new' snapshot
toAssetViewerTransitionId = null;
});
viewTransitionManager.startTransition(
new Promise((resolve) =>
eventManager.once('AssetViewerFree', () => {
toAssetViewerTransitionId = null;
eventManager.emit('TransitionToAssetViewer');
resolve();
}),
),
);
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {

View File

@@ -1,6 +1,12 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
class ViewTransitionManager {
#activeViewTransition = $state<ViewTransition | null>(null);
get activeViewTransition() {
return this.#activeViewTransition;
}
startTransition(domUpdateComplete: Promise<void>, finishedCallback?: () => void) {
// good time to add view-transition-name styles (if needed)
eventManager.emit('BeforeStartViewTransition');
@@ -16,6 +22,7 @@ class ViewTransitionManager {
console.log('exception', error);
}
});
this.#activeViewTransition = transition;
// UpdateCallbackDone is a good time to add any view-transition-name styles
// to the new DOM state, before the 'new' view snapshot is creatd
// eslint-disable-next-line tscompat/tscompat
@@ -34,7 +41,10 @@ class ViewTransitionManager {
.then(() => eventManager.emit('Finished'))
.catch((error: unknown) => console.log('exception in finished', error));
// eslint-disable-next-line tscompat/tscompat
void transition.finished.then(() => finishedCallback?.());
void transition.finished.then(() => {
finishedCallback?.();
this.#activeViewTransition = null;
});
}
}

View File

@@ -199,10 +199,13 @@ export const getAssetUrl = ({
sharedLink,
forceOriginal = false,
}: {
asset: AssetResponseDto;
asset: AssetResponseDto | undefined | null;
sharedLink?: SharedLinkResponseDto;
forceOriginal?: boolean;
}) => {
if (!asset) {
return null;
}
const id = asset.id;
const cacheKey = asset.thumbhash;
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {