diff --git a/web/src/lib/actions/swipe-feedback.ts b/web/src/lib/actions/swipe-feedback.ts new file mode 100644 index 0000000000..c5db1fe219 --- /dev/null +++ b/web/src/lib/actions/swipe-feedback.ts @@ -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 = ''; + }, + }; +}; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index e67d3e1928..7e173f562d 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -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(); } diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 08cc50e96b..88fcd92a09 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -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('hero'); - let equirectangularTransitionName = $state('hero'); + let transitionName = $state(null); + let equirectangularTransitionName = $state(); let detailPanelTransitionName = $state(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'} navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} + onPreviousAsset={() => navigateAsset('previous', true)} + onNextAsset={() => navigateAsset('next', true)} onClose={closeViewer} onVideoEnded={() => navigateAsset()} onVideoStarted={handleVideoStarted} @@ -593,11 +615,15 @@ 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'} navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} + onPreviousAsset={() => navigateAsset('previous', true)} + onNextAsset={() => navigateAsset('next', true)} onClose={closeViewer} onVideoEnded={() => navigateAsset()} onVideoStarted={handleVideoStarted} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index c0097d6904..b96b896a0a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,5 +1,6 @@ @@ -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}
@@ -265,11 +300,11 @@ {/if}
+ 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 {}; + }; {#if showVideo}
{#if castManager.isCasting}
@@ -154,43 +206,44 @@ />
{:else} - +
+ - {#if isLoading} -
- -
- {/if} + {#if isLoading} +
+ +
+ {/if} - {#if isFaceEditMode.value} - - {/if} + {#if isFaceEditMode.value} + + {/if} +
{/if}
{/if} diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index 142016b77f..5ccff5a7a5 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -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} diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index dd3b654bd4..bc16c6c295 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -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 { diff --git a/web/src/lib/managers/ViewTransitionManager.svelte.ts b/web/src/lib/managers/ViewTransitionManager.svelte.ts index dd50cc33a9..62fecf19ff 100644 --- a/web/src/lib/managers/ViewTransitionManager.svelte.ts +++ b/web/src/lib/managers/ViewTransitionManager.svelte.ts @@ -1,6 +1,12 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; class ViewTransitionManager { + #activeViewTransition = $state(null); + + get activeViewTransition() { + return this.#activeViewTransition; + } + startTransition(domUpdateComplete: Promise, 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; + }); } } diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 285a8de491..29cc3d24d9 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -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)) { diff --git a/web/src/lib/utils/layout-utils.ts b/web/src/lib/utils/layout-utils.ts index 16adb79f67..162073b6b6 100644 --- a/web/src/lib/utils/layout-utils.ts +++ b/web/src/lib/utils/layout-utils.ts @@ -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, + }; +}; diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 13b5275a37..7ac834ba17 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -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 }); }