feat(web): nav/slideshow view transitions and crossfade

Change-Id: I0a37b417ee4c247dcc93d442c976eede6a6a6964
This commit is contained in:
midzelis
2025-12-08 11:36:17 +00:00
parent 4957bb15d3
commit 682bbce88e
4 changed files with 451 additions and 19 deletions
@@ -0,0 +1,273 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { assetViewerUtils } from 'src/ui/specs/timeline/utils';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer', () => {
const rng = new SeededRandom(529);
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const yearMonths: string[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/photos/:id', () => {
test('Navigate to next asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByTestId('next-asset').waitFor();
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByTestId('previous-asset').waitFor();
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate forward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
}
});
test('Navigate backward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
}
});
test('Navigate forward then backward via keyboard', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
// Navigate forward 3 times
for (let i = 1; i <= 3; i++) {
await page.getByTestId('next-asset').waitFor();
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Navigate backward 3 times to return to original
for (let i = 2; i >= 0; i--) {
await page.getByTestId('previous-asset').waitFor();
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Verify we're back at the original asset
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
});
test('Verify no next button on last asset', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await page.goto(`/photos/${lastAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
// Verify next button doesn't exist
await expect(page.getByLabel('View next asset')).toHaveCount(0);
});
test('Verify no previous button on first asset', async ({ page }) => {
const firstAsset = assets[0];
await page.goto(`/photos/${firstAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
// Verify previous button doesn't exist
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
});
test('Delete photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete photo advances to next (2x)', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete last photo advances to prev', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
});
test('Delete last photo advances to prev (2x)', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
});
});
test.describe('/trash/photos/:id', () => {
test('Delete trashed photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete trashed photo advances to next 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete trashed photo advances to prev', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
});
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
});
});
});
+8
View File
@@ -219,6 +219,14 @@
animation: var(--vt-duration-default) 0s panelSlideInRight forwards;
}
::view-transition-group(detail-panel) {
z-index: 1;
}
::view-transition-old(detail-panel),
::view-transition-new(detail-panel) {
animation: none;
}
::view-transition-group(exclude-previousbutton),
::view-transition-group(exclude-nextbutton),
::view-transition-group(exclude) {
@@ -1,3 +1,9 @@
<script module lang="ts">
const useSplitNavTransitions =
typeof document !== 'undefined' &&
getComputedStyle(document.documentElement).getPropertyValue('--immich-split-viewer-nav').trim() === 'enabled';
</script>
<script lang="ts">
import { browser } from '$app/environment';
import { focusTrap } from '$lib/actions/focus-trap';
@@ -25,6 +31,7 @@
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { crossfadeViewerContent, removeCrossfadeOverlay } from '$lib/utils/transition-utils';
import {
AssetTypeEnum,
getAssetInfo,
@@ -95,6 +102,7 @@
slideshowNavigation,
slideshowState,
slideshowRepeat,
slideshowTransition,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
@@ -149,6 +157,7 @@
let navigationBarTransitionName = $state<string | undefined>();
let previousButtonTransitionName = $state<string | undefined>();
let nextButtonTransitionName = $state<string | undefined>();
let letterboxTransitionName = $state<string | undefined>();
const activateViewTransitionNames = () => {
detailPanelTransitionName = 'info';
@@ -228,26 +237,88 @@
assetViewerManager.closeEditor();
};
const completeNavigation = async (order: 'previous' | 'next') => {
preloadManager.cancelBeforeNavigation(order);
const getTransitionName = (kind: 'old' | 'new', direction: string | null | undefined) => {
if (direction === 'previous' || direction === 'next') {
return useSplitNavTransitions ? `${direction}-${kind}` : direction;
}
return direction ?? undefined;
};
let hasNext: boolean;
const clearTransitionNames = () => {
detailPanelTransitionName = undefined;
assetViewerManager.transitionName = undefined;
letterboxTransitionName = undefined;
};
const startTransition = async (
types: string[],
targetTransition: string | null,
navigateFn: () => Promise<boolean>,
) => {
const oldName = getTransitionName('old', targetTransition);
const newName = getTransitionName('new', targetTransition);
let result = false;
await viewTransitionManager.startTransition({
types,
prepareOldSnapshot: () => {
assetViewerManager.transitionName = oldName;
letterboxTransitionName = targetTransition ? `${targetTransition}-old` : undefined;
detailPanelTransitionName = 'detail-panel';
},
performUpdate: async (signal) => {
const ready = eventManager.untilNext('ViewerOpenTransitionReady', { signal });
result = await navigateFn();
await ready;
},
prepareNewSnapshot: () => {
assetViewerManager.transitionName = newName;
letterboxTransitionName = targetTransition ? `${targetTransition}-new` : undefined;
},
onFinished: clearTransitionNames,
});
return result;
};
const completeNavigation = async (order: 'previous' | 'next', skipTransition: boolean) => {
preloadManager.cancelBeforeNavigation(order);
const skipped = viewTransitionManager.skipTransitions();
const canTransition = viewTransitionManager.isSupported() && !skipped && !skipTransition;
let navigate: () => Promise<boolean>;
let types: string[];
let targetTransition: string | null;
if (slideShowPlaying && slideShowShuffle) {
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!next) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
next = true;
navigate = async () => {
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!next) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
next = true;
}
}
}
hasNext = next;
return next;
};
types = ['slideshow'];
targetTransition = null;
} else {
const target = order === 'previous' ? previousAsset : nextAsset;
hasNext = await navigateToAsset(target);
navigate = async () => {
const target = order === 'previous' ? previousAsset : nextAsset;
return navigateToAsset(target);
};
types = slideShowPlaying ? ['slideshow'] : ['viewer-nav'];
targetTransition = slideShowPlaying ? null : order;
}
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition;
const useTransition = canTransition && slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
const hasNext = useTransition ? await startTransition(types, targetTransition, navigate) : await navigate();
if (!slideShowPlaying) {
return;
}
@@ -268,7 +339,7 @@
const tracker = new InvocationTracker();
let navigating = $state(false);
const navigateAsset = (order?: 'previous' | 'next') => {
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
if (!order) {
if (slideShowPlaying) {
order = slideShowAscending ? 'previous' : 'next';
@@ -283,7 +354,7 @@
navigating = true;
void tracker
.invoke(() => completeNavigation(order), $t('error_while_navigating'))
.invoke(() => completeNavigation(order, skipTransition), $t('error_while_navigating'))
.finally(() => (navigating = false));
};
@@ -324,8 +395,22 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
const handleStackedAssetMouseEnter = (stackedAsset: AssetResponseDto) => {
if ((previewStackedAsset ?? cursor.current).id === stackedAsset.id) {
return;
}
assetViewerManager.closeFaceEditMode();
void crossfadeViewerContent(() => {
previewStackedAsset = stackedAsset;
});
};
const handleStackedAssetMouseLeave = () => {
if (!previewStackedAsset) {
return;
}
removeCrossfadeOverlay();
previewStackedAsset = undefined;
};
const handlePreAction = (action: Action) => {
@@ -652,7 +737,12 @@
{#if stack && withStacked && $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
id="stack-slideshow"
class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none"
onmouseleave={handleStackedAssetMouseLeave}
>
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
@@ -665,10 +755,11 @@
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
removeCrossfadeOverlay();
cursor.current = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
onMouseEvent={({ isMouseOver }) => isMouseOver && handleStackedAssetMouseEnter(stackedAsset)}
readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
+60
View File
@@ -23,3 +23,63 @@ export function startViewerTransition(
},
});
}
let activeOverlay: HTMLElement | undefined;
export function removeCrossfadeOverlay() {
if (activeOverlay) {
activeOverlay.remove();
activeOverlay = undefined;
}
}
export async function crossfadeViewerContent(updateFn: () => void | Promise<void>, duration = 200) {
const viewerContent = document.querySelector<HTMLElement>('[data-viewer-content]');
if (!viewerContent) {
await updateFn();
return;
}
removeCrossfadeOverlay();
const clone = viewerContent.cloneNode(true) as HTMLElement;
Object.assign(clone.style, {
position: 'absolute',
inset: '0',
zIndex: '1',
pointerEvents: 'none',
});
delete clone.dataset.viewerContent;
if (!viewerContent.parentElement) {
await updateFn();
return;
}
viewerContent.parentElement.append(clone);
activeOverlay = clone;
const ready = eventManager.untilNext('ViewerOpenTransitionReady');
await updateFn();
try {
await ready;
} catch {
clone.remove();
if (activeOverlay === clone) {
activeOverlay = undefined;
}
return;
}
const fadeOut = clone.animate([{ opacity: 1 }, { opacity: 0 }], {
duration,
easing: 'cubic-bezier(0.4, 0, 1, 1)',
fill: 'forwards',
});
void fadeOut.finished.then(() => {
clone.remove();
if (activeOverlay === clone) {
activeOverlay = undefined;
}
});
}