Compare commits

...

3 Commits

Author SHA1 Message Date
midzelis
682bbce88e feat(web): nav/slideshow view transitions and crossfade
Change-Id: I0a37b417ee4c247dcc93d442c976eede6a6a6964
2026-04-16 14:32:10 +00:00
midzelis
4957bb15d3 refactor(web): asset-viewer code improvements
Change-Id: Ic8e750f4d6429a374c3a3f98542db8d36a6a6964
2026-04-16 14:32:10 +00:00
midzelis
4f33aed350 feat(web): hero view transitions between timeline and asset viewer
Change-Id: I19e0c7385cc38adbc85177ae9706cff06a6a6964

fix(web): fix e2e test failures for view transitions

Change-Id: Ida64f2d509efce0a85a50b89fd4137276a6a6964
Change-Id: I19e0c7385cc38adbc85177ae9706cff06a6a6964
2026-04-16 14:32:10 +00:00
27 changed files with 1402 additions and 132 deletions

View File

@@ -143,8 +143,9 @@ export const timelineUtils = {
return page.locator('#asset-grid');
},
async waitForTimelineLoad(page: Page) {
await expect(timelineUtils.locator(page)).toBeInViewport();
await page.locator('#asset-grid[data-initialized]').waitFor();
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
await page.locator('#virtual-timeline:not(.invisible)').waitFor();
},
async getScrollTop(page: Page) {
const queryTop = () =>
@@ -163,14 +164,17 @@ export const assetViewerUtils = {
return page.locator('#immich-asset-viewer');
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await page
.locator(
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
)
.or(
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
)
.waitFor();
const imgLocator = page.locator(`[data-viewer-content] img[data-testid="preview"][src*="${asset.id}"]`);
const videoLocator = page.locator(`[data-viewer-content] video[poster*="${asset.id}"]`);
await imgLocator.or(videoLocator).waitFor();
if ((await videoLocator.count()) === 0) {
await expect
.poll(() => imgLocator.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0))
.toBe(true);
}
await expect(page.locator('#immich-asset-viewer')).not.toHaveAttribute('data-navigating');
},
async expectActiveAssetToBe(page: Page, assetId: string) {
const activeElement = () =>

View File

@@ -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]);
});
});
});

15
package-lock.json generated Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "immich-monorepo",
"version": "2.7.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-monorepo",
"version": "2.7.5",
"engines": {
"pnpm": ">=10.0.0"
}
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -75,6 +75,11 @@
--immich-dark-bg: 10 10 10;
--immich-dark-fg: 229 231 235;
--immich-dark-gray: 33 33 33;
/* view transition variables */
--vt-duration-default: 250ms;
--vt-duration-hero: 280ms;
--vt-memory-easing: cubic-bezier(0.2, 0, 0, 1);
}
button:not(:disabled),
@@ -175,3 +180,156 @@
@apply bg-subtle rounded-lg;
}
}
@layer base {
::view-transition {
background: var(--color-black);
animation-duration: var(--vt-duration-default);
}
::view-transition-old(*),
::view-transition-new(*) {
mix-blend-mode: normal;
animation-duration: inherit;
}
::view-transition-old(*) {
animation-name: fadeOut;
animation-fill-mode: forwards;
}
::view-transition-new(*) {
animation-name: fadeIn;
animation-fill-mode: forwards;
}
::view-transition-old(root) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
::view-transition-new(root) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
::view-transition-image-pair(info) {
isolation: auto;
}
::view-transition-old(info) {
animation: var(--vt-duration-default) 0s panelSlideOutRight forwards;
}
::view-transition-new(info) {
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) {
animation: none;
z-index: 5;
}
::view-transition-old(exclude-previousbutton),
::view-transition-old(exclude-nextbutton),
::view-transition-old(exclude) {
visibility: hidden;
}
::view-transition-new(exclude-previousbutton),
::view-transition-new(exclude-nextbutton),
::view-transition-new(exclude) {
animation: none;
z-index: 5;
}
::view-transition-group(hero) {
animation-duration: var(--vt-duration-hero);
animation-timing-function: var(--vt-memory-easing);
}
::view-transition-old(hero) {
animation: none;
display: none;
}
::view-transition-new(hero) {
animation: none;
align-content: center;
}
@keyframes panelSlideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes panelSlideOutRight {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
50% {
opacity: 0.85;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
50% {
opacity: 0.85;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(hero) {
animation-name: none;
}
::view-transition-old(hero) {
animation: none;
display: none;
}
::view-transition-new(hero) {
animation: none;
}
html:active-view-transition-type(viewer) {
&::view-transition-old(hero) {
animation: none;
display: none;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
html:active-view-transition-type(timeline) {
&::view-transition-old(hero) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
}
}

View File

@@ -18,6 +18,8 @@
sharedLink?: SharedLinkResponseDto;
objectFit?: 'contain' | 'cover';
container: Size;
imageClass?: string;
transitionName?: string;
onUrlChange?: (url: string) => void;
onImageReady?: () => void;
onError?: () => void;
@@ -35,6 +37,8 @@
sharedLink,
objectFit = 'contain',
container,
imageClass,
transitionName,
onUrlChange,
onImageReady,
onError,
@@ -152,11 +156,12 @@
{@render backdrop?.()}
<div
class="absolute inset-0 pointer-events-none"
class={['absolute inset-0 pointer-events-none', imageClass]}
style:inset-inline-start={insetInlineStart}
style:top
style:width
style:height
style:view-transition-name={transitionName ?? assetViewerManager.transitionName}
>
{#if show.alphaBackground}
<AlphaBackground />

View File

@@ -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';
@@ -13,6 +19,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { getAssetActions } from '$lib/services/asset.service';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
@@ -24,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,
@@ -37,7 +45,7 @@
import { onDestroy, onMount, untrack } from 'svelte';
import type { SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import { slide } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
@@ -94,6 +102,7 @@
slideshowNavigation,
slideshowState,
slideshowRepeat,
slideshowTransition,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
@@ -107,6 +116,10 @@
let sharedLink = getSharedLink();
let fullscreenElement = $state<Element>();
let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>();
@@ -140,14 +153,46 @@
}
};
const onAssetUpdate = (updatedAsset: AssetResponseDto) => {
if (asset.id === updatedAsset.id) {
cursor = { ...cursor, current: updatedAsset };
}
let detailPanelTransitionName = $state<string | undefined>();
let navigationBarTransitionName = $state<string | undefined>();
let previousButtonTransitionName = $state<string | undefined>();
let nextButtonTransitionName = $state<string | undefined>();
let letterboxTransitionName = $state<string | undefined>();
const activateViewTransitionNames = () => {
detailPanelTransitionName = 'info';
assetViewerManager.transitionName = 'hero';
};
onMount(() => {
syncAssetViewerOpenClass(true);
const unsubAssetViewerEvents = assetViewerManager.on({
ViewerOpenTransition: activateViewTransitionNames,
ViewerCloseTransition: activateViewTransitionNames,
});
const unsubViewTransitionEvents = viewTransitionManager.on({
PrepareOldSnapshot: (types) => {
if (types.includes('timeline')) {
navigationBarTransitionName = 'exclude';
previousButtonTransitionName = 'exclude-previousbutton';
nextButtonTransitionName = 'exclude-nextbutton';
}
},
PrepareNewSnapshot: (types) => {
const isViewer = types.includes('viewer');
navigationBarTransitionName = isViewer ? 'exclude' : undefined;
previousButtonTransitionName = isViewer ? 'exclude-previousbutton' : undefined;
nextButtonTransitionName = isViewer ? 'exclude-nextbutton' : undefined;
},
Finished: () => {
navigationBarTransitionName = undefined;
previousButtonTransitionName = undefined;
nextButtonTransitionName = undefined;
assetViewerManager.transitionName = undefined;
detailPanelTransitionName = undefined;
},
});
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
@@ -157,7 +202,6 @@
handlePromiseError(handleStopSlideshow());
}
});
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
@@ -166,6 +210,8 @@
});
return () => {
unsubAssetViewerEvents();
unsubViewTransitionEvents();
slideshowStateUnsubscribe();
slideshowNavigationUnsubscribe();
};
@@ -191,65 +237,127 @@
assetViewerManager.closeEditor();
};
const getTransitionName = (kind: 'old' | 'new', direction: string | null | undefined) => {
if (direction === 'previous' || direction === 'next') {
return useSplitNavTransitions ? `${direction}-${kind}` : direction;
}
return direction ?? undefined;
};
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) {
navigate = async () => {
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!next) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
next = true;
}
}
return next;
};
types = ['slideshow'];
targetTransition = null;
} else {
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;
}
if (hasNext) {
$restartSlideshowProgress = true;
return;
}
if ($slideshowRepeat && slideshowStartAssetId) {
await assetViewerManager.setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
return;
}
await handleStopSlideshow();
};
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next') => {
let navigating = $state(false);
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
if (slideShowPlaying) {
order = slideShowAscending ? 'previous' : 'next';
} else {
return;
}
}
preloadManager.cancelBeforeNavigation(order);
if (tracker.isActive()) {
return;
}
void tracker.invoke(async () => {
const isShuffle =
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
let hasNext: boolean;
if (isShuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else {
hasNext =
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
}
if ($slideshowState !== SlideshowState.PlaySlideshow) {
return;
}
if (hasNext) {
$restartSlideshowProgress = true;
return;
}
if ($slideshowRepeat && slideshowStartAssetId) {
await assetViewerManager.setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
return;
}
await handleStopSlideshow();
}, $t('error_while_navigating'));
navigating = true;
void tracker
.invoke(() => completeNavigation(order, skipTransition), $t('error_while_navigating'))
.finally(() => (navigating = false));
};
/**
* Slide show mode
*/
let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => {
@@ -274,9 +382,11 @@
const handleStopSlideshow = async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
if (!document.fullscreenElement) {
return;
}
document.body.style.cursor = '';
await document.exitFullscreen();
} catch (error) {
handleError(error, $t('errors.unable_to_exit_fullscreen'));
} finally {
@@ -285,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) => {
@@ -379,14 +503,21 @@
return;
}
if (lastCursor) {
previewStackedAsset = undefined;
ocrManager.showOverlay = false;
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
}
if (!lastCursor) {
} else {
preloadManager.initializePreloads(cursor, sharedLink);
}
lastCursor = cursor;
});
const onAssetUpdate = (update: AssetResponseDto) => {
if (asset.id === update.id) {
cursor = { ...cursor, current: update };
}
};
const viewerKind = $derived.by(() => {
if (previewStackedAsset) {
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
@@ -457,13 +588,16 @@
<section
id="immich-asset-viewer"
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
class="fixed inset-s-0 top-0 z-10 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
data-navigating={navigating || undefined}
use:focusTrap
bind:this={assetViewerHtmlElement}
>
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<div
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
style:view-transition-name={navigationBarTransitionName}
>
<AssetViewerNavBar
{asset}
{album}
@@ -496,16 +630,20 @@
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<div
data-test-id="previous-asset"
class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"
style:view-transition-name={previousButtonTransitionName}
>
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
{/if}
<!-- Asset Viewer -->
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackVideoViewer'}
<VideoViewer
asset={previewStackedAsset!}
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
@@ -569,15 +707,20 @@
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<div
data-test-id="next-asset"
class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"
style:view-transition-name={nextButtonTransitionName}
>
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
{#if showDetailPanel || assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
transition:slide={{ axis: 'x', duration: 150 }}
id="detail-panel"
style:view-transition-name={detailPanelTransitionName}
class={[
'row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light',
showDetailPanel ? 'w-90' : 'w-100',
@@ -592,9 +735,14 @@
</div>
{/if}
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{#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
@@ -607,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}
@@ -630,7 +779,7 @@
{#if isShared && album && assetViewerManager.isShowActivityPanel && authManager.authenticated}
<div
transition:fly={{ duration: 150 }}
transition:slide={{ axis: 'x', duration: 150 }}
id="activity-panel"
class="row-start-1 row-span-5 w-90 md:w-115 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"

View File

@@ -7,6 +7,7 @@
import { Icon } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
interface Props {
asset: AssetResponseDto;
@@ -74,6 +75,8 @@
alt={$getAltText(toTimelineAsset(asset))}
class="h-full select-none transition-transform motion-reduce:transition-none"
style:transform={imageTransform}
onload={() => assetViewerManager.emit('ViewerOpenTransitionReady')}
onerror={() => assetViewerManager.emit('ViewerOpenTransitionReady')}
/>
<div
class={[

View File

@@ -4,8 +4,6 @@
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
type Props = {
asset: AssetResponseDto;
};
@@ -20,7 +18,7 @@
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<div class="flex h-dvh w-dvw select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}

View File

@@ -211,6 +211,7 @@
zoomSpeed: 0.5,
fisheye: false,
});
viewer.addEventListener('ready', () => assetViewerManager.emit('ViewerOpenTransitionReady'), { once: true });
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel is 0-100
@@ -255,7 +256,12 @@
<AssetViewerEvents {onZoom} />
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div>
<div
id="sphere"
class="h-dvh w-dvw mb-0"
bind:this={container}
style:view-transition-name={assetViewerManager.transitionName}
></div>
<style>
/* Reset the default tooltip styling */

View File

@@ -28,14 +28,16 @@
cursor: AssetCursor;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
onReady?: () => void;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
};
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
let { cursor, element = $bindable(), sharedLink, onError, onSwipe }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const objectFit = $derived(
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain',
);
const asset = $derived(cursor.current);
let visibleImageReady: boolean = $state(false);
@@ -227,15 +229,15 @@
{asset}
{sharedLink}
{container}
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
{objectFit}
{onUrlChange}
onImageReady={() => {
visibleImageReady = true;
onReady?.();
assetViewerManager.emit('ViewerOpenTransitionReady');
}}
onError={() => {
onError?.();
onReady?.();
assetViewerManager.emit('ViewerOpenTransitionReady');
}}
bind:imgRef={assetViewerManager.imgRef}
bind:ref={adaptiveImage}

View File

@@ -58,7 +58,6 @@
});
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
hasFocused = false;
videoPlayer?.load();
@@ -139,6 +138,7 @@
</div>
{:else}
<video
style:view-transition-name={assetViewerManager.transitionName}
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
@@ -147,6 +147,7 @@
disablePictureInPicture
class="h-full object-contain"
{...useSwipe(onSwipe)}
onloadedmetadata={() => assetViewerManager.emit('ViewerOpenTransitionReady')}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}

View File

@@ -3,8 +3,6 @@
import type { AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
asset: AssetResponseDto;
}
@@ -19,7 +17,7 @@
]);
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<div class="flex h-full select-none place-content-center place-items-center">
{#await modules}
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}

View File

@@ -3,11 +3,13 @@
</script>
<script lang="ts">
import { page } from '$app/state';
import { useActions, type ActionArray } from '$lib/actions/use-actions';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -48,7 +50,7 @@
<header>
{#if !hideNavbar}
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
<NavigationBar hidden={isAssetViewerRoute(page)} onUploadClick={() => openFileUploadDialog()} />
{/if}
</header>
<div
@@ -64,7 +66,7 @@
<UserSidebar />
{/if}
<main class="relative">
<main class="relative w-full">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
{@render children?.()}
</div>

View File

@@ -10,6 +10,7 @@
import SkipLink from '$lib/elements/SkipLink.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
@@ -27,29 +28,43 @@
onUploadClick?: () => void;
// TODO: remove once this is only used in <AppShellHeader>
noBorder?: boolean;
hidden?: boolean;
};
let { onUploadClick, noBorder = false }: Props = $props();
let { onUploadClick, noBorder = false, hidden = false }: Props = $props();
let viewTransitionName = $state<string | undefined>();
let shouldShowAccountInfoPanel = $state(false);
let shouldShowNotificationPanel = $state(false);
let innerWidth: number = $state(0);
const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0);
onMount(async () => {
try {
await notificationManager.refresh();
} catch (error) {
console.error('Failed to load notifications on mount', error);
}
});
const { Cast } = $derived(getGlobalActions($t));
onMount(() => {
void notificationManager.refresh().catch((error) => console.error('Failed to load notifications on mount', error));
return viewTransitionManager.on({
PrepareOldSnapshot: (types) => {
if (types.includes('viewer')) {
viewTransitionName = 'exclude';
}
},
PrepareNewSnapshot: (types) => {
viewTransitionName = types.includes('timeline') ? 'exclude' : undefined;
},
Finished: () => {
viewTransitionName = undefined;
},
});
});
</script>
<svelte:window bind:innerWidth />
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
<nav
id="dashboard-navbar"
class={['max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm', hidden && 'invisible']}
style:view-transition-name={viewTransitionName}
>
<SkipLink text={$t('skip_to_content')} />
<div
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder

View File

@@ -2,7 +2,6 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
@@ -12,10 +11,11 @@
let { isUploading } = uploadAssetsStore;
type Props = {
heroTransitionAssetId?: string | null;
suspendTransitions?: boolean;
viewerAssets: ViewerAsset[];
width: number;
height: number;
manager: VirtualScrollManager;
thumbnail: Snippet<
[
{
@@ -27,9 +27,17 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
const {
heroTransitionAssetId,
suspendTransitions = false,
viewerAssets,
width,
height,
thumbnail,
customThumbnailLayout,
}: Props = $props();
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const transitionDuration = $derived(suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
</script>
@@ -38,11 +46,13 @@
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
{@const transitionName = heroTransitionAssetId === asset.id ? 'hero' : undefined}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:view-transition-name={transitionName}
style:top={position.top + 'px'}
style:inset-inline-start={position.left + 'px'}
style:width={position.width + 'px'}

View File

@@ -1,19 +1,23 @@
<script lang="ts">
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot, filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import type { Snippet } from 'svelte';
import { onMount, tick, type Snippet } from 'svelte';
type Props = {
toViewerHeroAssetId?: string | null;
thumbnail: Snippet<
[
{
@@ -28,16 +32,16 @@
singleSelect: boolean;
assetInteraction: AssetMultiSelectManager;
timelineMonth: TimelineMonth;
manager: VirtualScrollManager;
onTimelineDaySelect: (timelineDay: TimelineDay, assets: TimelineAsset[]) => void;
};
let {
toViewerHeroAssetId,
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
assetInteraction,
timelineMonth,
manager,
onTimelineDaySelect,
}: Props = $props();
@@ -55,6 +59,32 @@
});
return getDateLocaleString(date);
};
let toTimelineHeroAssetId = $state<string | null>(null);
let heroTransitionAssetId = $derived(toTimelineHeroAssetId ?? toViewerHeroAssetId ?? null);
const handleViewerCloseTransition = ({ id }: { id: string }) => {
const asset = timelineMonth.findAssetById({ id });
if (!asset) {
return;
}
void viewTransitionManager.startTransition({
types: ['timeline'],
performUpdate: async () => {
assetViewerManager.emit('ViewerCloseTransitionReady');
const event = await eventManager.untilNext('TimelineLoaded');
toTimelineHeroAssetId = event.id;
await tick();
},
onFinished: () => {
toTimelineHeroAssetId = null;
focusAsset(asset.id);
},
});
};
if (viewTransitionManager.isSupported()) {
onMount(() => assetViewerManager.on({ ViewerCloseTransition: handleViewerCloseTransition }));
}
</script>
{#each filterIsInOrNearViewport(timelineMonth.timelineDays) as timelineDay, groupIndex (timelineDay.day)}
@@ -99,7 +129,8 @@
</div>
<AssetLayout
{manager}
{heroTransitionAssetId}
suspendTransitions={timelineMonth.timelineManager.suspendTransitions}
viewerAssets={timelineDay.viewerAssets}
height={timelineDay.height}
width={timelineDay.width}

View File

@@ -11,6 +11,7 @@
import { fade, fly } from 'svelte/transition';
interface Props {
invisible: boolean;
/** Offset from the top of the timeline (e.g., for headers) */
timelineTopOffset?: number;
/** Offset from the bottom of the timeline (e.g., for footers) */
@@ -39,6 +40,7 @@
}
let {
invisible = false,
timelineTopOffset = 0,
timelineBottomOffset = 0,
height = 0,
@@ -509,6 +511,7 @@
aria-valuemin={toScrollY(0)}
data-id="scrubber"
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
class:invisible
style:padding-top={PADDING_TOP + 'px'}
style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width

View File

@@ -13,6 +13,8 @@
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { startViewerTransition } from '$lib/utils/transition-utils';
import type { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
@@ -99,6 +101,7 @@
// Overall scroll percentage through the entire timeline (0-1)
let timelineScrollPercent: number = $state(0);
let scrubberWidth = $state(0);
let toViewerHeroAssetId = $state<string | null>(null);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const maxMd = $derived(mediaQueryManager.maxMd);
@@ -207,7 +210,7 @@
timelineManager.viewportWidth = rect.width;
}
}
const scrollTarget = assetViewerManager.gridScrollTarget?.at;
const scrollTarget = getScrollTarget();
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -219,7 +222,7 @@
await tick();
focusAsset(scrollTarget);
}
invisible = false;
invisible = isAssetViewerRoute(page) ? true : false;
};
// note: only modified once in afterNavigate()
@@ -237,10 +240,13 @@
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
});
const getScrollTarget = () => {
return assetViewerManager.gridScrollTarget?.at ?? page.params.assetId ?? null;
};
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
// after successful navigation.
afterNavigate(({ complete }) => {
void complete.finally(() => {
void complete.finally(async () => {
const isAssetViewerPage = isAssetViewerRoute(page);
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
@@ -251,6 +257,12 @@
}
void scrollAfterNavigate();
if (!isAssetViewerPage) {
const scrollTarget = getScrollTarget();
await tick();
eventManager.emit('TimelineLoaded', { id: scrollTarget });
}
});
});
@@ -258,7 +270,7 @@
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
onMount(() => {
if (!enableRouting) {
if (!enableRouting && !isAssetViewerRoute(page)) {
invisible = false;
}
});
@@ -545,7 +557,7 @@
assetInteraction.selectAll = timelineManager.assetCount === assetInteraction.assets.length;
};
const _onClick = (
const defaultThumbnailClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
@@ -557,6 +569,25 @@
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleThumbnailClick = (asset: TimelineAsset, timelineDay: TimelineDay) => {
if (typeof onThumbnailClick === 'function' || isSelectionMode || assetInteraction.selectionActive) {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, defaultThumbnailClick);
} else {
defaultThumbnailClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
}
return;
}
const openViewer = () => void navigate({ targetRoute: 'current', assetId: asset.id });
startViewerTransition(
asset.id,
openViewer,
(id) => (toViewerHeroAssetId = id),
() => (toViewerHeroAssetId = null),
);
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
@@ -587,6 +618,7 @@
{#if timelineManager.months.length > 0}
<Scrubber
{timelineManager}
{invisible}
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight}
@@ -618,6 +650,7 @@
id="asset-grid"
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
data-initialized={timelineManager.isInitialized || undefined}
tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={timelineManager.viewportWidth}
@@ -666,11 +699,11 @@
style:width="100%"
>
<Month
{toViewerHeroAssetId}
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{timelineMonth}
manager={timelineManager}
onTimelineDaySelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
@@ -684,13 +717,7 @@
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
} else {
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
}
}}
onClick={(asset) => handleThumbnailClick(asset, timelineDay)}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);

View File

@@ -5,6 +5,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket';
@@ -97,6 +98,12 @@
};
const handleClose = async (asset: { id: string }) => {
if (viewTransitionManager.isSupported()) {
const transitionReady = assetViewerManager.untilNext('ViewerCloseTransitionReady');
assetViewerManager.emit('ViewerCloseTransition', { id: asset.id });
await transitionReady;
}
invisible = true;
assetViewerManager.gridScrollTarget = { at: asset.id };
await navigate({

View File

@@ -0,0 +1,327 @@
import { ViewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
function mockViewTransition({
updateCallbackDone = Promise.resolve(),
finished = Promise.resolve(),
ready = Promise.resolve(),
skipTransition = vi.fn(),
}: {
updateCallbackDone?: Promise<void>;
finished?: Promise<void>;
ready?: Promise<void>;
skipTransition?: ReturnType<typeof vi.fn>;
} = {}) {
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return { updateCallbackDone, finished, ready, skipTransition };
});
}
describe('ViewTransitionManager', () => {
let manager: ViewTransitionManager;
beforeEach(() => {
manager = new ViewTransitionManager();
});
afterEach(() => {
delete (document as Partial<typeof document> & { startViewTransition?: unknown }).startViewTransition;
});
describe('when View Transition API is not supported', () => {
it('should still call performUpdate', async () => {
const performUpdate = vi.fn().mockResolvedValue(undefined);
await manager.startTransition({ performUpdate });
expect(performUpdate).toHaveBeenCalledOnce();
});
it('should call onFinished after performUpdate', async () => {
const callOrder: string[] = [];
const performUpdate = vi.fn().mockImplementation(() => {
callOrder.push('performUpdate');
});
const onFinished = vi.fn().mockImplementation(() => {
callOrder.push('onFinished');
});
await manager.startTransition({ performUpdate, onFinished });
expect(onFinished).toHaveBeenCalledOnce();
expect(callOrder).toEqual(['performUpdate', 'onFinished']);
});
it('should not call prepareOldSnapshot or prepareNewSnapshot', async () => {
const prepareOldSnapshot = vi.fn();
const prepareNewSnapshot = vi.fn();
const performUpdate = vi.fn().mockResolvedValue(undefined);
await manager.startTransition({ performUpdate, prepareOldSnapshot, prepareNewSnapshot });
expect(prepareOldSnapshot).not.toHaveBeenCalled();
expect(prepareNewSnapshot).not.toHaveBeenCalled();
});
});
describe('when a transition is already active', () => {
it('should skip the first transition and run the second', async () => {
let resolveFirstUpdate!: () => void;
const firstUpdateCallbackDone = new Promise<void>((resolve) => {
resolveFirstUpdate = resolve;
});
const firstFinished = new Promise<void>(() => {});
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<void> }).update;
void updateFn();
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 firstPromise = manager.startTransition({
performUpdate: async () => {},
});
await new Promise<void>((r) => queueMicrotask(r));
// While first is active, start a second — should skip the first and proceed
await manager.startTransition({ performUpdate: secondPerformUpdate });
expect(firstSkipTransition).toHaveBeenCalledOnce();
expect(secondPerformUpdate).toHaveBeenCalledOnce();
resolveFirstUpdate();
await firstPromise;
});
});
describe('skipTransitions', () => {
it('should return false when no transition is active', () => {
expect(manager.skipTransitions()).toBe(false);
});
it('should call skipTransition on the active transition and return true', async () => {
let resolveFinished!: () => void;
const finished = new Promise<void>((resolve) => {
resolveFinished = resolve;
});
let resolveUpdate!: () => void;
const updateCallbackDone = new Promise<void>((resolve) => {
resolveUpdate = resolve;
});
const skipTransition = vi.fn();
mockViewTransition({ updateCallbackDone, finished, skipTransition });
const promise = manager.startTransition({ performUpdate: async () => {} });
await new Promise<void>((r) => queueMicrotask(r));
const skipped = manager.skipTransitions();
expect(skipped).toBe(true);
expect(skipTransition).toHaveBeenCalledOnce();
resolveUpdate();
resolveFinished();
await promise;
});
it('should allow a new transition after skipping', async () => {
let resolveFinished!: () => void;
const finished = new Promise<void>((resolve) => {
resolveFinished = resolve;
});
let resolveUpdate!: () => void;
const updateCallbackDone = new Promise<void>((resolve) => {
resolveUpdate = resolve;
});
mockViewTransition({ updateCallbackDone, finished });
const promise = manager.startTransition({ performUpdate: async () => {} });
await new Promise<void>((r) => queueMicrotask(r));
manager.skipTransitions();
resolveUpdate();
resolveFinished();
await promise;
const secondUpdate = vi.fn().mockResolvedValue(undefined);
mockViewTransition({ updateCallbackDone: Promise.resolve(), finished: Promise.resolve() });
await manager.startTransition({ performUpdate: secondUpdate });
expect(secondUpdate).toHaveBeenCalledOnce();
});
});
describe('error handling', () => {
it('should propagate error from performUpdate when API is not supported', async () => {
const error = new Error('update failed');
const performUpdate = vi.fn().mockRejectedValue(error);
await expect(manager.startTransition({ performUpdate })).rejects.toThrow('update failed');
});
it('should clean up activeViewTransition when performUpdate throws (API supported)', async () => {
const error = new Error('update failed');
let resolveFinished!: () => void;
const finished = new Promise<void>((resolve) => {
resolveFinished = resolve;
});
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
const updateCallbackDone = updateFn();
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
});
await expect(manager.startTransition({ performUpdate: () => Promise.reject(error) })).rejects.toThrow(
'update failed',
);
resolveFinished();
await new Promise<void>((r) => queueMicrotask(r));
const secondUpdate = vi.fn().mockResolvedValue(undefined);
mockViewTransition();
await manager.startTransition({ performUpdate: secondUpdate });
expect(secondUpdate).toHaveBeenCalledOnce();
});
});
describe('fallback path', () => {
it('should fall back to function argument when object argument throws', async () => {
const performUpdate = vi.fn().mockResolvedValue(undefined);
const prepareNewSnapshot = vi.fn();
const finished = Promise.resolve();
const updateCallbackDone = Promise.resolve();
let callCount = 0;
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
callCount++;
if (callCount === 1 && typeof arg !== 'function') {
throw new TypeError('object form not supported');
}
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
});
await manager.startTransition({ performUpdate, prepareNewSnapshot, types: ['test'] });
expect(performUpdate).toHaveBeenCalledOnce();
expect(prepareNewSnapshot).toHaveBeenCalledOnce();
// eslint-disable-next-line tscompat/tscompat
expect(document.startViewTransition).toHaveBeenCalledTimes(2);
});
});
describe('abort signal', () => {
it('should pass an AbortSignal to performUpdate', async () => {
const performUpdate = vi.fn().mockResolvedValue(undefined);
mockViewTransition();
await manager.startTransition({ performUpdate });
expect(performUpdate).toHaveBeenCalledWith(expect.any(AbortSignal));
});
it('should abort the signal when transition.ready rejects', async () => {
let capturedSignal: AbortSignal | undefined;
let resolveUpdate!: () => void;
const updateCallbackDone = new Promise<void>((resolve) => {
resolveUpdate = resolve;
});
const readyError = new Error('Transition was aborted because of timeout in DOM update');
mockViewTransition({
updateCallbackDone,
finished: Promise.reject(readyError),
ready: Promise.reject(readyError),
});
const performUpdate = vi.fn().mockImplementation((signal: AbortSignal) => {
capturedSignal = signal;
return new Promise<void>((resolve) => {
signal.addEventListener('abort', () => resolve(), { once: true });
});
});
const promise = manager.startTransition({ performUpdate });
await new Promise<void>((r) => queueMicrotask(r));
await new Promise<void>((r) => queueMicrotask(r));
expect(capturedSignal?.aborted).toBe(true);
resolveUpdate();
await promise;
});
it('should not abort the signal when transition completes normally', async () => {
let capturedSignal: AbortSignal | undefined;
mockViewTransition();
await manager.startTransition({
performUpdate: (signal) => {
capturedSignal = signal;
return Promise.resolve();
},
});
expect(capturedSignal?.aborted).toBe(false);
});
it('should pass a non-aborted signal in the unsupported fallback path', async () => {
let capturedSignal: AbortSignal | undefined;
await manager.startTransition({
performUpdate: (signal) => {
capturedSignal = signal;
return Promise.resolve();
},
});
expect(capturedSignal).toBeInstanceOf(AbortSignal);
expect(capturedSignal?.aborted).toBe(false);
});
});
describe('isSupported', () => {
it('should return false when startViewTransition is not in document', () => {
expect(manager.isSupported()).toBe(false);
});
it('should return true when startViewTransition is in document', () => {
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn();
expect(manager.isSupported()).toBe(true);
});
});
});

