swipeFeedback
parent
01ceb3eb31
commit
423d98cf39
|
|
@ -0,0 +1,433 @@
|
|||
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;
|
||||
/** Minimum number of pixels before activating swipe. (Default = 45) */
|
||||
swipeThreshold?: number;
|
||||
/** Current asset URL - when this changes, preview containers are reset */
|
||||
currentAssetUrl?: string | null;
|
||||
/** The img or video element to transform. If not provided, will query for img/video inside the node */
|
||||
imageElement?: HTMLImageElement | HTMLVideoElement | 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
|
||||
let imgElement: HTMLImageElement | HTMLVideoElement | null =
|
||||
options?.imageElement ?? node.querySelector('img') ?? node.querySelector('video');
|
||||
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let currentOffsetX = 0;
|
||||
|
||||
let lastAssetUrl = options?.currentAssetUrl;
|
||||
let dragStartTime: Date | null = null;
|
||||
let swipeAmount = 0;
|
||||
|
||||
// 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
|
||||
if (imgElement) {
|
||||
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 || !imgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle single pointer (mouse or single touch)
|
||||
if (event.isPrimary) {
|
||||
isDragging = true;
|
||||
startX = event.clientX;
|
||||
|
||||
// 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);
|
||||
dragStartTime = new Date();
|
||||
|
||||
// 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 || !imgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
currentOffsetX = event.clientX - startX;
|
||||
|
||||
const xDelta = event.clientX - startX;
|
||||
swipeAmount = xDelta;
|
||||
|
||||
// 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 = () => {
|
||||
if (!imgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
if (imgElement) {
|
||||
imgElement.style.transition = '';
|
||||
}
|
||||
if (leftPreviewContainer) {
|
||||
leftPreviewContainer.style.transition = '';
|
||||
}
|
||||
if (rightPreviewContainer) {
|
||||
rightPreviewContainer.style.transition = '';
|
||||
}
|
||||
}, 300);
|
||||
|
||||
currentOffsetX = 0;
|
||||
updatePreviewVisibility();
|
||||
};
|
||||
|
||||
const completeTransition = (direction: 'left' | 'right') => {
|
||||
if (!imgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the active preview image and its dimensions
|
||||
const activePreviewImg = direction === 'right' ? leftPreviewImg : rightPreviewImg;
|
||||
const naturalWidth = activePreviewImg?.naturalWidth ?? 1;
|
||||
const naturalHeight = activePreviewImg?.naturalHeight ?? 1;
|
||||
console.log('nat', naturalWidth, naturalHeight);
|
||||
|
||||
// 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 = () => {
|
||||
if (!imgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!imgElement) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
|
||||
const threshold = options?.swipeThreshold ?? 45;
|
||||
|
||||
const timeTaken = Date.now() - (dragStartTime?.getTime() ?? 0);
|
||||
|
||||
const velocity = Math.abs(swipeAmount) / timeTaken;
|
||||
console.log('velocity', velocity, swipeAmount);
|
||||
if (Math.abs(swipeAmount) < threshold || velocity < 0.11) {
|
||||
resetPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if swipe exceeded threshold
|
||||
|
||||
const commitDirection = currentOffsetX > 0 ? 'right' : 'left';
|
||||
|
||||
// Call onSwipeEnd callback
|
||||
options?.onSwipeEnd?.(currentOffsetX);
|
||||
|
||||
// complete the transition animation
|
||||
completeTransition(commitDirection);
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
node.addEventListener('pointerdown', pointerDown);
|
||||
node.addEventListener('pointermove', pointerMove);
|
||||
node.addEventListener('pointerup', pointerUp);
|
||||
node.addEventListener('pointercancel', pointerUp);
|
||||
|
||||
return {
|
||||
update(newOptions?: SwipeFeedbackOptions) {
|
||||
// Update imgElement if provided
|
||||
if (newOptions?.imageElement !== undefined) {
|
||||
imgElement = newOptions.imageElement;
|
||||
}
|
||||
|
||||
// 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 = '';
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@
|
|||
let zoomToggle = $state(() => void 0);
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
|
||||
let nextSizeHint = $state<{ width: number; height: number } | null>(null);
|
||||
|
||||
const setPlayOriginalVideo = (value: boolean) => {
|
||||
playOriginalVideo = value;
|
||||
};
|
||||
|
|
@ -159,10 +161,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 +283,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 +292,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
e?.stopPropagation();
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -295,7 +300,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 +314,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);
|
||||
}
|
||||
|
|
@ -427,6 +434,15 @@
|
|||
await goto(`${AppRoute.PHOTOS}/${newAssetId}`);
|
||||
};
|
||||
|
||||
const handleAboutToNavigate = (target: { direction: 'left' | 'right'; nextWidth: number; nextHeight: number }) => {
|
||||
debugger;
|
||||
nextSizeHint = {
|
||||
width: target.nextWidth,
|
||||
height: target.nextHeight,
|
||||
};
|
||||
console.log('setting', nextSizeHint);
|
||||
};
|
||||
|
||||
let isFullScreen = $derived(fullscreenElement !== null);
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -468,7 +484,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,20 +586,26 @@
|
|||
bind:copyImage
|
||||
{transitionName}
|
||||
asset={previewStackedAsset!}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
haveFadeTransition={false}
|
||||
{nextAsset}
|
||||
{previousAsset}
|
||||
{nextSizeHint}
|
||||
onAboutToNavigate={handleAboutToNavigate}
|
||||
onPreviousAsset={() => navigateAsset('previous', true)}
|
||||
onNextAsset={() => navigateAsset('next', true)}
|
||||
{sharedLink}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
assetId={previewStackedAsset!.id}
|
||||
{nextAsset}
|
||||
{previousAsset}
|
||||
{nextSizeHint}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||
loopVideo={true}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onPreviousAsset={() => navigateAsset('previous', true)}
|
||||
onNextAsset={() => navigateAsset('next', true)}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
|
|
@ -593,11 +615,15 @@
|
|||
<VideoViewer
|
||||
{transitionName}
|
||||
assetId={asset.livePhotoVideoId!}
|
||||
{nextAsset}
|
||||
{previousAsset}
|
||||
{sharedLink}
|
||||
{nextSizeHint}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onPreviousAsset={() => navigateAsset('previous', true)}
|
||||
onNextAsset={() => navigateAsset('next', true)}
|
||||
onVideoEnded={() => (shouldPlayMotionPhoto = false)}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
|
|
@ -611,21 +637,28 @@
|
|||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
{asset}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
{nextAsset}
|
||||
{previousAsset}
|
||||
{nextSizeHint}
|
||||
onAboutToNavigate={handleAboutToNavigate}
|
||||
onPreviousAsset={() => navigateAsset('previous', true)}
|
||||
onNextAsset={() => navigateAsset('next', true)}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||
onFree={() => eventManager.emit('AssetViewerFree')}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
assetId={asset.id}
|
||||
{nextAsset}
|
||||
{previousAsset}
|
||||
{sharedLink}
|
||||
{nextSizeHint}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onPreviousAsset={() => navigateAsset('previous', true)}
|
||||
onNextAsset={() => navigateAsset('next', true)}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } 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';
|
||||
|
|
@ -22,15 +24,25 @@
|
|||
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 { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null;
|
||||
asset: AssetResponseDto;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
previousAsset?: AssetResponseDto;
|
||||
nextAsset?: AssetResponseDto;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
nextSizeHint?: { width: number; height: number } | null;
|
||||
onAboutToNavigate?: ({
|
||||
direction,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
}: {
|
||||
direction: 'left' | 'right';
|
||||
nextWidth: number;
|
||||
nextHeight: number;
|
||||
}) => void;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
onLoad?: (() => void) | null;
|
||||
|
|
@ -44,9 +56,12 @@
|
|||
let {
|
||||
transitionName,
|
||||
asset,
|
||||
previousAsset,
|
||||
nextAsset,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
sharedLink,
|
||||
nextSizeHint,
|
||||
onAboutToNavigate,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
onLoad,
|
||||
|
|
@ -58,7 +73,7 @@
|
|||
}: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
haveFadeTransition = true;
|
||||
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
|
|
@ -71,32 +86,15 @@
|
|||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// Use the smaller scale to ensure image fits (like object-fit: contain)
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
const scaledWidth = naturalWidth * scale;
|
||||
const scaledHeight = naturalHeight * scale;
|
||||
|
||||
const box = $derived.by(() => {
|
||||
const { width, height } = scaleToFit(naturalWidth, naturalHeight, containerWidth, containerHeight);
|
||||
return {
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
left: (containerWidth - scaledWidth) / 2,
|
||||
top: (containerHeight - scaledHeight) / 2,
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
left: (containerWidth - width) / 2 + 'px',
|
||||
top: (containerHeight - height) / 2 + 'px',
|
||||
};
|
||||
};
|
||||
|
||||
const box = $derived(calculateSize());
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(
|
||||
ocrManager.showOverlay && $photoViewerImgElement
|
||||
|
|
@ -106,6 +104,30 @@
|
|||
|
||||
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||
|
||||
const handlePreCommit = (direction: 'left' | 'right', nextWidth: number, nextHeight: number) => {
|
||||
// Scale the preview dimensions to fit within the viewport (like object-fit: contain)
|
||||
// This prevents flashing when small images are scaled up by scaleToFit
|
||||
const { width: scaledWidth, height: scaledHeight } = scaleToFit(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
);
|
||||
console.log('nextSize', nextWidth, nextHeight, scaledWidth, scaledHeight);
|
||||
// onAboutToNavigate?.({ direction, nextWidth: scaledWidth, nextHeight: scaledHeight });
|
||||
onAboutToNavigate?.({ direction, nextWidth, nextHeight });
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
@ -142,24 +164,6 @@
|
|||
handlePromiseError(copyImage());
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if ($photoZoomState.currentZoom > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onNextAsset && event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
|
||||
if (onPreviousAsset && event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
};
|
||||
|
||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -186,12 +190,16 @@
|
|||
onLoad?.();
|
||||
onFree?.();
|
||||
imageLoaded = true;
|
||||
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 +208,28 @@
|
|||
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 = $derived(nextSizeHint?.width ?? 1);
|
||||
let naturalHeight = $derived(nextSizeHint?.height ?? 1);
|
||||
|
||||
let lastUrl: string | undefined;
|
||||
$inspect(naturalWidth).with(console.log.bind(null, 'natW'));
|
||||
$inspect(naturalHeight).with(console.log.bind(null, 'natH'));
|
||||
let lastUrl: string | undefined | null;
|
||||
let lastPreviousUrl: string | undefined | null;
|
||||
let lastNextUrl: string | undefined | null;
|
||||
|
||||
$effect(() => {
|
||||
if (!lastUrl) {
|
||||
|
|
@ -219,13 +237,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>
|
||||
|
||||
|
|
@ -249,6 +275,15 @@
|
|||
class="absolute h-full w-full select-none"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
use:swipeFeedback={{
|
||||
disabled: isOcrActive || $photoZoomState.currentZoom > 1,
|
||||
onPreCommit: handlePreCommit,
|
||||
onSwipeCommit: handleSwipeCommit,
|
||||
leftPreviewUrl: previousAssetUrl,
|
||||
rightPreviewUrl: nextAssetUrl,
|
||||
currentAssetUrl: imageLoaderUrl,
|
||||
imageElement: $photoViewerImgElement,
|
||||
}}
|
||||
>
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
|
|
@ -265,11 +300,11 @@
|
|||
{/if}
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
{...useSwipe(onSwipe)}
|
||||
style:width={box.width + 'px'}
|
||||
style:height={box.height + 'px'}
|
||||
style:left={box.left + 'px'}
|
||||
style:top={box.top + 'px'}
|
||||
style:width={box.width}
|
||||
style:height={box.height}
|
||||
style:left={box.left}
|
||||
style:top={box.top}
|
||||
style:overflow="visible"
|
||||
class="absolute"
|
||||
>
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<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 { 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,19 +12,32 @@
|
|||
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 { 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 {
|
||||
transitionName?: string | null;
|
||||
assetId: string;
|
||||
previousAsset?: AssetResponseDto;
|
||||
nextAsset?: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
nextSizeHint?: { width: number; height: number } | null;
|
||||
loopVideo: boolean;
|
||||
cacheKey: string | null;
|
||||
playOriginalVideo: boolean;
|
||||
onAboutToNavigate?: ({
|
||||
direction,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
}: {
|
||||
direction: 'left' | 'right';
|
||||
nextWidth: number;
|
||||
nextHeight: number;
|
||||
}) => void;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onVideoEnded?: () => void;
|
||||
|
|
@ -33,9 +48,14 @@
|
|||
let {
|
||||
transitionName,
|
||||
assetId,
|
||||
previousAsset,
|
||||
nextAsset,
|
||||
nextSizeHint,
|
||||
sharedLink,
|
||||
loopVideo,
|
||||
cacheKey,
|
||||
playOriginalVideo,
|
||||
onAboutToNavigate,
|
||||
onPreviousAsset = () => {},
|
||||
onNextAsset = () => {},
|
||||
onVideoEnded = () => {},
|
||||
|
|
@ -51,6 +71,12 @@
|
|||
let isScrubbing = $state(false);
|
||||
let showVideo = $state(false);
|
||||
|
||||
let containerWidth = $state(document.documentElement.clientWidth);
|
||||
let containerHeight = $state(document.documentElement.clientHeight);
|
||||
let videoHeight = $derived(nextSizeHint?.height ?? 1);
|
||||
let videoWidth = $derived(nextSizeHint?.width ?? 1);
|
||||
$inspect(videoWidth).with(console.log.bind(null, 'vwidth'));
|
||||
console.log('next', nextSizeHint);
|
||||
onMount(() => {
|
||||
// Show video after mount to ensure fading in.
|
||||
showVideo = true;
|
||||
|
|
@ -69,6 +95,13 @@
|
|||
}
|
||||
});
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
console.log('loaded', videoPlayer?.videoWidth);
|
||||
videoWidth = videoPlayer?.videoWidth ?? 1;
|
||||
videoHeight = videoPlayer?.videoHeight ?? 1;
|
||||
eventManager.emit('AssetViewerFree');
|
||||
};
|
||||
|
||||
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||
try {
|
||||
if (!video.paused && !isScrubbing) {
|
||||
|
|
@ -100,17 +133,24 @@
|
|||
}
|
||||
};
|
||||
|
||||
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);
|
||||
const handlePreCommit = (direction: 'left' | 'right', nextWidth: number, nextHeight: number) => {
|
||||
const { width: scaledWidth, height: scaledHeight } = scaleToFit(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
);
|
||||
|
||||
onAboutToNavigate?.({ direction, nextWidth: scaledWidth, nextHeight: scaledHeight });
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value) {
|
||||
|
|
@ -119,30 +159,42 @@
|
|||
});
|
||||
|
||||
const calculateSize = () => {
|
||||
const videoWidth = videoPlayer?.videoWidth ?? 1;
|
||||
const videoHeight = videoPlayer?.videoHeight ?? 1;
|
||||
const { width, height } = scaleToFit(videoWidth, videoHeight, containerWidth, containerHeight);
|
||||
|
||||
const scaleX = containerWidth / videoWidth;
|
||||
const scaleY = containerHeight / videoHeight;
|
||||
|
||||
// Use the smaller scale to ensure image fits (like object-fit: contain)
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
return {
|
||||
width: videoWidth * scale + 'px',
|
||||
height: videoHeight * scale + 'px',
|
||||
const size = {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
};
|
||||
|
||||
return size;
|
||||
};
|
||||
|
||||
let box = $derived(calculateSize());
|
||||
const box = $derived(calculateSize());
|
||||
|
||||
const previousAssetUrl = $derived(getAssetUrl({ asset: previousAsset, sharedLink }));
|
||||
const nextAssetUrl = $derived(getAssetUrl({ asset: nextAsset, sharedLink }));
|
||||
const transitionFn = (node: Element) => {
|
||||
if (nextSizeHint === null) {
|
||||
return fade(node, { duration: assetViewerFadeDuration });
|
||||
}
|
||||
return {};
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if showVideo}
|
||||
<div
|
||||
transition:fade={{ duration: assetViewerFadeDuration }}
|
||||
transition:transitionFn
|
||||
class="flex select-none h-full w-full place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
use:swipeFeedback={{
|
||||
onPreCommit: handlePreCommit,
|
||||
onSwipeCommit: handleSwipeCommit,
|
||||
leftPreviewUrl: previousAssetUrl,
|
||||
rightPreviewUrl: nextAssetUrl,
|
||||
currentAssetUrl: assetFileUrl,
|
||||
imageElement: videoPlayer,
|
||||
}}
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
|
|
@ -154,43 +206,44 @@
|
|||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<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
|
||||
{...useSwipe(onSwipe)}
|
||||
onloadedmetadata={() => (box = calculateSize())}
|
||||
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: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 />
|
||||
</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}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,28 @@
|
|||
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 {
|
||||
transitionName?: string | null;
|
||||
assetId: string;
|
||||
previousAsset?: AssetResponseDto;
|
||||
nextAsset?: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
nextSizeHint?: { width: number; height: number } | null;
|
||||
projectionType: string | null | undefined;
|
||||
cacheKey: string | null;
|
||||
loopVideo: boolean;
|
||||
playOriginalVideo: boolean;
|
||||
onAboutToNavigate?: ({
|
||||
direction,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
}: {
|
||||
direction: 'left' | 'right';
|
||||
nextWidth: number;
|
||||
nextHeight: number;
|
||||
}) => void;
|
||||
onClose?: () => void;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
|
|
@ -20,10 +34,15 @@
|
|||
let {
|
||||
transitionName,
|
||||
assetId,
|
||||
previousAsset,
|
||||
nextAsset,
|
||||
sharedLink,
|
||||
nextSizeHint,
|
||||
projectionType,
|
||||
cacheKey,
|
||||
loopVideo,
|
||||
playOriginalVideo,
|
||||
onAboutToNavigate,
|
||||
onPreviousAsset,
|
||||
onClose,
|
||||
onNextAsset,
|
||||
|
|
@ -40,7 +59,12 @@
|
|||
{loopVideo}
|
||||
{cacheKey}
|
||||
{assetId}
|
||||
{nextAsset}
|
||||
{sharedLink}
|
||||
{nextSizeHint}
|
||||
{previousAsset}
|
||||
{playOriginalVideo}
|
||||
{onAboutToNavigate}
|
||||
{onPreviousAsset}
|
||||
{onNextAsset}
|
||||
{onVideoEnded}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -130,3 +130,16 @@ export type CommonPosition = {
|
|||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// Scales dimensions to fit within a container (like object-fit: contain)
|
||||
export const scaleToFit = (width: number, height: number, containerW: number, containerH: number) => {
|
||||
const scaleX = containerW / width;
|
||||
const scaleY = containerH / height;
|
||||
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
return {
|
||||
width: width * scale,
|
||||
height: height * scale,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,11 +9,17 @@ broadcast.addEventListener('message', (event) => {
|
|||
}
|
||||
});
|
||||
|
||||
export function cancelImageUrl(url: string) {
|
||||
export function cancelImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'cancel', url });
|
||||
}
|
||||
|
||||
export function preloadImageUrl(url: string) {
|
||||
export function preloadImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'preload', url });
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue