feat(web): hero view transitions for memory viewer

Change-Id: I6221557a6b8561122baccbc651a48ae46a6a6964
This commit is contained in:
midzelis
2026-03-19 01:41:07 +00:00
parent e79a98fa82
commit 148f7a7fdb
7 changed files with 679 additions and 210 deletions

View File

@@ -328,8 +328,189 @@
} }
::view-transition-new(hero) { ::view-transition-new(hero) {
animation: none; 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),
::view-transition-old(next-old), ::view-transition-old(next-old),
::view-transition-new(next), ::view-transition-new(next),
@@ -381,6 +562,24 @@
z-index: -1; z-index: -1;
} }
@keyframes fadeFromDim {
from {
opacity: 0.25;
}
to {
opacity: 0;
}
}
@keyframes dimDown {
from {
opacity: 1;
}
to {
opacity: 0.25;
}
}
@keyframes flyInLeft { @keyframes flyInLeft {
from { from {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance))); transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
@@ -539,36 +738,50 @@
background-color: transparent; background-color: transparent;
} }
::view-transition-group(previous), html:active-view-transition-type(viewer-nav) {
::view-transition-group(previous-old), &::view-transition-group(previous),
::view-transition-group(next), &::view-transition-group(previous-old),
::view-transition-group(next-old) { &::view-transition-group(next),
width: 100% !important; &::view-transition-group(next-old) {
height: 100% !important; width: 100% !important;
transform: none !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), html:active-view-transition-type(memory-enter) {
::view-transition-old(previous-old), &::view-transition-group(hero) {
::view-transition-old(next), animation-duration: 0s;
::view-transition-old(next-old) { }
animation: var(--vt-duration-viewer-navigation) fadeOut forwards; &::view-transition-old(hero) {
transform-origin: center; animation: var(--vt-duration-default) fadeOut forwards;
height: 100%; }
width: 100%; &::view-transition-new(hero) {
object-fit: contain; animation: var(--vt-duration-default) fadeIn forwards;
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 File

@@ -1,57 +1,32 @@
<script lang="ts"> <script lang="ts">
import { assetViewerFadeDuration } from '$lib/constants'; import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { Size } from '$lib/utils/container-utils';
import { getAssetMediaUrl } from '$lib/utils'; import type { AssetResponseDto } from '@immich/sdk';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize } from '@immich/sdk';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte'; import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
interface Props { interface Props {
asset: TimelineAsset; asset: AssetResponseDto;
transitionName?: string;
onImageLoad: () => void; onImageLoad: () => void;
onError?: () => void;
} }
const { asset, onImageLoad }: Props = $props(); const { asset, transitionName, onImageLoad, onError }: Props = $props();
let assetFileUrl: string = $state(''); let containerWidth = $state(0);
let imageLoaded: boolean = $state(false); let containerHeight = $state(0);
let loader = $state<HTMLImageElement>();
const onLoadCallback = () => { const container: Size = $derived({ width: containerWidth, height: containerHeight });
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
onImageLoad();
};
onMount(() => {
if (loader?.complete) {
onLoadCallback();
}
loader?.addEventListener('load', onLoadCallback);
return () => {
loader?.removeEventListener('load', onLoadCallback);
};
});
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
</script> </script>
{#if !imageLoaded} <div
<!-- svelte-ignore a11y_missing_attribute --> class="relative h-full w-full overflow-hidden"
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" /> bind:clientWidth={containerWidth}
{/if} bind:clientHeight={containerHeight}
>
{#if !imageLoaded} {#if containerWidth > 0 && containerHeight > 0}
<DelayedLoadingSpinner /> <AdaptiveImage {asset} {container} {transitionName} showLetterboxes={false} onImageReady={onImageLoad} {onError} />
{:else if imageLoaded} {:else}
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full"> <DelayedLoadingSpinner />
<img {/if}
class="h-full w-full rounded-2xl object-contain transition-all" </div>
src={assetFileUrl}
alt={$getAltText(asset)}
draggable="false"
/>
</div>
{/if}

View File

@@ -20,11 +20,14 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { QueryParameter } from '$lib/constants'; import { QueryParameter } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; 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 type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service'; import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; 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 { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
@@ -52,6 +55,7 @@
} from '@mdi/js'; } from '@mdi/js';
import type { NavigationTarget, Page } from '@sveltejs/kit'; import type { NavigationTarget, Page } from '@sveltejs/kit';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { tick } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments'; import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion'; import { Tween } from 'svelte/motion';
@@ -64,6 +68,7 @@
let paused = $state(false); let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined); let current = $state<MemoryAsset | undefined>(undefined);
const currentAssetId = $derived(current?.asset.id); const currentAssetId = $derived(current?.asset.id);
const currentAssetDto = $derived(current ? current.memory.assets[current.assetIndex] : undefined);
const currentMemoryAssetFull = $derived.by(async () => const currentMemoryAssetFull = $derived.by(async () =>
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined, currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
); );
@@ -76,6 +81,14 @@
let isSaved = $derived(current?.memory.isSaved); let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0); 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 { isViewing } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 }); const viewport: Viewport = $state({ width: 0, height: 0 });
@@ -86,18 +99,6 @@
let videoPlayer: HTMLVideoElement | undefined = $state(); let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`; 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) => { const setProgressDuration = (asset: TimelineAsset) => {
if (asset.isVideo) { if (asset.isVideo) {
const timeParts = asset.duration!.split(':').map(Number); const timeParts = asset.duration!.split(':').map(Number);
@@ -112,11 +113,177 @@
} }
}; };
const handleNextAsset = () => handleNavigate(current?.next?.asset); const scrollToTop = () => {
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset); if (window.scrollY === 0) {
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); return Promise.resolve();
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); }
const handleEscape = async () => goto(Route.photos()); window.scrollTo({ top: 0, behavior: 'smooth' });
return new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, 500);
window.addEventListener(
'scrollend',
() => {
clearTimeout(timeout);
resolve();
},
{ once: true },
);
});
};
const withMemoryTransition = async (
asset: { id: string } | undefined,
config: Omit<Parameters<typeof viewTransitionManager.startTransition>[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<HTMLElement>(`[data-memory-id="${CSS.escape(memoryId)}"]`)
: null;
memoryCard?.scrollIntoView({ behavior: 'instant', inline: 'nearest', block: 'nearest' });
cardImage = memoryCard?.querySelector<HTMLElement>('img');
if (cardImage) {
cardImage.style.viewTransitionName = 'hero';
await tick();
}
},
onFinished: () => {
if (cardImage) {
cardImage.style.viewTransitionName = '';
cardImage = null;
}
},
});
};
const handleEscape = closeMemoryViewer;
const handleSelectAll = () => const handleSelectAll = () =>
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []); assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
@@ -160,13 +327,17 @@
} }
}; };
const handleProgress = async (progress: number) => { const handleProgress = (progress: number) => {
if (!progressBarController) { if (!progressBarController) {
return; return;
} }
if (progress === 1 && !paused) { if (progress === 1 && !paused && !transition.active) {
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause'))); if (current?.next) {
handleNextAsset();
} else {
handlePromiseError(handleAction('handleProgressLast', 'pause'));
}
} }
}; };
@@ -270,7 +441,18 @@
playerInitialized = false; 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', 'reset'));
handlePromiseError(handleAction('resetAndPlay', 'play')); handlePromiseError(handleAction('resetAndPlay', 'play'));
}; };
@@ -285,7 +467,7 @@
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause')); handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
} else if (isVideo) { } else if (isVideo) {
// Image assets will start playing when the image is loaded. Only autostart video assets. // Image assets will start playing when the image is loaded. Only autostart video assets.
resetAndPlay(); handleMemoryImageReady();
} }
playerInitialized = true; playerInitialized = true;
}; };
@@ -313,7 +495,7 @@
$effect(() => { $effect(() => {
if (progressBarController) { if (progressBarController) {
handlePromiseError(handleProgress(progressBarController.current)); handleProgress(progressBarController.current);
} }
}); });
@@ -382,7 +564,7 @@
bind:clientWidth={viewport.width} bind:clientWidth={viewport.width}
> >
{#if current} {#if current}
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow> <ControlAppBar onClose={closeMemoryViewer} forceDark multiRow>
{#snippet leading()} {#snippet leading()}
{#if current} {#if current}
<p class="text-lg"> <p class="text-lg">
@@ -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" 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"
> >
<!-- PREVIOUS MEMORY --> <!-- PREVIOUS MEMORY -->
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}"> <div
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.previousMemory
? ''
: 'opacity-0!'}"
>
<button <button
type="button" type="button"
class="relative h-full w-full rounded-2xl" class="relative h-full w-full rounded-2xl"
@@ -471,6 +657,7 @@
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })} src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('previous_memory')} alt={$t('previous_memory')}
draggable="false" draggable="false"
style:view-transition-name={transition.previousPanel}
/> />
{:else} {:else}
<enhanced:img <enhanced:img
@@ -483,7 +670,10 @@
{/if} {/if}
{#if current.previousMemory} {#if current.previousMemory}
<div class="absolute bottom-4 end-4 text-start text-white"> <div
class="absolute bottom-4 end-4 text-start text-white"
style:view-transition-name={transition.active ? 'memory-overlay-prev' : undefined}
>
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p> <p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p> <p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
</div> </div>
@@ -492,39 +682,42 @@
</div> </div>
<!-- CURRENT MEMORY --> <!-- CURRENT MEMORY -->
<div <div class="main-view relative isolate h-full w-[70vw] rounded-2xl bg-black">
class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black" {#key current.asset.id}
> {#if current.asset.isVideo}
<div class="relative h-full w-full rounded-2xl bg-black"> <MemoryVideoViewer
{#key current.asset.id} asset={current.asset}
{#if current.asset.isVideo} bind:videoPlayer
<MemoryVideoViewer videoViewerMuted={$videoViewerMuted}
asset={current.asset} videoViewerVolume={$videoViewerVolume}
bind:videoPlayer />
videoViewerMuted={$videoViewerMuted} {:else if currentAssetDto}
videoViewerVolume={$videoViewerVolume} <MemoryPhotoViewer
/> asset={currentAssetDto}
{:else} transitionName={transition.name}
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} /> onImageLoad={handleMemoryImageReady}
{/if} onError={resolveTransitionIfPending}
{/key} />
{/if}
{/key}
<div <div
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark" class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
class:opacity-0={galleryInView} class:opacity-0={galleryInView}
class:opacity-100={!galleryInView} class:opacity-100={!galleryInView}
> style:view-transition-name={showTransitionOverlays ? 'memory-controls' : undefined}
<div class="flex items-center"> >
<IconButton <div class="flex items-center">
icon={isSaved ? mdiHeart : mdiHeartOutline} <IconButton
shape="round" icon={isSaved ? mdiHeart : mdiHeartOutline}
variant="ghost" shape="round"
color="secondary" variant="ghost"
aria-label={isSaved ? $t('unfavorite') : $t('favorite')} color="secondary"
onclick={() => handleSaveMemory()} aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
class="w-12 h-12" onclick={() => handleSaveMemory()}
/> class="w-12 h-12"
<!-- <IconButton />
<!-- <IconButton
icon={mdiShareVariantOutline} icon={mdiShareVariantOutline}
shape="round" shape="round"
variant="ghost" variant="ghost"
@@ -532,42 +725,46 @@
color="secondary" color="secondary"
aria-label={$t('share')} aria-label={$t('share')}
/> --> /> -->
<ButtonContextMenu <ButtonContextMenu
icon={mdiDotsVertical} icon={mdiDotsVertical}
title={$t('menu')} title={$t('menu')}
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))} onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
direction="left" direction="left"
size="medium" size="medium"
align="bottom-right" align="bottom-right"
> >
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} /> <MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
<MenuOption <MenuOption
onClick={() => handleDeleteMemoryAsset()} onClick={() => handleDeleteMemoryAsset()}
text={$t('remove_photo_from_memory')} text={$t('remove_photo_from_memory')}
icon={mdiImageMinusOutline} icon={mdiImageMinusOutline}
/> />
<!-- shortcut={{ key: 'l', shift: shared }} --> <!-- shortcut={{ key: 'l', shift: shared }} -->
</ButtonContextMenu> </ButtonContextMenu>
</div>
<div>
{#await currentMemoryAssetFull then asset}
{#if asset}
<IconButton
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
/>
{/if}
{/await}
</div>
</div> </div>
<!-- CONTROL BUTTONS -->
<div>
{#await currentMemoryAssetFull then asset}
{#if asset}
<IconButton
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
/>
{/if}
{/await}
</div>
</div>
<!-- CONTROL BUTTONS -->
<div
class="absolute inset-0 pointer-events-none"
style:view-transition-name={showNavButtonOverlay ? 'memory-nav-buttons' : undefined}
>
{#if current.previous} {#if current.previous}
<div class="absolute top-1/2 start-0 ms-4 dark"> <div class="absolute top-1/2 inset-s-0 ms-4 dark pointer-events-auto">
<IconButton <IconButton
shape="round" shape="round"
aria-label={$t('previous_memory')} aria-label={$t('previous_memory')}
@@ -581,7 +778,7 @@
{/if} {/if}
{#if current.next} {#if current.next}
<div class="absolute top-1/2 end-0 me-4 dark"> <div class="absolute top-1/2 inset-e-0 me-4 dark pointer-events-auto">
<IconButton <IconButton
shape="round" shape="round"
aria-label={$t('next_memory')} aria-label={$t('next_memory')}
@@ -593,25 +790,32 @@
/> />
</div> </div>
{/if} {/if}
</div>
<div class="absolute start-8 top-4 text-sm font-medium text-white"> <div
<p> class="absolute start-8 top-4 text-sm font-medium text-white"
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, { style:view-transition-name={showTransitionOverlays ? 'memory-overlay' : undefined}
locale: $locale, >
})} <p>
</p> {fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
<p> locale: $locale,
{#await currentMemoryAssetFull then asset} })}
{asset?.exifInfo?.city || ''} </p>
{asset?.exifInfo?.country || ''} <p>
{/await} {#await currentMemoryAssetFull then asset}
</p> {asset?.exifInfo?.city || ''}
</div> {asset?.exifInfo?.country || ''}
{/await}
</p>
</div> </div>
</div> </div>
<!-- NEXT MEMORY --> <!-- NEXT MEMORY -->
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}"> <div
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.nextMemory
? ''
: 'opacity-0!'}"
>
<button <button
type="button" type="button"
class="relative h-full w-full rounded-2xl" class="relative h-full w-full rounded-2xl"
@@ -624,6 +828,7 @@
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })} src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('next_memory')} alt={$t('next_memory')}
draggable="false" draggable="false"
style:view-transition-name={transition.nextPanel}
/> />
{:else} {:else}
<enhanced:img <enhanced:img
@@ -636,7 +841,10 @@
{/if} {/if}
{#if current.nextMemory} {#if current.nextMemory}
<div class="absolute bottom-4 start-4 text-start text-white"> <div
class="absolute bottom-4 start-4 text-start text-white"
style:view-transition-name={transition.active ? 'memory-overlay-next' : undefined}
>
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p> <p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p> <p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
</div> </div>
@@ -680,8 +888,6 @@
<style> <style>
.main-view { .main-view {
box-shadow: filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
0 4px 4px 0 rgba(0, 0, 0, 0.3),
0 8px 12px 6px rgba(0, 0, 0, 0.15);
} }
</style> </style>

View File

@@ -48,21 +48,34 @@ describe('ViewTransitionManager', () => {
}); });
describe('when a transition is already active', () => { describe('when a transition is already active', () => {
it('should skip the second transition', async () => { it('should skip the first transition and run the second', async () => {
let resolveFinished!: () => void; let resolveFirstUpdate!: () => void;
const finished = new Promise<void>((resolve) => { const firstUpdateCallbackDone = new Promise<void>((resolve) => {
resolveFinished = resolve; resolveFirstUpdate = resolve;
});
let resolveUpdate!: () => void;
const updateCallbackDone = new Promise<void>((resolve) => {
resolveUpdate = resolve;
}); });
const firstFinished = new Promise<void>(() => {});
const firstSkipTransition = vi.fn();
let callCount = 0;
// eslint-disable-next-line tscompat/tscompat // eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => { document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
callCount++;
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update; const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn(); 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); const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
@@ -75,13 +88,13 @@ describe('ViewTransitionManager', () => {
// Flush microtasks so the first transition reaches the startViewTransition call // Flush microtasks so the first transition reaches the startViewTransition call
await new Promise<void>((r) => queueMicrotask(r)); await new Promise<void>((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 }); await manager.startTransition({ performUpdate: secondPerformUpdate });
expect(secondPerformUpdate).not.toHaveBeenCalled(); expect(firstSkipTransition).toHaveBeenCalledOnce();
expect(secondPerformUpdate).toHaveBeenCalledOnce();
// Clean up // Clean up first promise
resolveUpdate(); resolveFirstUpdate();
resolveFinished();
await firstPromise; await firstPromise;
}); });
}); });

View File

@@ -10,6 +10,7 @@ interface TransitionRequest {
export class ViewTransitionManager { export class ViewTransitionManager {
#activeViewTransition = $state<ViewTransition | null>(null); #activeViewTransition = $state<ViewTransition | null>(null);
#activeOnFinished: (() => void) | undefined;
get activeViewTransition() { get activeViewTransition() {
return this.#activeViewTransition; return this.#activeViewTransition;
@@ -23,6 +24,9 @@ export class ViewTransitionManager {
const skipped = !!this.#activeViewTransition; const skipped = !!this.#activeViewTransition;
this.#activeViewTransition?.skipTransition(); this.#activeViewTransition?.skipTransition();
this.#activeViewTransition = null; this.#activeViewTransition = null;
const onFinished = this.#activeOnFinished;
this.#activeOnFinished = undefined;
onFinished?.();
return skipped; return skipped;
} }
@@ -34,7 +38,7 @@ export class ViewTransitionManager {
onFinished, onFinished,
}: TransitionRequest) { }: TransitionRequest) {
if (this.#activeViewTransition) { if (this.#activeViewTransition) {
return; this.skipTransitions();
} }
if (!this.isSupported()) { if (!this.isSupported()) {
@@ -68,6 +72,7 @@ export class ViewTransitionManager {
} }
this.#activeViewTransition = transition; this.#activeViewTransition = transition;
this.#activeOnFinished = onFinished;
// eslint-disable-next-line tscompat/tscompat // eslint-disable-next-line tscompat/tscompat
void transition.ready.catch((error: unknown) => { void transition.ready.catch((error: unknown) => {
@@ -80,8 +85,11 @@ export class ViewTransitionManager {
void transition.finished void transition.finished
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
this.#activeViewTransition = null; if (this.#activeViewTransition === transition) {
onFinished?.(); this.#activeViewTransition = null;
this.#activeOnFinished = undefined;
onFinished?.();
}
}); });
// Wait only until the DOM update completes (both snapshots captured), // Wait only until the DOM update completes (both snapshots captured),

View File

@@ -2,15 +2,16 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte'; import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { tick } from 'svelte'; import { tick } from 'svelte';
export function startViewerTransition( function startHeroTransition(
assetId: string, type: string,
id: string,
navigate: () => void, navigate: () => void,
setTransitionId: (id: string | null) => void, setTransitionId: (id: string | null) => void,
) { ) {
void viewTransitionManager.startTransition({ void viewTransitionManager.startTransition({
types: ['viewer'], types: [type],
prepareOldSnapshot: () => { prepareOldSnapshot: () => {
setTransitionId(assetId); setTransitionId(id);
}, },
performUpdate: async (signal) => { performUpdate: async (signal) => {
setTransitionId(null); 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; let activeOverlay: HTMLElement | undefined;
export function removeCrossfadeOverlay() { export function removeCrossfadeOverlay() {

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte'; import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
@@ -38,8 +38,9 @@
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { startMemoryTransition } from '$lib/utils/transition-utils';
import { AssetVisibility } from '@immich/sdk'; import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui'; import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel, type CarouselImageItem } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js'; import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -99,6 +100,16 @@
src: getAssetMediaUrl({ id: memory.assets[0].id }), src: getAssetMediaUrl({ id: memory.assets[0].id }),
})), })),
); );
let memoryTransitionId = $state<string | null>(null);
const handleMemoryCardClick = (item: CarouselImageItem) => {
startMemoryTransition(
item.id ?? item.href,
() => void goto(item.href),
(id) => (memoryTransitionId = id),
);
};
</script> </script>
<UserPageLayout hideNavbar={assetInteraction.selectionActive} scrollbar={false}> <UserPageLayout hideNavbar={assetInteraction.selectionActive} scrollbar={false}>
@@ -112,7 +123,33 @@
withStacked withStacked
> >
{#if $preferences.memories.enabled} {#if $preferences.memories.enabled}
<ImageCarousel {items} /> {#snippet memoryCard(item: CarouselImageItem)}
<a
class="relative me-2 inline-block aspect-3/4 h-54 rounded-xl last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video"
href={item.href}
data-memory-id={item.id}
onclick={(e) => {
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"
>
<img
class="h-full w-full rounded-xl object-cover"
src={item.src}
alt={item.alt ?? item.title}
draggable="false"
style:view-transition-name={memoryTransitionId === (item.id ?? item.href) ? 'hero' : undefined}
/>
<div
class="absolute inset-s-0 top-0 h-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
></div>
<p class="absolute inset-s-4 bottom-2 text-lg text-white max-md:text-sm">
{item.title}
</p>
</a>
{/snippet}
<ImageCarousel {items} child={memoryCard} />
{/if} {/if}
{#snippet empty()} {#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" /> <EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />