From fd631ee3b283c3c11b79cff2c4bdede97de65357 Mon Sep 17 00:00:00 2001 From: midzelis Date: Mon, 8 Dec 2025 11:36:17 +0000 Subject: [PATCH] feat: web - view transitions from timeline to viewer, next/prev feat: web - view transitions from timeline to viewer, next/prev feat: web - swipe feedback - show image while swiping/dragging left/right feat: web - swipe feedback - show image while swiping/dragging left/right --- web/src/app.css | 328 ++++++++++++++++++ web/src/lib/components/AdaptiveImage.svelte | 35 +- .../asset-viewer/asset-viewer.svelte | 233 ++++++++++--- .../editor/transform-tool/crop-area.svelte | 5 +- .../face-editor/face-editor.svelte | 5 +- .../asset-viewer/image-panorama-viewer.svelte | 14 +- .../asset-viewer/letterboxes.svelte | 114 ++++++ .../photo-sphere-viewer-adapter.svelte | 34 +- .../asset-viewer/photo-viewer.svelte | 4 +- .../asset-viewer/video-native-viewer.svelte | 6 + .../asset-viewer/video-panorama-viewer.svelte | 9 +- .../asset-viewer/video-wrapper-viewer.svelte | 8 +- .../layouts/user-page-layout.svelte | 16 +- .../components/timeline/AssetLayout.svelte | 19 +- web/src/lib/components/timeline/Month.svelte | 39 ++- .../lib/components/timeline/Scrubber.svelte | 5 +- .../lib/components/timeline/Timeline.svelte | 92 +++-- .../timeline/TimelineAssetViewer.svelte | 5 + .../managers/ViewTransitionManager.svelte.ts | 127 +++++++ web/src/lib/managers/app-manager.svelte.ts | 13 + web/src/lib/managers/event-manager.svelte.ts | 14 + web/src/lib/stores/asset-viewing.store.ts | 2 + .../lib/utils/base-event-manager.svelte.ts | 8 + web/src/routes/(user)/+layout.svelte | 5 +- web/src/routes/+layout.svelte | 16 +- 25 files changed, 1031 insertions(+), 125 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/letterboxes.svelte create mode 100644 web/src/lib/managers/ViewTransitionManager.svelte.ts create mode 100644 web/src/lib/managers/app-manager.svelte.ts diff --git a/web/src/app.css b/web/src/app.css index 3a4d29b466..5dcefe4173 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), @@ -175,3 +188,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/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index ec2eb2a24a..15e89729be 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -1,12 +1,14 @@ -
+
{#await Promise.all([loadAssetData(assetId), 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 926383d9c2..96a1837fd0 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,8 +1,10 @@ -
+
{#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 291c5a14eb..f2fa9842f2 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 8b06d9b72b..431e5fa968 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)} @@ -93,7 +122,7 @@
(null); const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0); const maxMd = $derived(mediaQueryManager.maxMd); @@ -209,7 +211,7 @@ timelineManager.viewportWidth = rect.width; } } - const scrollTarget = $gridScrollTarget?.at; + const scrollTarget = getScrollTarget(); let scrolled = false; if (scrollTarget) { scrolled = await scrollAndLoadAsset(scrollTarget); @@ -221,7 +223,7 @@ await tick(); focusAsset(scrollTarget); } - invisible = false; + invisible = isAssetViewerRoute(page) ? true : false; }; // note: only modified once in afterNavigate() @@ -239,10 +241,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 @@ -251,8 +256,13 @@ if (isDirectNavigation) { initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer; } - void scrollAfterNavigate(); + if (!isAssetViewerPage) { + const scrollTarget = getScrollTarget(); + await tick(); + + eventManager.emit('TimelineLoaded', { id: scrollTarget }); + } }); }); @@ -260,7 +270,7 @@ // note: don't throttle, debounch, or otherwise do this function async - it causes flicker onMount(() => { - if (!enableRouting) { + if (!enableRouting && !isAssetViewerRoute(page)) { invisible = false; } }); @@ -544,19 +554,6 @@ assetInteraction.selectAll = 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 }); - }; @@ -587,6 +584,7 @@ {#if timelineManager.months.length > 0} {#snippet thumbnail({ asset, position, dayGroup, groupIndex })} @@ -684,12 +682,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 bd4ead6def..75d5c92548 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'; @@ -98,6 +99,10 @@ }; const handleClose = async (asset: { id: string }) => { + const awaitInit = new Promise((resolve) => eventManager.once('StartViewTransition', resolve)); + eventManager.emit('TransitionToTimeline', { id: asset.id }); + await awaitInit; + invisible = true; $gridScrollTarget = { at: asset.id }; await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }); 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 47e163cb17..3bfc098bc7 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: []; @@ -78,6 +79,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 5112076988..f4611df4ce 100644 --- a/web/src/lib/utils/base-event-manager.svelte.ts +++ b/web/src/lib/utils/base-event-manager.svelte.ts @@ -43,6 +43,14 @@ export class BaseEventManager { }; } + once(event: T, callback: EventCallback) { + const unsubscribe = this.#onEvent(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 046d5ce068..b66b441a13 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,5 +1,5 @@