View File

@@ -0,0 +1,102 @@
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import { tick } from 'svelte';
type TransitionEvents = {
PrepareOldSnapshot: [string[]];
PrepareNewSnapshot: [string[]];
Finished: [string[]];
};
interface TransitionRequest {
types?: string[];
prepareOldSnapshot?: () => void;
performUpdate: (signal: AbortSignal) => Promise<void>;
prepareNewSnapshot?: () => void;
onFinished?: () => void;
}
export class ViewTransitionManager extends BaseEventManager<TransitionEvents> {
#activeViewTransition: ViewTransition | null = null;
#activeOnFinished: (() => void) | undefined = undefined;
isSupported() {
return 'startViewTransition' in document;
}
skipTransitions() {
const skipped = !!this.#activeViewTransition;
this.#activeViewTransition?.skipTransition();
this.#activeViewTransition = null;
const onFinished = this.#activeOnFinished;
this.#activeOnFinished = undefined;
onFinished?.();
return skipped;
}
async startTransition({
types,
prepareOldSnapshot,
performUpdate,
prepareNewSnapshot,
onFinished,
}: TransitionRequest) {
if (this.#activeViewTransition) {
this.skipTransitions();
}
const resolvedTypes = types ?? [];
if (!this.isSupported()) {
await performUpdate(AbortSignal.timeout(10_000));
onFinished?.();
return;
}
this.emit('PrepareOldSnapshot', resolvedTypes);
prepareOldSnapshot?.();
await tick();
const abortController = new AbortController();
const update = async () => {
await performUpdate(abortController.signal);
this.emit('PrepareNewSnapshot', resolvedTypes);
prepareNewSnapshot?.();
await tick();
};
let transition: ViewTransition;
try {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition({ update, types });
} catch {
// Fallback: browsers supporting VT Level 1 but not Level 2 (object form with types) will throw
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition(update);
}
this.#activeViewTransition = transition;
this.#activeOnFinished = onFinished;
// eslint-disable-next-line tscompat/tscompat
void transition.ready.catch((error: unknown) => {
abortController.abort(error);
});
// eslint-disable-next-line tscompat/tscompat
void transition.finished
.catch(() => {})
.finally(() => {
if (this.#activeViewTransition === transition) {
this.#activeViewTransition = null;
this.#activeOnFinished = undefined;
this.emit('Finished', resolvedTypes);
onFinished?.();
}
});
// eslint-disable-next-line tscompat/tscompat
await transition.updateCallbackDone;
}
}
export const viewTransitionManager = new ViewTransitionManager();

