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

-
-{/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"
>
-
+
{:else}
+
{$t('previous')}
{$memoryLaneTitle(current.previousMemory)}
@@ -492,39 +682,42 @@
-
-
- {#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}
-
+
-
-
- {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}
+
-
+
{:else}
+
{$t('up_next')}
{$memoryLaneTitle(current.nextMemory)}
@@ -680,8 +888,6 @@
diff --git a/web/src/lib/managers/ViewTransitionManager.svelte.spec.ts b/web/src/lib/managers/ViewTransitionManager.svelte.spec.ts
index 245b2ff9f0..f7bffa8097 100644
--- a/web/src/lib/managers/ViewTransitionManager.svelte.spec.ts
+++ b/web/src/lib/managers/ViewTransitionManager.svelte.spec.ts
@@ -48,21 +48,34 @@ describe('ViewTransitionManager', () => {
});
describe('when a transition is already active', () => {
- it('should skip the second transition', async () => {
- let resolveFinished!: () => void;
- const finished = new Promise((resolve) => {
- resolveFinished = resolve;
- });
- let resolveUpdate!: () => void;
- const updateCallbackDone = new Promise((resolve) => {
- resolveUpdate = resolve;
+ it('should skip the first transition and run the second', async () => {
+ let resolveFirstUpdate!: () => void;
+ const firstUpdateCallbackDone = new Promise((resolve) => {
+ resolveFirstUpdate = resolve;
});
+ const firstFinished = new Promise(() => {});
+ const firstSkipTransition = vi.fn();
+ let callCount = 0;
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
+ callCount++;
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise }).update;
void updateFn();
- return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
+ if (callCount === 1) {
+ return {
+ updateCallbackDone: firstUpdateCallbackDone,
+ finished: firstFinished,
+ ready: Promise.resolve(),
+ skipTransition: firstSkipTransition,
+ };
+ }
+ return {
+ updateCallbackDone: Promise.resolve(),
+ finished: Promise.resolve(),
+ ready: Promise.resolve(),
+ skipTransition: vi.fn(),
+ };
});
const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
@@ -75,13 +88,13 @@ describe('ViewTransitionManager', () => {
// Flush microtasks so the first transition reaches the startViewTransition call
await new Promise((r) => queueMicrotask(r));
- // While first is active, try a second — should be skipped
+ // While first is active, start a second — should skip the first and proceed
await manager.startTransition({ performUpdate: secondPerformUpdate });
- expect(secondPerformUpdate).not.toHaveBeenCalled();
+ expect(firstSkipTransition).toHaveBeenCalledOnce();
+ expect(secondPerformUpdate).toHaveBeenCalledOnce();
- // Clean up
- resolveUpdate();
- resolveFinished();
+ // Clean up first promise
+ resolveFirstUpdate();
await firstPromise;
});
});
diff --git a/web/src/lib/managers/ViewTransitionManager.svelte.ts b/web/src/lib/managers/ViewTransitionManager.svelte.ts
index af5f06e4aa..fd8256f29c 100644
--- a/web/src/lib/managers/ViewTransitionManager.svelte.ts
+++ b/web/src/lib/managers/ViewTransitionManager.svelte.ts
@@ -10,6 +10,7 @@ interface TransitionRequest {
export class ViewTransitionManager {
#activeViewTransition = $state(null);
+ #activeOnFinished: (() => void) | undefined;
get activeViewTransition() {
return this.#activeViewTransition;
@@ -23,6 +24,9 @@ export class ViewTransitionManager {
const skipped = !!this.#activeViewTransition;
this.#activeViewTransition?.skipTransition();
this.#activeViewTransition = null;
+ const onFinished = this.#activeOnFinished;
+ this.#activeOnFinished = undefined;
+ onFinished?.();
return skipped;
}
@@ -34,7 +38,7 @@ export class ViewTransitionManager {
onFinished,
}: TransitionRequest) {
if (this.#activeViewTransition) {
- return;
+ this.skipTransitions();
}
if (!this.isSupported()) {
@@ -68,6 +72,7 @@ export class ViewTransitionManager {
}
this.#activeViewTransition = transition;
+ this.#activeOnFinished = onFinished;
// eslint-disable-next-line tscompat/tscompat
void transition.ready.catch((error: unknown) => {
@@ -80,8 +85,11 @@ export class ViewTransitionManager {
void transition.finished
.catch(() => {})
.finally(() => {
- this.#activeViewTransition = null;
- onFinished?.();
+ if (this.#activeViewTransition === transition) {
+ this.#activeViewTransition = null;
+ this.#activeOnFinished = undefined;
+ onFinished?.();
+ }
});
// Wait only until the DOM update completes (both snapshots captured),
diff --git a/web/src/lib/utils/transition-utils.ts b/web/src/lib/utils/transition-utils.ts
index d25921390f..006272378d 100644
--- a/web/src/lib/utils/transition-utils.ts
+++ b/web/src/lib/utils/transition-utils.ts
@@ -2,15 +2,16 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { tick } from 'svelte';
-export function startViewerTransition(
- assetId: string,
+function startHeroTransition(
+ type: string,
+ id: string,
navigate: () => void,
setTransitionId: (id: string | null) => void,
) {
void viewTransitionManager.startTransition({
- types: ['viewer'],
+ types: [type],
prepareOldSnapshot: () => {
- setTransitionId(assetId);
+ setTransitionId(id);
},
performUpdate: async (signal) => {
setTransitionId(null);
@@ -23,6 +24,22 @@ export function startViewerTransition(
});
}
+export function startViewerTransition(
+ assetId: string,
+ navigate: () => void,
+ setTransitionId: (id: string | null) => void,
+) {
+ startHeroTransition('viewer', assetId, navigate, setTransitionId);
+}
+
+export function startMemoryTransition(
+ memoryId: string,
+ navigate: () => void,
+ setTransitionId: (id: string | null) => void,
+) {
+ startHeroTransition('memory-enter', memoryId, navigate, setTransitionId);
+}
+
let activeOverlay: HTMLElement | undefined;
export function removeCrossfadeOverlay() {
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 31a991fa8f..972c81d342 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -1,5 +1,5 @@
@@ -112,7 +123,33 @@
withStacked
>
{#if $preferences.memories.enabled}
-
+ {#snippet memoryCard(item: CarouselImageItem)}
+ {
+ e.preventDefault();
+ handleMemoryCardClick(item);
+ }}
+ style:box-shadow="rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px"
+ >
+
+
+
+ {item.title}
+
+
+ {/snippet}
+
{/if}
{#snippet empty()}
openFileUploadDialog()} class="mt-10 mx-auto" />