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 484d6507dc..3934c8d97a 100644 --- a/web/src/lib/components/asset-viewer/adaptive-image.svelte +++ b/web/src/lib/components/asset-viewer/adaptive-image.svelte @@ -3,6 +3,7 @@ import { thumbhash } from '$lib/actions/thumbhash'; import { zoomImageAction } from '$lib/actions/zoom-image'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; + import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte'; import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store'; import { photoZoomState, photoZoomTransform, resetZoomState } from '$lib/stores/zoom-image.store'; import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte'; @@ -25,6 +26,7 @@ }; slideshowState: SlideshowState; slideshowLook: SlideshowLook; + transitionName?: string | null | undefined; onImageReady?: () => void; onError?: () => void; imgElement?: HTMLImageElement; @@ -42,6 +44,7 @@ container, slideshowState, slideshowLook, + transitionName, onImageReady, onError, overlays, @@ -97,6 +100,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); @@ -121,119 +128,140 @@ }); -
- {#if asset.thumbhash} - -
- -
- {:else if showSpinner} -
- -
+
+ + {#if blurredSlideshow} + {/if} -
adaptiveImageLoader.onThumbnailStart(), - onLoad: () => adaptiveImageLoader.onThumbnailLoad(), - onError: () => adaptiveImageLoader.onThumbnailError(), - onElementCreated: (el) => (thumbnailElement = el), - 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}
+ use:imageLoader={{ + src: thumbnailUrl, + onStart: () => adaptiveImageLoader.onThumbnailStart(), + onLoad: () => adaptiveImageLoader.onThumbnailLoad(), + onError: () => adaptiveImageLoader.onThumbnailError(), + onElementCreated: (el) => (thumbnailElement = el), + 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} +
adaptiveImageLoader.onPreviewStart(), - onLoad: () => adaptiveImageLoader.onPreviewLoad(), - onError: () => adaptiveImageLoader.onPreviewError(), - onElementCreated: (el) => (previewElement = el), - imgClass: ['h-full', 'w-full', { imageClass }], - alt: imageAltText, - draggable: false, - dataAttributes: { - 'data-testid': 'preview', - }, - }} - >
+ class="absolute top-0" + style:transform-origin="0px 0px" + style:transform={$photoZoomTransform} + style:width={renderDimensions.width} + style:height={renderDimensions.height} + > +
adaptiveImageLoader.onPreviewStart(), + onLoad: () => adaptiveImageLoader.onPreviewLoad(), + onError: () => adaptiveImageLoader.onPreviewError(), + onElementCreated: (el) => (previewElement = el), + imgClass: ['h-full', 'w-full', { imageClass }], + alt: imageAltText, + draggable: false, + dataAttributes: { + 'data-testid': 'preview', + }, + }} + >
- {@render overlays?.()} -
+ {@render overlays?.()} +
-
adaptiveImageLoader.onOriginalStart(), - onLoad: () => adaptiveImageLoader.onOriginalLoad(), - onError: () => adaptiveImageLoader.onOriginalError(), - onElementCreated: (el) => (originalElement = el), - imgClass: ['h-full', 'w-full', { imageClass }], - alt: imageAltText, - draggable: false, - dataAttributes: { - 'data-testid': 'original', - }, - }} - >
+ class="absolute top-0" + style:transform-origin="0px 0px" + style:transform={$photoZoomTransform} + style:width={renderDimensions.width} + style:height={renderDimensions.height} + > +
adaptiveImageLoader.onOriginalStart(), + onLoad: () => adaptiveImageLoader.onOriginalLoad(), + onError: () => adaptiveImageLoader.onOriginalError(), + onElementCreated: (el) => (originalElement = el), + imgClass: ['h-full', 'w-full', { imageClass }], + alt: imageAltText, + draggable: false, + dataAttributes: { + 'data-testid': 'original', + }, + }} + >
- {@render overlays?.()} -
+ {@render overlays?.()} +
- -
- -
- {/if} + +
+ +
+ {/if} + diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 08a304190a..dc98cbaeb9 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,5 +1,5 @@