View File

@@ -23,12 +23,17 @@ export type Events = {
Zoom: [];
ZoomChange: [ZoomImageWheelState];
Copy: [];
ViewerOpenTransitionReady: [];
ViewerOpenTransition: [];
ViewerCloseTransition: [{ id: string }];
ViewerCloseTransitionReady: [];
};
class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state(createDefaultZoomState());
#animationFrameId: number | null = null;
transitionName = $state<string | undefined>();
imgRef = $state<HTMLImageElement | undefined>();
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
#isImageLoading = $derived.by(() => {

View File

@@ -89,6 +89,8 @@ export type Events = {
ReleaseEvent: [ReleaseEvent];
WebsocketConnect: [];
TimelineLoaded: [{ id: string | null }];
};
export const eventManager = new BaseEventManager<Events>();

View File

@@ -43,6 +43,50 @@ export class BaseEventManager<Events extends EventsBase> {
};
}
private once<T extends keyof Events>(event: T, callback: EventCallback<Events, T>) {
const unsubscribe = this.#onEvent(event, (...args: Events[T]) => {
unsubscribe();
return callback(...args);
});
return unsubscribe;
}
untilNext<T extends keyof Events>(
event: T,
{ timeoutMs = 10_000, signal }: { timeoutMs?: number; signal?: AbortSignal } = {},
): Promise<Events[T] extends [] ? void : Events[T][0]> {
type Result = Events[T] extends [] ? void : Events[T][0];
return new Promise<Result>((resolve, reject) => {
let settled = false;
const settle = () => {
if (settled) {
return false;
}
settled = true;
unsubscribe();
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
return true;
};
const unsubscribe = this.once(event, (...args: Events[T]) => {
if (settle()) {
resolve(args[0] as Result);
}
});
const timer = setTimeout(() => {
if (settle()) {
reject(new Error(`untilNext('${String(event)}') timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
const onAbort = () => {
if (settle()) {
resolve(undefined as Result);
}
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
const listeners = this.getListeners(event);
for (const listener of listeners) {

View File

@@ -0,0 +1,85 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { tick } from 'svelte';
export function startViewerTransition(
heroAssetId: string,
openViewer: () => void,
activateHeroAsset: (assetId: string) => void,
deactivateHeroAsset: () => void,
) {
void viewTransitionManager.startTransition({
types: ['viewer'],
prepareOldSnapshot: () => {
activateHeroAsset(heroAssetId);
},
performUpdate: async (signal) => {
deactivateHeroAsset();
const ready = assetViewerManager.untilNext('ViewerOpenTransitionReady', { signal });
openViewer();
await ready;
assetViewerManager.emit('ViewerOpenTransition');
await tick();
},
});
}
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;
}
});
}

View File

@@ -22,7 +22,7 @@
});
</script>
<div class:display-none={assetViewerManager.isViewing}>
<div class:invisible={assetViewerManager.isViewing}>
{@render children?.()}
</div>
<UploadCover />
@@ -31,7 +31,4 @@
:root {
overscroll-behavior: none;
}
.display-none {
display: none;
}
</style>