diff --git a/web/src/app.css b/web/src/app.css index 090b8fa392..e4b2337b6a 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -328,8 +328,189 @@ } ::view-transition-new(hero) { animation: none; - align-content: center; } + ::view-transition-old(memory-overlay), + ::view-transition-old(memory-controls), + ::view-transition-new(memory-overlay), + ::view-transition-new(memory-controls) { + width: 100%; + height: 100%; + object-fit: none; + object-position: left top; + } + + html:active-view-transition-type(memory) { + &::view-transition-group(hero), + &::view-transition-group(hero-out) { + animation-duration: var(--vt-duration-memory); + animation-timing-function: var(--vt-memory-easing); + overflow: hidden; + z-index: 1; + } + &::view-transition-group(memory-overlay), + &::view-transition-group(memory-controls) { + animation: none; + z-index: 5; + } + &::view-transition-group(memory-overlay-prev), + &::view-transition-group(memory-overlay-next) { + animation: none; + z-index: 2; + opacity: 0.25; + } + &::view-transition-image-pair(memory-overlay), + &::view-transition-image-pair(memory-controls) { + isolation: auto; + } + &::view-transition-old(memory-overlay), + &::view-transition-old(memory-controls) { + animation: 120ms linear fadeOut forwards; + } + &::view-transition-new(memory-overlay), + &::view-transition-new(memory-controls) { + animation: 200ms linear calc(var(--vt-duration-memory) - 200ms) fadeIn forwards; + opacity: 0; + } + &::view-transition-old(memory-overlay-prev), + &::view-transition-old(memory-overlay-next) { + display: none; + } + &::view-transition-new(memory-overlay-prev), + &::view-transition-new(memory-overlay-next) { + animation: none; + width: 100%; + height: 100%; + object-fit: none; + object-position: left top; + } + &::view-transition-image-pair(hero) { + isolation: auto; + } + &::view-transition-old(hero) { + display: none; + } + &::view-transition-new(hero) { + animation: none; + object-fit: cover; + width: 100%; + height: 100%; + } + &::view-transition-image-pair(hero-out) { + isolation: auto; + } + &::view-transition-old(hero-out) { + display: none; + } + &::view-transition-new(hero-out) { + animation: var(--vt-duration-memory) var(--vt-memory-easing) dimDown forwards; + object-fit: cover; + width: 100%; + height: 100%; + } + &::view-transition-group(memory-departing) { + animation: none; + } + &::view-transition-old(memory-departing) { + animation: calc(var(--vt-duration-memory) * 0.4) linear fadeFromDim forwards; + } + &::view-transition-new(memory-departing) { + animation: none; + visibility: hidden; + } + } + + html:active-view-transition-type(memory-enter) { + &::view-transition-group(hero) { + animation-duration: var(--vt-duration-hero); + animation-timing-function: var(--vt-memory-easing); + overflow: hidden; + } + &::view-transition-old(hero), + &::view-transition-new(hero) { + animation: none; + object-fit: cover; + width: 100%; + height: 100%; + } + &::view-transition-group(memory-overlay), + &::view-transition-group(memory-controls), + &::view-transition-group(memory-nav-buttons) { + animation: none; + z-index: 5; + } + &::view-transition-old(memory-overlay), + &::view-transition-old(memory-controls), + &::view-transition-old(memory-nav-buttons) { + animation: none; + visibility: hidden; + } + &::view-transition-new(memory-overlay), + &::view-transition-new(memory-controls), + &::view-transition-new(memory-nav-buttons) { + animation: 200ms linear var(--vt-duration-hero) fadeIn forwards; + opacity: 0; + } + } + + ::view-transition-old(memory-fade-out) { + animation: 500ms linear crossfadeOut forwards; + } + ::view-transition-new(memory-fade-in) { + animation: 500ms linear crossfadeIn forwards; + } + + html:active-view-transition-type(memory-nav-fast) { + &::view-transition-old(memory-fade-out) { + animation-duration: 250ms; + } + &::view-transition-new(memory-fade-in) { + animation-duration: 250ms; + } + &::view-transition-old(memory-overlay), + &::view-transition-old(memory-controls) { + animation-duration: 100ms; + } + &::view-transition-new(memory-overlay), + &::view-transition-new(memory-controls) { + animation: 100ms linear 150ms fadeIn forwards; + opacity: 0; + } + } + + html:active-view-transition-type(memory-nav) { + &::view-transition-group(memory-overlay), + &::view-transition-group(memory-controls) { + animation: none; + z-index: 5; + } + &::view-transition-image-pair(memory-overlay), + &::view-transition-image-pair(memory-controls) { + isolation: auto; + } + &::view-transition-old(memory-overlay), + &::view-transition-old(memory-controls) { + animation: 150ms linear fadeOut forwards; + } + &::view-transition-new(memory-overlay), + &::view-transition-new(memory-controls) { + animation: 200ms linear 300ms fadeIn forwards; + opacity: 0; + } + &::view-transition-group(memory-overlay-prev), + &::view-transition-group(memory-overlay-next) { + animation: none; + opacity: 0.25; + } + &::view-transition-old(memory-overlay-prev), + &::view-transition-old(memory-overlay-next) { + display: none; + } + &::view-transition-new(memory-overlay-prev), + &::view-transition-new(memory-overlay-next) { + animation: none; + } + } + ::view-transition-old(next), ::view-transition-old(next-old), ::view-transition-new(next), @@ -381,6 +562,24 @@ z-index: -1; } + @keyframes fadeFromDim { + from { + opacity: 0.25; + } + to { + opacity: 0; + } + } + + @keyframes dimDown { + from { + opacity: 1; + } + to { + opacity: 0.25; + } + } + @keyframes flyInLeft { from { transform: translateX(calc(-1 * var(--vt-viewer-slide-distance))); @@ -539,36 +738,50 @@ background-color: transparent; } - ::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; + html:active-view-transition-type(viewer-nav) { + &::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; + } } - ::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; + html:active-view-transition-type(memory-enter) { + &::view-transition-group(hero) { + animation-duration: 0s; + } + &::view-transition-old(hero) { + animation: var(--vt-duration-default) fadeOut forwards; + } + &::view-transition-new(hero) { + animation: var(--vt-duration-default) fadeIn forwards; + } } } } diff --git a/web/src/lib/components/memory-page/memory-photo-viewer.svelte b/web/src/lib/components/memory-page/memory-photo-viewer.svelte index e69f31fbd0..b37da23dba 100644 --- a/web/src/lib/components/memory-page/memory-photo-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-photo-viewer.svelte @@ -1,57 +1,32 @@ -{#if !imageLoaded} - - -{/if} - -{#if !imageLoaded} - -{:else if imageLoaded} -
- {$getAltText(asset)} -
-{/if} +
+ {#if containerWidth > 0 && containerHeight > 0} + + {:else} + + {/if} +
diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index c86385b8d9..c1ed2cffdb 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -20,11 +20,14 @@ import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import { QueryParameter } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import { eventManager } from '$lib/managers/event-manager.svelte'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; + import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte'; import { Route } from '$lib/route'; import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte'; import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { preferences } from '$lib/stores/user.store'; @@ -52,6 +55,7 @@ } from '@mdi/js'; import type { NavigationTarget, Page } from '@sveltejs/kit'; import { DateTime } from 'luxon'; + import { tick } from 'svelte'; import { t } from 'svelte-i18n'; import type { Attachment } from 'svelte/attachments'; import { Tween } from 'svelte/motion'; @@ -64,6 +68,7 @@ let paused = $state(false); let current = $state(undefined); const currentAssetId = $derived(current?.asset.id); + const currentAssetDto = $derived(current ? current.memory.assets[current.assetIndex] : undefined); const currentMemoryAssetFull = $derived.by(async () => currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined, ); @@ -76,6 +81,14 @@ let isSaved = $derived(current?.memory.isSaved); let viewerHeight = $state(0); + let transition = $state({ + name: undefined as string | undefined, + previousPanel: undefined as string | undefined, + nextPanel: undefined as string | undefined, + active: false, + }); + const showTransitionOverlays = $derived(transition.active || transition.name === 'hero'); + const showNavButtonOverlay = $derived(transition.name === 'hero'); const { isViewing } = assetViewingStore; const viewport: Viewport = $state({ width: 0, height: 0 }); @@ -86,18 +99,6 @@ let videoPlayer: HTMLVideoElement | undefined = $state(); const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`; - const handleNavigate = async (asset?: { id: string }) => { - if ($isViewing) { - return asset; - } - - if (!asset) { - return; - } - - await goto(asHref(asset)); - }; - const setProgressDuration = (asset: TimelineAsset) => { if (asset.isVideo) { const timeParts = asset.duration!.split(':').map(Number); @@ -112,11 +113,177 @@ } }; - const handleNextAsset = () => handleNavigate(current?.next?.asset); - const handlePreviousAsset = () => handleNavigate(current?.previous?.asset); - const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); - const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); - const handleEscape = async () => goto(Route.photos()); + const scrollToTop = () => { + if (window.scrollY === 0) { + return Promise.resolve(); + } + window.scrollTo({ top: 0, behavior: 'smooth' }); + return new Promise((resolve) => { + const timeout = setTimeout(resolve, 500); + window.addEventListener( + 'scrollend', + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + }); + }; + + const withMemoryTransition = async ( + asset: { id: string } | undefined, + config: Omit[0], 'onFinished'> & { + onFinished?: () => void; + }, + ) => { + if ($isViewing || !asset) { + return; + } + + await scrollToTop(); + + transition.active = true; + viewTransitionManager + .startTransition({ + ...config, + onFinished: () => { + transition.previousPanel = undefined; + transition.nextPanel = undefined; + transition.name = undefined; + transition.active = false; + config.onFinished?.(); + }, + }) + .catch((error: unknown) => console.error('[Memory] transition failed:', error)); + }; + + const navigateWithTransition = (asset?: { id: string }) => + withMemoryTransition(asset, { + types: ['memory-nav'], + prepareOldSnapshot: () => { + transition.name = 'memory-fade-out'; + }, + performUpdate: async () => { + await goto(asHref(asset!)); + await eventManager.untilNext('ViewerOpenTransitionReady'); + }, + prepareNewSnapshot: () => { + transition.name = 'memory-fade-in'; + }, + }); + + const handleNextAsset = () => { + const next = current?.next; + if (next && next.memory.id !== current?.memory.id) { + void navigateToMemory('next', next.asset); + } else { + void navigateWithTransition(next?.asset); + } + }; + const handlePreviousAsset = () => { + const previous = current?.previous; + if (previous && previous.memory.id !== current?.memory.id) { + void navigateToMemory('previous', previous.asset); + } else { + void navigateWithTransition(previous?.asset); + } + }; + const navigateToMemory = (direction: 'next' | 'previous', asset?: { id: string }) => { + const isNext = direction === 'next'; + const useHeroMorph = !mediaQueryManager.reducedMotion; + + return withMemoryTransition(asset, { + types: ['memory'], + prepareOldSnapshot: () => { + if (useHeroMorph) { + if (isNext) { + transition.nextPanel = 'hero'; + transition.previousPanel = 'memory-departing'; + } else { + transition.previousPanel = 'hero'; + transition.nextPanel = 'memory-departing'; + } + transition.name = 'hero-out'; + } else { + transition.name = 'memory-fade-out'; + } + }, + performUpdate: async () => { + transition.nextPanel = undefined; + transition.previousPanel = undefined; + if (useHeroMorph) { + if (isNext) { + transition.previousPanel = 'hero-out'; + } else { + transition.nextPanel = 'hero-out'; + } + } + transition.name = useHeroMorph ? 'hero' : 'memory-fade-in'; + await goto(asHref(asset!)); + await eventManager.untilNext('ViewerOpenTransitionReady'); + }, + }); + }; + + const handleNextMemory = () => void navigateToMemory('next', current?.nextMemory?.assets[0]); + const handlePreviousMemory = () => void navigateToMemory('previous', current?.previousMemory?.assets[0]); + const closeMemoryViewer = () => { + if (current && current.assetIndex > 0 && !mediaQueryManager.reducedMotion) { + const firstAsset = current.memory.assets[0]; + void withMemoryTransition(firstAsset, { + types: ['memory-nav', 'memory-nav-fast'], + prepareOldSnapshot: () => { + transition.name = 'memory-fade-out'; + }, + performUpdate: async () => { + await goto(asHref(firstAsset)); + await eventManager.untilNext('ViewerOpenTransitionReady'); + }, + prepareNewSnapshot: () => { + transition.name = 'memory-fade-in'; + }, + onFinished: () => closeToTimeline(), + }); + } else { + closeToTimeline(); + } + }; + + const closeToTimeline = () => { + const memoryId = current?.memory.id; + let cardImage: HTMLElement | null | undefined; + + void viewTransitionManager.startTransition({ + types: ['memory-enter'], + prepareOldSnapshot: () => { + transition.name = 'hero'; + }, + performUpdate: async () => { + transition.name = undefined; + await goto(Route.photos()); + await tick(); + + const memoryCard = memoryId + ? document.querySelector(`[data-memory-id="${CSS.escape(memoryId)}"]`) + : null; + memoryCard?.scrollIntoView({ behavior: 'instant', inline: 'nearest', block: 'nearest' }); + cardImage = memoryCard?.querySelector('img'); + if (cardImage) { + cardImage.style.viewTransitionName = 'hero'; + await tick(); + } + }, + onFinished: () => { + if (cardImage) { + cardImage.style.viewTransitionName = ''; + cardImage = null; + } + }, + }); + }; + + const handleEscape = closeMemoryViewer; const handleSelectAll = () => assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []); @@ -160,13 +327,17 @@ } }; - const handleProgress = async (progress: number) => { + const handleProgress = (progress: number) => { if (!progressBarController) { return; } - if (progress === 1 && !paused) { - await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause'))); + if (progress === 1 && !paused && !transition.active) { + if (current?.next) { + handleNextAsset(); + } else { + handlePromiseError(handleAction('handleProgressLast', 'pause')); + } } }; @@ -270,7 +441,18 @@ playerInitialized = false; }; - const resetAndPlay = () => { + const resolveTransitionIfPending = () => { + if (viewTransitionManager.activeViewTransition) { + transition.name = 'hero'; + eventManager.emit('ViewerOpenTransitionReady'); + requestAnimationFrame(() => { + transition.name = undefined; + }); + } + }; + + const handleMemoryImageReady = () => { + resolveTransitionIfPending(); handlePromiseError(handleAction('resetAndPlay', 'reset')); handlePromiseError(handleAction('resetAndPlay', 'play')); }; @@ -285,7 +467,7 @@ handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause')); } else if (isVideo) { // Image assets will start playing when the image is loaded. Only autostart video assets. - resetAndPlay(); + handleMemoryImageReady(); } playerInitialized = true; }; @@ -313,7 +495,7 @@ $effect(() => { if (progressBarController) { - handlePromiseError(handleProgress(progressBarController.current)); + handleProgress(progressBarController.current); } }); @@ -382,7 +564,7 @@ bind:clientWidth={viewport.width} > {#if current} - goto(Route.photos())} forceDark multiRow> + {#snippet leading()} {#if current}

@@ -458,7 +640,11 @@ class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden" > -

+
-
-
- {#key current.asset.id} - {#if current.asset.isVideo} - - {:else} - - {/if} - {/key} +
+ {#key current.asset.id} + {#if current.asset.isVideo} + + {:else if currentAssetDto} + + {/if} + {/key} -
-
- handleSaveMemory()} - class="w-12 h-12" - /> - - handlePromiseError(handleAction('ContextMenuClick', 'pause'))} - direction="left" - size="medium" - align="bottom-right" - > - handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} /> - handleDeleteMemoryAsset()} - text={$t('remove_photo_from_memory')} - icon={mdiImageMinusOutline} - /> - - -
- -
- {#await currentMemoryAssetFull then asset} - {#if asset} - - {/if} - {/await} -
+ handlePromiseError(handleAction('ContextMenuClick', 'pause'))} + direction="left" + size="medium" + align="bottom-right" + > + handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} /> + handleDeleteMemoryAsset()} + text={$t('remove_photo_from_memory')} + icon={mdiImageMinusOutline} + /> + +
- + +
+ {#await currentMemoryAssetFull then asset} + {#if asset} + + {/if} + {/await} +
+
+ +
{#if current.previous} -
+
+
{/if} +
-
-

- {fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, { - locale: $locale, - })} -

-

- {#await currentMemoryAssetFull then asset} - {asset?.exifInfo?.city || ''} - {asset?.exifInfo?.country || ''} - {/await} -

-
+
+

+ {fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, { + locale: $locale, + })} +

+

+ {#await currentMemoryAssetFull then asset} + {asset?.exifInfo?.city || ''} + {asset?.exifInfo?.country || ''} + {/await} +

-
+