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}
-