diff --git a/web/src/app.css b/web/src/app.css index dc2d3bf3c3..e07dd66d47 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -74,6 +74,20 @@ --immich-dark-bg: 10 10 10; --immich-dark-fg: 229 231 235; --immich-dark-gray: 33 33 33; + + /* transitions */ + --immich-split-viewer-nav: enabled; + + /* view transition variables */ + --vt-duration-default: 250ms; + --vt-duration-hero: 350ms; + --vt-duration-slideshow: 1s; + --vt-viewer-slide-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94); + --vt-viewer-slide-distance: 15%; + --vt-viewer-opacity-start: 0.1; + --vt-viewer-opacity-mid: 0.4; + --vt-viewer-blur-max: 4px; + --vt-viewer-blur-mid: 2px; } button:not(:disabled), @@ -171,3 +185,318 @@ @apply bg-subtle rounded-lg; } } + +@layer base { + ::view-transition { + background: var(--color-black); + animation-duration: var(--vt-duration-default); + } + + ::view-transition-old(*), + ::view-transition-new(*) { + mix-blend-mode: normal; + animation-duration: inherit; + } + + ::view-transition-old(*) { + animation-name: fadeOut; + animation-fill-mode: forwards; + } + ::view-transition-new(*) { + animation-name: fadeIn; + animation-fill-mode: forwards; + } + + ::view-transition-old(root) { + animation: var(--vt-duration-default) 0s fadeOut forwards; + } + ::view-transition-new(root) { + animation: var(--vt-duration-default) 0s fadeIn forwards; + } + html:active-view-transition-type(slideshow) { + &::view-transition-old(root) { + animation: var(--vt-duration-slideshow) 0s fadeOut forwards; + } + &::view-transition-new(root) { + animation: var(--vt-duration-slideshow) 0s fadeIn forwards; + } + } + html:active-view-transition-type(viewer-nav) { + &::view-transition-old(root) { + animation: var(--vt-duration-hero) 0s fadeOut forwards; + } + &::view-transition-new(root) { + animation: var(--vt-duration-hero) 0s fadeIn forwards; + } + } + ::view-transition-old(info) { + animation: var(--vt-duration-default) 0s flyOutRight forwards; + } + ::view-transition-new(info) { + animation: var(--vt-duration-default) 0s flyInRight forwards; + } + + ::view-transition-group(detail-panel) { + z-index: 1; + } + ::view-transition-old(detail-panel), + ::view-transition-new(detail-panel) { + animation: none; + } + ::view-transition-group(letterbox-left), + ::view-transition-group(letterbox-right), + ::view-transition-group(letterbox-top), + ::view-transition-group(letterbox-bottom) { + z-index: 4; + } + + ::view-transition-old(letterbox-left), + ::view-transition-old(letterbox-right), + ::view-transition-old(letterbox-top), + ::view-transition-old(letterbox-bottom) { + background-color: var(--color-black); + } + + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right) { + height: 100dvh; + } + + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right), + ::view-transition-new(letterbox-top), + ::view-transition-new(letterbox-bottom) { + background-color: var(--color-black); + opacity: 1 !important; + } + + ::view-transition-group(exclude-leftbutton), + ::view-transition-group(exclude-rightbutton), + ::view-transition-group(exclude) { + animation: none; + z-index: 5; + } + ::view-transition-old(exclude-leftbutton), + ::view-transition-old(exclude-rightbutton), + ::view-transition-old(exclude) { + visibility: hidden; + } + ::view-transition-new(exclude-leftbutton), + ::view-transition-new(exclude-rightbutton), + ::view-transition-new(exclude) { + animation: none; + z-index: 5; + } + + ::view-transition-old(hero) { + animation: var(--vt-duration-hero) fadeOut forwards; + align-content: center; + } + ::view-transition-new(hero) { + animation: var(--vt-duration-hero) fadeIn forwards; + align-content: center; + } + ::view-transition-old(next), + ::view-transition-old(next-old) { + animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutLeft forwards; + overflow: hidden; + } + + ::view-transition-new(next), + ::view-transition-new(next-new) { + animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInRight forwards; + overflow: hidden; + } + + ::view-transition-old(previous) { + animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutRight forwards; + } + ::view-transition-old(previous-old) { + animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutRight forwards; + overflow: hidden; + z-index: -1; + } + + ::view-transition-new(previous) { + animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInLeft forwards; + } + + ::view-transition-new(previous-new) { + animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInLeft forwards; + overflow: hidden; + } + + @keyframes flyInLeft { + from { + /* object-position: -25dvw; */ + transform: translateX(calc(-1 * var(--vt-viewer-slide-distance))); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + 50% { + opacity: var(--vt-viewer-opacity-mid); + filter: blur(var(--vt-viewer-blur-mid)); + } + to { + opacity: 1; + filter: blur(0); + } + } + + @keyframes flyOutLeft { + from { + opacity: 1; + filter: blur(0); + } + 50% { + opacity: var(--vt-viewer-opacity-mid); + filter: blur(var(--vt-viewer-blur-mid)); + } + to { + /* object-position: -25dvw; */ + transform: translateX(calc(-1 * var(--vt-viewer-slide-distance))); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + } + + @keyframes flyInRight { + from { + /* object-position: 25dvw; */ + transform: translateX(var(--vt-viewer-slide-distance)); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + 50% { + opacity: var(--vt-viewer-opacity-mid); + filter: blur(var(--vt-viewer-blur-mid)); + } + to { + opacity: 1; + filter: blur(0); + } + } + + /* Fly out to right */ + @keyframes flyOutRight { + from { + opacity: 1; + filter: blur(0); + } + 50% { + opacity: var(--vt-viewer-opacity-mid); + filter: blur(var(--vt-viewer-blur-mid)); + } + to { + /* object-position: 50dvw 0px; */ + transform: translateX(var(--vt-viewer-slide-distance)); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } + } + + /* Reduced motion: when system preference is set */ + @media (prefers-reduced-motion: reduce) { + ::view-transition-group(hero) { + animation-name: none; + } + + ::view-transition-old(hero) { + animation: none; + display: none; + } + + ::view-transition-new(hero) { + animation: none; + } + + html:active-view-transition-type(viewer) { + &::view-transition-old(hero) { + animation: none; + display: none; + } + &::view-transition-new(hero) { + animation: var(--vt-duration-default) 0s fadeIn forwards; + } + } + + html:active-view-transition-type(timeline) { + &::view-transition-old(hero) { + animation: var(--vt-duration-default) 0s fadeOut forwards; + } + &::view-transition-new(hero) { + animation: var(--vt-duration-default) 0s fadeIn forwards; + } + } + + ::view-transition-group(letterbox-left), + ::view-transition-group(letterbox-right), + ::view-transition-group(letterbox-top), + ::view-transition-group(letterbox-bottom) { + animation-name: none; + } + + ::view-transition-old(letterbox-left), + ::view-transition-old(letterbox-right), + ::view-transition-old(letterbox-top), + ::view-transition-old(letterbox-bottom) { + animation: var(--vt-duration-default) fadeOut forwards; + } + + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right), + ::view-transition-new(letterbox-top), + ::view-transition-new(letterbox-bottom) { + animation: var(--vt-duration-default) fadeIn forwards; + } + + ::view-transition-group(previous), + ::view-transition-group(previous-old), + ::view-transition-group(next), + ::view-transition-group(next-old) { + width: 100% !important; + height: 100% !important; + transform: none !important; + } + + ::view-transition-old(previous), + ::view-transition-old(previous-old), + ::view-transition-old(next), + ::view-transition-old(next-old) { + animation: var(--vt-duration-default) fadeOut forwards; + transform-origin: center; + height: 100%; + width: 100%; + object-fit: contain; + overflow: hidden; + } + + ::view-transition-new(previous), + ::view-transition-new(previous-new), + ::view-transition-new(next), + ::view-transition-new(next-new) { + animation: var(--vt-duration-default) fadeIn forwards; + transform-origin: center; + height: 100%; + width: 100%; + object-fit: contain; + } + } +} diff --git a/web/src/lib/components/asset-viewer/adaptive-image.svelte b/web/src/lib/components/asset-viewer/adaptive-image.svelte index 56eea2a4c7..237c1e1b08 100644 --- a/web/src/lib/components/asset-viewer/adaptive-image.svelte +++ b/web/src/lib/components/asset-viewer/adaptive-image.svelte @@ -2,6 +2,7 @@ import { imageLoader } from '$lib/actions/image-loader.svelte'; import { thumbhash } from '$lib/actions/thumbhash'; import { zoomImageAction } from '$lib/actions/zoom-image'; + import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store'; @@ -25,6 +26,7 @@ }; slideshowState: SlideshowState; slideshowLook: SlideshowLook; + transitionName?: string | null | undefined; onImageReady?: () => void; onError?: () => void; imgElement?: HTMLImageElement; @@ -40,6 +42,7 @@ container, slideshowState, slideshowLook, + transitionName, onImageReady, onError, overlays, @@ -110,6 +113,10 @@ }; }); + const blurredSlideshow = $derived( + slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash, + ); + const loadState = $derived(adaptiveImageLoader.adaptiveLoaderState); const imageAltText = $derived(loadState.previewUrl ? $getAltText(toTimelineAsset(asset)) : ''); const thumbnailUrl = $derived(loadState.thumbnailUrl); @@ -134,59 +141,42 @@ }); -
- {#if asset.thumbhash} - -
- -
- {:else if showSpinner} -
- -
+
+ + {#if blurredSlideshow} + {/if} + + + +
adaptiveImageLoader.onThumbnailStart(), - onLoad: () => adaptiveImageLoader.onThumbnailLoad(), - onError: () => adaptiveImageLoader.onThumbnailError(), - onElementCreated: (element) => (thumbnailElement = element), - imgClass: ['absolute h-full', 'w-full'], - alt: '', - role: 'presentation', - dataAttributes: { - 'data-testid': 'thumbnail', - }, - }} - >
- - {#if showBrokenAsset} -
- -
- {:else} - - {#if thumbnailUrl && slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground} - + > + {#if asset.thumbhash} + +
+ +
+ {:else if showSpinner} +
+ +
{/if}
adaptiveImageLoader.onPreviewStart(), - onLoad: () => adaptiveImageLoader.onPreviewLoad(), - onError: () => adaptiveImageLoader.onPreviewError(), - onElementCreated: (element) => (previewElement = element), - imgClass: ['h-full', 'w-full', imageClass], - alt: imageAltText, - draggable: false, + src: thumbnailUrl, + onStart: () => adaptiveImageLoader.onThumbnailStart(), + onLoad: () => adaptiveImageLoader.onThumbnailLoad(), + onError: () => adaptiveImageLoader.onThumbnailError(), + onElementCreated: (element) => (thumbnailElement = element), + imgClass: ['absolute h-full', 'w-full'], + alt: '', + role: 'presentation', dataAttributes: { - 'data-testid': 'preview', + 'data-testid': 'thumbnail', }, }} - > - {@render overlays?.()} -
+ >
+ {#if showBrokenAsset} +
+ +
+ {:else} +
adaptiveImageLoader.onPreviewStart(), + onLoad: () => adaptiveImageLoader.onPreviewLoad(), + onError: () => adaptiveImageLoader.onPreviewError(), + onElementCreated: (element) => (previewElement = element), + imgClass: ['h-full', 'w-full', imageClass], + alt: imageAltText, + draggable: false, + dataAttributes: { + 'data-testid': 'preview', + }, + }} + > + {@render overlays?.()} +
+ +
adaptiveImageLoader.onOriginalStart(), + onLoad: () => adaptiveImageLoader.onOriginalLoad(), + onError: () => adaptiveImageLoader.onOriginalError(), + onElementCreated: (element) => (originalElement = element), + imgClass: ['h-full', 'w-full', imageClass], + alt: imageAltText, + draggable: false, + dataAttributes: { + 'data-testid': 'original', + }, + }} + > + {@render overlays?.()} +
+ {/if} + +
adaptiveImageLoader.onOriginalStart(), - onLoad: () => adaptiveImageLoader.onOriginalLoad(), - onError: () => adaptiveImageLoader.onOriginalError(), - onElementCreated: (element) => (originalElement = element), - imgClass: ['h-full', 'w-full', imageClass], - alt: imageAltText, - draggable: false, - dataAttributes: { - 'data-testid': 'original', - }, - }} > - {@render overlays?.()} +
- {/if} - - -
-
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index d118fdcaa0..29ec216dcc 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -13,6 +13,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; + import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte'; import { Route } from '$lib/route'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; @@ -22,11 +23,11 @@ import { getSharedLink, handlePromiseError } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte'; - import { navigateToAsset } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { InvocationTracker } from '$lib/utils/invocationTracker'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; + import { navigateToAsset } from '$lib/utils/asset-utils'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetTypeEnum, @@ -37,9 +38,9 @@ type PersonResponseDto, type StackResponseDto, } from '@immich/sdk'; - import { onDestroy, onMount, untrack } from 'svelte'; + import { onDestroy, onMount, tick, untrack } from 'svelte'; import { t } from 'svelte-i18n'; - import { fly } from 'svelte/transition'; + import { fly, slide } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import ActivityStatus from './activity-status.svelte'; import ActivityViewer from './activity-viewer.svelte'; @@ -88,7 +89,7 @@ onRandom, }: Props = $props(); - const { setAssetId } = assetViewingStore; + const { setAssetId, invisible } = assetViewingStore; const { restartProgress: restartSlideshowProgress, stopProgress: stopSlideshowProgress, @@ -110,6 +111,10 @@ let sharedLink = getSharedLink(); let fullscreenElement = $state(); + let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow); + let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder); + let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle); + let playOriginalVideo = $state($alwaysLoadOriginalVideo); let slideshowStartAssetId = $state(); @@ -142,38 +147,57 @@ } }; + let transitionName = $state('hero'); + let equirectangularTransitionName = $state('hero'); + let detailPanelTransitionName = $state(undefined); + + let unsubscribes: (() => void)[] = []; onMount(() => { - const slideshowStateUnsubscribe = slideshowState.subscribe((value) => { - if (value === SlideshowState.PlaySlideshow) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - handlePromiseError(handlePlaySlideshow()); - } else if (value === SlideshowState.StopSlideshow) { - handlePromiseError(handleStopSlideshow()); - } - }); - - const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => { - if (value === SlideshowNavigation.Shuffle) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - } - }); - - return () => { - slideshowStateUnsubscribe(); - slideshowNavigationUnsubscribe(); + const addInfoTransition = () => { + detailPanelTransitionName = 'info'; + transitionName = 'hero'; + equirectangularTransitionName = 'hero'; }; + const finished = () => { + detailPanelTransitionName = undefined; + transitionName = undefined; + }; + + unsubscribes.push( + eventManager.on('TransitionToAssetViewer', addInfoTransition), + eventManager.on('TransitionToTimeline', addInfoTransition), + eventManager.on('Finished', finished), + slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + handlePromiseError(handlePlaySlideshow()); + } else if (value === SlideshowState.StopSlideshow) { + handlePromiseError(handleStopSlideshow()); + } + }), + slideshowNavigation.subscribe((value) => { + if (value === SlideshowNavigation.Shuffle) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + } + }), + ); }); onDestroy(() => { activityManager.reset(); + for (const unsubscribe of unsubscribes) { + unsubscribe(); + } + destroyNextPreloader(); destroyPreviousPreloader(); }); const closeViewer = () => { + transitionName = 'hero'; onClose?.(asset); }; @@ -186,6 +210,35 @@ assetViewerManager.closeEditor(); }; + const startTransition = async ( + types: string[], + targetTransition: string | null, + targetAsset: AssetResponseDto | null, + navigateFn: () => Promise, + ) => { + transitionName = viewTransitionManager.getTransitionName('old', targetTransition); + equirectangularTransitionName = viewTransitionManager.getTransitionName('old', targetTransition); + detailPanelTransitionName = 'detail-panel'; + await tick(); + const navigationResult = new Promise((navigationResolve) => { + viewTransitionManager.startTransition( + new Promise((resolve) => { + eventManager.once('StartViewTransition', async () => { + transitionName = viewTransitionManager.getTransitionName('new', targetTransition); + if (targetAsset && isEquirectangular(asset) && !isEquirectangular(targetAsset)) { + equirectangularTransitionName = undefined; + } + await tick(); + navigationResolve(await navigateFn()); + }); + eventManager.once('AssetViewerFree', () => tick().then(resolve)); + }), + types, + ); + }); + return navigationResult; + }; + let nextPreloader: AdaptiveImageLoader | undefined; let previousPreloader: AdaptiveImageLoader | undefined; let nextPreviewUrl = $state(); @@ -271,33 +324,57 @@ } }; - const getNavigationTarget = () => { - if ($slideshowState === SlideshowState.PlaySlideshow) { - return $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; - } else { - return 'skip'; + const getNavigationTarget = (): 'previous' | 'next' | undefined => { + if (slideShowPlaying) { + return slideShowAscending ? 'previous' : 'next'; } + return undefined; }; - const completeNavigation = async (target: 'previous' | 'next') => { - cancelPreloadsBeforeNavigation(target); - let hasNext = false; - - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { - hasNext = target === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); - if (!hasNext) { - const asset = await onRandom?.(); - if (asset) { - slideshowHistory.queue(asset); - hasNext = true; - } - } - } else { - hasNext = - target === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); + const completeNavigation = async (order: 'previous' | 'next', skipTransition: boolean) => { + cancelPreloadsBeforeNavigation(order); + let skipped = false; + if (viewTransitionManager.skipTransitions()) { + skipped = true; } - if ($slideshowState !== SlideshowState.PlaySlideshow) { + let hasNext = false; + if (slideShowPlaying && slideShowShuffle) { + const navigate = async () => { + let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); + if (!next) { + const asset = await onRandom?.(); + if (asset) { + slideshowHistory.queue(asset); + next = true; + } + } + return next; + }; + // eslint-disable-next-line unicorn/prefer-ternary + if (viewTransitionManager.isSupported() && !skipped && !skipTransition) { + hasNext = await startTransition(['slideshow'], null, null, navigate); + } else { + hasNext = await navigate(); + } + } else { + const targetAsset = order === 'previous' ? previousAsset : nextAsset; + const navigate = async () => + order === 'previous' ? await navigateToAsset(previousAsset) : await navigateToAsset(nextAsset); + if (viewTransitionManager.isSupported() && !skipped && !skipTransition && !!targetAsset) { + const targetTransition = slideShowPlaying ? null : order; + hasNext = await startTransition( + slideShowPlaying ? ['slideshow'] : ['viewer-nav'], + targetTransition, + targetAsset, + navigate, + ); + } else { + hasNext = await navigate(); + } + } + + if (!slideShowPlaying) { return; } @@ -312,15 +389,23 @@ }; const tracker = new InvocationTracker(); - const navigateAsset = (target: 'previous' | 'next' | 'skip') => { - if (target === 'skip' || tracker.isActive()) { + const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => { + if (!order) { + if (slideShowPlaying) { + order = slideShowAscending ? 'previous' : 'next'; + } else { + return; + } + } + + if (tracker.isActive()) { return; } void tracker.invoke( - () => completeNavigation(target), + () => completeNavigation(order, skipTransition), (error: unknown) => handleError(error, $t('error_while_navigating')), - () => eventManager.emit('ViewerFinishNavigate'), + () => eventManager.emit('AssetViewerAfterNavigate'), ); }; @@ -352,10 +437,11 @@ const handleStopSlideshow = async () => { try { - if (document.fullscreenElement) { - document.body.style.cursor = ''; - await document.exitFullscreen(); + if (!document.fullscreenElement) { + return; } + document.body.style.cursor = ''; + await document.exitFullscreen(); } catch (error) { handleError(error, $t('errors.unable_to_exit_fullscreen')); } finally { @@ -391,9 +477,10 @@ } case AssetAction.REMOVE_ASSET_FROM_STACK: { stack = action.stack; - if (stack) { - cursor.current = stack.assets[0]; + if (!stack) { + break; } + cursor.current = stack.assets[0]; break; } case AssetAction.STACK: @@ -463,20 +550,22 @@ if (cursor.current.id === lastCursor?.current.id) { return; } + if (lastCursor) { selectedStackAsset = undefined; previewStackedAsset = undefined; // After navigation completes, reconcile preloads with full state information updatePreloadsAfterNavigation(lastCursor, cursor); + lastCursor = cursor; + return; } - if (!lastCursor && cursor) { - // "first time" load, start preloads - if (cursor.nextAsset) { - nextPreloader = startPreloader(cursor.nextAsset, 'next'); - } - if (cursor.previousAsset) { - previousPreloader = startPreloader(cursor.previousAsset, 'previous'); - } + + // "first time" load, start preloads + if (cursor.nextAsset) { + nextPreloader = startPreloader(cursor.nextAsset, 'next'); + } + if (cursor.previousAsset) { + previousPreloader = startPreloader(cursor.previousAsset, 'previous'); } lastCursor = cursor; }); @@ -496,6 +585,21 @@ } }; + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + asset.id; + if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') { + eventManager.emit('AssetViewerFree'); + } + }); + + const isEquirectangular = (asset: AssetResponseDto) => { + return ( + asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + ); + }; + const viewerKind = $derived.by(() => { if (previewStackedAsset) { return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; @@ -541,12 +645,16 @@
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor} -
+
+
navigateAsset('previous')} />
{/if} @@ -587,6 +698,7 @@
{#if viewerKind === 'StackVideoViewer'} {:else if viewerKind === 'LiveVideoViewer'} {:else if viewerKind === 'ImagePanaramaViewer'} - + {:else if viewerKind === 'CropArea'} {:else if viewerKind === 'PhotoViewer'} navigateAsset(direction === 'left' ? 'next' : 'previous')} + onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous', true)} + onReady={() => eventManager.emit('AssetViewerFree')} /> {:else if viewerKind === 'VideoViewer'} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset} -
+
navigateAsset('next')} />
{/if} {#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor}
diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index 01b2982efb..4925b93517 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -11,7 +11,7 @@ import { t } from 'svelte-i18n'; interface Props { - htmlElement: HTMLImageElement | HTMLVideoElement; + htmlElement: HTMLImageElement | HTMLVideoElement | undefined | null; containerWidth: number; containerHeight: number; assetId: string; @@ -78,6 +78,9 @@ }); $effect(() => { + if (!htmlElement) { + return; + } const { actualWidth, actualHeight } = getContainedSize(htmlElement); const offsetArea = { width: (containerWidth - actualWidth) / 2, diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index 5b18dbb4e3..76601af6ef 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -7,10 +7,11 @@ import { fade } from 'svelte/transition'; type Props = { + transitionName?: string; asset: AssetResponseDto; }; - let { asset }: Props = $props(); + let { transitionName, asset }: Props = $props(); const loadAssetData = async (id: string) => { const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); @@ -18,11 +19,15 @@ }; -
+
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])} {:then [data, { default: PhotoSphereViewer }]} - + {:catch} {$t('errors.failed_to_load_asset')} {/await} diff --git a/web/src/lib/components/asset-viewer/letterboxes.svelte b/web/src/lib/components/asset-viewer/letterboxes.svelte new file mode 100644 index 0000000000..d183f10a0b --- /dev/null +++ b/web/src/lib/components/asset-viewer/letterboxes.svelte @@ -0,0 +1,114 @@ + + + +
+
+
+
diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index f671aa1b1c..bedc2846d9 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -2,6 +2,7 @@ import { shortcuts } from '$lib/actions/shortcut'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; + import { eventManager } from '$lib/managers/event-manager.svelte'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { @@ -28,6 +29,7 @@ }; type Props = { + transitionName?: string; panorama: string | { source: string }; originalPanorama?: string | { source: string }; adapter?: AdapterConstructor | [AdapterConstructor, unknown]; @@ -35,7 +37,14 @@ navbar?: boolean; }; - let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); + let { + transitionName, + panorama, + originalPanorama, + adapter = EquirectangularAdapter, + plugins = [], + navbar = false, + }: Props = $props(); let container: HTMLDivElement | undefined = $state(); let viewer: Viewer; @@ -144,6 +153,13 @@ zoomSpeed: 0.5, fisheye: false, }); + viewer.addEventListener( + 'ready', + () => { + eventManager.emit('AssetViewerFree'); + }, + { once: true }, + ); const resolutionPlugin = viewer.getPlugin(ResolutionPlugin); const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { // zoomLevel is 0-100 @@ -175,4 +191,9 @@ -
+
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 294f10100f..38f2ef8b3e 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -26,12 +26,13 @@ cursor: AssetCursor; element?: HTMLDivElement; sharedLink?: SharedLinkResponseDto; + transitionName?: string; onReady?: () => void; onError?: () => void; onSwipe?: (direction: 'left' | 'right') => void; } - let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props(); + let { cursor, element = $bindable(), sharedLink, transitionName, onReady, onError, onSwipe }: Props = $props(); const { slideshowState, slideshowLook } = slideshowStore; const asset = $derived(cursor.current); @@ -156,6 +157,7 @@ onReady?.(); }} bind:imgElement={assetViewerManager.imgRef} + {transitionName} > {#snippet overlays()} diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 76e192b9a4..96b6fad124 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -6,6 +6,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, @@ -109,6 +110,7 @@ width: videoPlayer?.videoWidth ?? 1, height: videoPlayer?.videoHeight ?? 1, }; + eventManager.emit('AssetViewerFree'); }; const handleCanPlay = async (video: HTMLVideoElement) => { diff --git a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte index 2f22094bc7..9aa72e0e9a 100644 --- a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte @@ -6,10 +6,11 @@ import { fade } from 'svelte/transition'; interface Props { + transitionName?: string; asset: AssetResponseDto; } - const { asset }: Props = $props(); + const { asset, transitionName }: Props = $props(); const modules = Promise.all([ import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default), @@ -24,6 +25,7 @@ {:then [PhotoSphereViewer, adapter, videoPlugin]} {#if projectionType === ProjectionType.EQUIRECTANGULAR} - + {:else}
- {#if !hideNavbar} + {#if !hideNavbar && !isAssetViewer} openFileUploadDialog()} /> {/if} + + {#if isAssetViewer} +
+ {/if}
- {#if sidebar} + {#if isAssetViewer} +
+ {:else if sidebar} {@render sidebar()} {:else} {/if} -
+
{@render children?.()}
diff --git a/web/src/lib/components/timeline/AssetLayout.svelte b/web/src/lib/components/timeline/AssetLayout.svelte index 1d3300ca71..d8e9b875e8 100644 --- a/web/src/lib/components/timeline/AssetLayout.svelte +++ b/web/src/lib/components/timeline/AssetLayout.svelte @@ -1,20 +1,14 @@ {#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} @@ -95,7 +126,7 @@
(null); const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0); const maxMd = $derived(mediaQueryManager.maxMd); @@ -211,7 +213,7 @@ timelineManager.viewportWidth = rect.width; } } - const scrollTarget = $gridScrollTarget?.at; + const scrollTarget = getScrollTarget(); let scrolled = false; if (scrollTarget) { scrolled = await scrollAndLoadAsset(scrollTarget); @@ -223,7 +225,7 @@ await tick(); focusAsset(scrollTarget); } - invisible = false; + invisible = isAssetViewerRoute(page) ? true : false; }; // note: only modified once in afterNavigate() @@ -241,10 +243,13 @@ hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer; }); + const getScrollTarget = () => { + return $gridScrollTarget?.at ?? page.params.assetId ?? null; + }; // afterNavigate is only called after navigation to a new URL, {complete} will resolve // after successful navigation. afterNavigate(({ complete }) => { - void complete.finally(() => { + void complete.finally(async () => { const isAssetViewerPage = isAssetViewerRoute(page); // Set initial load state only once - if initialLoadWasAssetViewer is null, then @@ -253,8 +258,13 @@ if (isDirectNavigation) { initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer; } - void scrollAfterNavigate(); + if (!isAssetViewerPage) { + const scrollTarget = getScrollTarget(); + await tick(); + + eventManager.emit('TimelineLoaded', { id: scrollTarget }); + } }); }); @@ -264,7 +274,7 @@ const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height); onMount(() => { - if (!enableRouting) { + if (!enableRouting && !isAssetViewerRoute(page)) { invisible = false; } }); @@ -561,19 +571,6 @@ isSelectingAllAssets.set(timelineManager.assetCount === assetInteraction.selectedAssets.length); }; - - const _onClick = ( - timelineManager: TimelineManager, - assets: TimelineAsset[], - groupTitle: string, - asset: TimelineAsset, - ) => { - if (isSelectionMode || assetInteraction.selectionActive) { - assetSelectHandler(timelineManager, asset, assets, groupTitle); - return; - } - void navigate({ targetRoute: 'current', assetId: asset.id }); - }; @@ -604,6 +601,7 @@ {#if timelineManager.months.length > 0} {#snippet thumbnail({ asset, position, dayGroup, groupIndex })} @@ -701,12 +699,56 @@ {asset} {albumUsers} {groupIndex} - onClick={(asset) => { - if (typeof onThumbnailClick === 'function') { - onThumbnailClick(asset, timelineManager, dayGroup, _onClick); - } else { - _onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); + onClick={async (asset) => { + const onClick = ( + timelineManager: TimelineManager, + assets: TimelineAsset[], + groupTitle: string, + asset: TimelineAsset, + ) => { + if (isSelectionMode || assetInteraction.selectionActive) { + assetSelectHandler(timelineManager, asset, assets, groupTitle); + return; + } + void navigate({ targetRoute: 'current', assetId: asset.id }); + }; + + const dispatchClick = () => { + if (typeof onThumbnailClick === 'function') { + onThumbnailClick(asset, timelineManager, dayGroup, onClick); + } else { + onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); + } + }; + + const hasThumbnailClick = typeof onThumbnailClick === 'function'; + const selectingAssets = isSelectionMode || assetInteraction.selectionActive; + + if (!viewTransitionManager.isSupported() || hasThumbnailClick || selectingAssets) { + dispatchClick(); + return; } + + // tag target on the 'old' snapshot + toAssetViewerTransitionId = asset.id; + await tick(); + + eventManager.once('StartViewTransition', () => { + toAssetViewerTransitionId = null; + dispatchClick(); + }); + + viewTransitionManager.startTransition( + new Promise((resolve) => { + eventManager.once('AssetViewerFree', () => { + void tick().then(() => { + eventManager.emit('TransitionToAssetViewer'); + resolve(); + }); + }); + }), + ['viewer'], + ); }} onSelect={() => { if (isSelectionMode || assetInteraction.selectionActive) { diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index f61d88c1c4..42f8ab1337 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -4,6 +4,7 @@ import { AssetAction } from '$lib/constants'; import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import { eventManager } from '$lib/managers/event-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -97,6 +98,10 @@ }; const handleClose = async (asset: { id: string }) => { + const awaitInit = new Promise((resolve) => eventManager.once('StartViewTransition', resolve)); + eventManager.emit('TransitionToTimeline', { id: asset.id }); + await awaitInit; + assetViewingStore.showAssetViewer(false); invisible = true; $gridScrollTarget = { at: asset.id }; diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index 5c3b59fafe..59b36a3306 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -49,6 +49,7 @@ $locale = newLocale; } }; + let editedLocale = $derived(findLocale($locale).code); let selectedDate: string = $derived(createDateFormatter(editedLocale).formatDateTime(time)); let selectedOption = $derived({ diff --git a/web/src/lib/managers/ViewTransitionManager.svelte.ts b/web/src/lib/managers/ViewTransitionManager.svelte.ts new file mode 100644 index 0000000000..fc3a0bf719 --- /dev/null +++ b/web/src/lib/managers/ViewTransitionManager.svelte.ts @@ -0,0 +1,127 @@ +import { eventManager } from '$lib/managers/event-manager.svelte'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function traceTransitionEvents(msg: string, error?: unknown) { + // console.log(msg, error); +} +class ViewTransitionManager { + #activeViewTransition = $state(null); + #finishedCallbacks: (() => void)[] = []; + + #splitViewerNavTransitionNames = true; + + constructor() { + const root = document.documentElement; + const value = getComputedStyle(root).getPropertyValue('--immich-split-viewer-nav').trim(); + this.#splitViewerNavTransitionNames = value === 'enabled'; + } + + getTransitionName = (kind: 'old' | 'new', name: string | null | undefined) => { + if (name === 'previous' || name === 'next') { + return this.#splitViewerNavTransitionNames ? name + '-' + kind : name; + } else if (name) { + return name; + } + return undefined; + }; + + get activeViewTransition() { + return this.#activeViewTransition; + } + + isSupported() { + return 'startViewTransition' in document; + } + + skipTransitions() { + const skippedTransitions = !!this.#activeViewTransition; + this.#activeViewTransition?.skipTransition(); + this.#notifyFinished(); + return skippedTransitions; + } + + startTransition(domUpdateComplete: Promise, types?: string[], finishedCallback?: () => unknown) { + if (!this.isSupported()) { + throw new Error('View transition API not available'); + } + if (this.#activeViewTransition) { + traceTransitionEvents('Can not start transition - one already active'); + return; + } + + // good time to add view-transition-name styles (if needed) + traceTransitionEvents('emit BeforeStartViewTransition'); + eventManager.emit('BeforeStartViewTransition'); + + // next call will create the 'old' view snapshot + let transition: ViewTransition; + try { + // eslint-disable-next-line tscompat/tscompat + transition = document.startViewTransition({ + update: async () => { + // Good time to remove any view-transition-name styles created during + // BeforeStartViewTransition, then trigger the actual view transition. + traceTransitionEvents('emit StartViewTransition'); + eventManager.emit('StartViewTransition'); + + await domUpdateComplete; + traceTransitionEvents('awaited domUpdateComplete'); + }, + types, + }); + } catch { + // eslint-disable-next-line tscompat/tscompat + transition = document.startViewTransition(async () => { + // Good time to remove any view-transition-name styles created during + // BeforeStartViewTransition, then trigger the actual view transition. + traceTransitionEvents('emit StartViewTransition'); + eventManager.emit('StartViewTransition'); + await domUpdateComplete; + traceTransitionEvents('awaited domUpdateComplete'); + }); + } + this.#activeViewTransition = transition; + this.#finishedCallbacks.push(() => { + this.#activeViewTransition = null; + }); + if (finishedCallback) { + this.#finishedCallbacks.push(finishedCallback); + } + // 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 + transition.updateCallbackDone + .then(() => { + traceTransitionEvents('emit UpdateCallbackDone'); + eventManager.emit('UpdateCallbackDone'); + }) + .catch((error: unknown) => traceTransitionEvents('error in UpdateCallbackDone', error)); + // Both old/new snapshots are taken - pseudo elements are created, transition is + // about to start + // eslint-disable-next-line tscompat/tscompat + transition.ready + .then(() => eventManager.emit('Ready')) + .catch((error: unknown) => { + this.#notifyFinished(); + traceTransitionEvents('error in Ready', error); + }); + // Transition is complete + // eslint-disable-next-line tscompat/tscompat + transition.finished + .then(() => { + traceTransitionEvents('emit Finished'); + eventManager.emit('Finished'); + }) + .catch((error: unknown) => traceTransitionEvents('error in Finished', error)); + // eslint-disable-next-line tscompat/tscompat + void transition.finished.then(() => this.#notifyFinished()); + } + + #notifyFinished() { + for (const callback of this.#finishedCallbacks) { + callback(); + } + this.#finishedCallbacks = []; + } +} + +export const viewTransitionManager = new ViewTransitionManager(); diff --git a/web/src/lib/managers/app-manager.svelte.ts b/web/src/lib/managers/app-manager.svelte.ts new file mode 100644 index 0000000000..b0b7229ab8 --- /dev/null +++ b/web/src/lib/managers/app-manager.svelte.ts @@ -0,0 +1,13 @@ +class AppManager { + #isAssetViewer = $state(false); + + set isAssetViewer(value: boolean) { + this.#isAssetViewer = value; + } + + get isAssetViewer() { + return this.#isAssetViewer; + } +} + +export const appManager = new AppManager(); diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 40b22f96f6..065dfd7d77 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -23,6 +23,7 @@ export type Events = { ResetSwipeFeedback: []; ViewerFinishNavigate: []; + AssetViewerAfterNavigate: []; AuthLogin: [LoginResponseDto]; AuthLogout: []; @@ -76,6 +77,19 @@ export type Events = { SessionLocked: []; + TransitionToTimeline: [{ id: string }]; + TimelineLoaded: [{ id: string | null }]; + + TransitionToAssetViewer: []; + AssetViewerLoaded: []; + AssetViewerFree: []; + + BeforeStartViewTransition: []; + Finished: []; + Ready: []; + UpdateCallbackDone: []; + StartViewTransition: []; + SystemConfigUpdate: [SystemConfigDto]; LibraryCreate: [LibraryResponseDto]; diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index 3cd2cd9579..f137e917f9 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -5,6 +5,7 @@ import { readonly, writable } from 'svelte/store'; function createAssetViewingStore() { const viewingAssetStoreState = writable(); + const invisible = writable(false); const viewState = writable(false); const gridScrollTarget = writable(); @@ -30,6 +31,7 @@ function createAssetViewingStore() { setAsset, setAssetId, showAssetViewer, + invisible, }; } diff --git a/web/src/lib/utils/base-event-manager.svelte.ts b/web/src/lib/utils/base-event-manager.svelte.ts index 50fa29b776..aff6ad372c 100644 --- a/web/src/lib/utils/base-event-manager.svelte.ts +++ b/web/src/lib/utils/base-event-manager.svelte.ts @@ -30,6 +30,14 @@ export class BaseEventManager { }; } + once(event: T, callback: EventCallback) { + const unsubscribe = this.on(event, (...args: Events[T]) => { + unsubscribe(); + return callback(...args); + }); + return unsubscribe; + } + emit(event: T, ...params: Events[T]) { const listeners = this.getListeners(event); for (const listener of listeners) { diff --git a/web/src/routes/(user)/+layout.svelte b/web/src/routes/(user)/+layout.svelte index e6e349fe91..bf086ca97a 100644 --- a/web/src/routes/(user)/+layout.svelte +++ b/web/src/routes/(user)/+layout.svelte @@ -24,7 +24,7 @@ }); -
+
{@render children?.()}
@@ -33,7 +33,4 @@ :root { overscroll-behavior: none; } - .display-none { - display: none; - } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 08a304190a..f8fdddf2b4 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,5 +1,5 @@