diff --git a/web/src/app.css b/web/src/app.css index dc2d3bf3c3..98c124c681 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -74,6 +74,19 @@ --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: 280ms; + --vt-duration-viewer-navigation: 270ms; + --vt-duration-slideshow: 1s; + --vt-viewer-slide-easing: cubic-bezier(0.2, 0, 0, 1); + --vt-viewer-slide-distance: 15%; + --vt-viewer-opacity-start: 0.1; + --vt-viewer-blur-max: 4px; } button:not(:disabled), @@ -171,3 +184,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) { + animation-duration: var(--vt-duration-viewer-navigation); + z-index: 4; + } + + ::view-transition-image-pair(letterbox-left), + ::view-transition-image-pair(letterbox-right), + ::view-transition-image-pair(letterbox-top), + ::view-transition-image-pair(letterbox-bottom) { + isolation: auto; + } + + ::view-transition-old(letterbox-left), + ::view-transition-old(letterbox-right), + ::view-transition-old(letterbox-top), + ::view-transition-old(letterbox-bottom), + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right), + ::view-transition-new(letterbox-top), + ::view-transition-new(letterbox-bottom) { + animation: none; + width: 100%; + height: 100%; + object-fit: fill; + background-color: var(--color-black); + } + + ::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-group(hero) { + animation-duration: var(--vt-duration-hero); + animation-timing-function: cubic-bezier(0.2, 0, 0, 1); + } + ::view-transition-old(hero) { + animation: none; + align-content: center; + } + ::view-transition-new(hero) { + animation: none; + align-content: center; + } + ::view-transition-old(next), + ::view-transition-old(next-old) { + animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyOutLeft forwards; + overflow: hidden; + } + + ::view-transition-new(next), + ::view-transition-new(next-new) { + animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyInRight forwards; + overflow: hidden; + } + + ::view-transition-old(previous) { + animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyOutRight forwards; + } + ::view-transition-old(previous-old) { + animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyOutRight forwards; + overflow: hidden; + z-index: -1; + } + + ::view-transition-new(previous) { + animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyInLeft forwards; + } + + ::view-transition-new(previous-new) { + animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyInLeft forwards; + overflow: hidden; + } + + @keyframes flyInLeft { + from { + transform: translateX(calc(-1 * var(--vt-viewer-slide-distance))); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + to { + opacity: 1; + filter: blur(0); + } + } + + @keyframes flyOutLeft { + from { + opacity: 1; + filter: blur(0); + } + to { + 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 { + transform: translateX(var(--vt-viewer-slide-distance)); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + to { + opacity: 1; + filter: blur(0); + } + } + + @keyframes flyOutRight { + from { + opacity: 1; + filter: blur(0); + } + to { + transform: translateX(var(--vt-viewer-slide-distance)); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + } + + /* cubic fade curves so combined opacity stays close to 1.0 during crossfade */ + @keyframes fadeIn { + from { + opacity: 0; + } + 50% { + opacity: 0.85; + } + to { + opacity: 1; + } + } + @keyframes fadeOut { + from { + opacity: 1; + } + 50% { + opacity: 0.85; + } + 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) { + z-index: 100; + } + + ::view-transition-image-pair(letterbox-left), + ::view-transition-image-pair(letterbox-right), + ::view-transition-image-pair(letterbox-top), + ::view-transition-image-pair(letterbox-bottom) { + isolation: auto; + } + + ::view-transition-old(letterbox-left), + ::view-transition-old(letterbox-right), + ::view-transition-old(letterbox-top), + ::view-transition-old(letterbox-bottom), + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right), + ::view-transition-new(letterbox-top), + ::view-transition-new(letterbox-bottom) { + animation: none; + width: 100%; + height: 100%; + object-fit: fill; + } + + ::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-viewer-navigation) 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-viewer-navigation) 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 65b9244b46..88572699e5 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, @@ -141,9 +144,21 @@ > {/if} + + +
(); + 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 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'; }; + const finished = () => { + detailPanelTransitionName = undefined; + transitionName = undefined; + }; + + unsubscribes.push( + eventManager.onMany({ + TransitionToAssetViewer: addInfoTransition, + TransitionToTimeline: addInfoTransition, + 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,36 @@ assetViewerManager.closeEditor(); }; + const startTransition = async ( + types: string[], + targetTransition: string | null, + targetAsset: AssetResponseDto | null, + navigateFn: () => Promise, + ) => { + const oldTransitionName = viewTransitionManager.getTransitionName('old', targetTransition); + const newTransitionName = viewTransitionManager.getTransitionName('new', targetTransition); + + transitionName = oldTransitionName; + detailPanelTransitionName = 'detail-panel'; + await tick(); + + const navigationResult = new Promise((navigationResolve) => { + viewTransitionManager.startTransition( + new Promise((resolve) => { + eventManager.once('StartViewTransition', async () => { + transitionName = newTransitionName; + await tick(); + const result = await navigateFn(); + navigationResolve(result); + }); + eventManager.once('AssetViewerFree', () => void tick().then(resolve)); + }), + types, + ); + }); + return navigationResult; + }; + let nextPreloader: AdaptiveImageLoader | undefined; let previousPreloader: AdaptiveImageLoader | undefined; let nextPreviewUrl = $state(); @@ -271,33 +325,56 @@ } }; - 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); + const completeNavigation = async (order: 'previous' | 'next', skipTransition: boolean) => { + cancelPreloadsBeforeNavigation(order); + let skipped = false; + if (viewTransitionManager.skipTransitions()) { + skipped = true; + } 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; + 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 { - hasNext = - target === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); + 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 ($slideshowState !== SlideshowState.PlaySlideshow) { + 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,8 @@ } }; + const handleAssetViewerFree = () => eventManager.emit('AssetViewerFree'); + const viewerKind = $derived.by(() => { if (previewStackedAsset) { return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; @@ -541,12 +632,16 @@
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor} -
+
+
navigateAsset('previous')} />
{/if} @@ -587,6 +685,7 @@
{#if viewerKind === 'StackVideoViewer'} navigateAsset(getNavigationTarget())} onVideoStarted={handleVideoStarted} + onReady={handleAssetViewerFree} {playOriginalVideo} /> {:else if viewerKind === 'LiveVideoViewer'} navigateAsset(direction === 'left' ? 'next' : 'previous')} onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)} + onReady={handleAssetViewerFree} {playOriginalVideo} /> {: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={handleAssetViewerFree} /> {:else if viewerKind === 'VideoViewer'} navigateAsset(getNavigationTarget())} onVideoStarted={handleVideoStarted} + onReady={handleAssetViewerFree} {playOriginalVideo} /> {/if} @@ -655,16 +761,20 @@
{#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/editor/transform-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte index 7a84612fe8..6167725588 100644 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte @@ -7,9 +7,10 @@ interface Props { asset: AssetResponseDto; + onReady?: () => void; } - let { asset }: Props = $props(); + let { asset, onReady }: Props = $props(); let canvasContainer = $state(null); @@ -62,6 +63,8 @@ src={imageSrc} alt={$getAltText(toTimelineAsset(asset))} style={imageTransform ? `transform: ${imageTransform}` : ''} + onload={() => onReady?.()} + onerror={() => onReady?.()} />
{ + 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..71683eb88e 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -4,13 +4,14 @@ import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk'; import { LoadingSpinner } from '@immich/ui'; import { t } from 'svelte-i18n'; - import { fade } from 'svelte/transition'; type Props = { + transitionName?: string; asset: AssetResponseDto; + onReady?: () => void; }; - let { asset }: Props = $props(); + let { transitionName, asset, onReady }: Props = $props(); const loadAssetData = async (id: string) => { const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); @@ -18,11 +19,16 @@ }; -
+
{#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..57f1c59882 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 @@ -1,7 +1,9 @@ -
+
{#await modules} {:then [PhotoSphereViewer, adapter, videoPlugin]} {:catch} {$t('errors.failed_to_load_asset')} 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 590b1724f9..8e030676b7 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -6,6 +6,7 @@ import type { SharedLinkResponseDto } from '@immich/sdk'; interface Props { + transitionName?: string; cursor: AssetCursor; assetId?: string; sharedLink?: SharedLinkResponseDto; @@ -17,9 +18,11 @@ onSwipe?: (direction: 'left' | 'right') => void; onVideoEnded?: () => void; onVideoStarted?: () => void; + onReady?: () => void; } let { + transitionName, cursor, assetId, sharedLink, @@ -31,13 +34,15 @@ onClose, onVideoEnded, onVideoStarted, + onReady, }: Props = $props(); {#if projectionType === ProjectionType.EQUIRECTANGULAR} - + {:else} {/if} diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 614b1377fb..31a52670cf 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -6,10 +6,11 @@ import { useActions, type ActionArray } from '$lib/actions/use-actions'; import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte'; + import { appManager } from '$lib/managers/app-manager.svelte'; import type { HeaderButtonActionItem } from '$lib/types'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui'; - import type { Snippet } from 'svelte'; + import { type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; interface Props { @@ -44,12 +45,17 @@ let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden'); let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full'); + let isAssetViewer = $derived(appManager.isAssetViewer);
- {#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 +124,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 2d4644d143..b2e468c40d 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: []; @@ -77,6 +78,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 89a5c7390e..208f554db4 100644 --- a/web/src/lib/utils/base-event-manager.svelte.ts +++ b/web/src/lib/utils/base-event-manager.svelte.ts @@ -41,6 +41,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 @@