From e79a98fa8206f1deb13c2a803de240080eee9d38 Mon Sep 17 00:00:00 2001 From: midzelis Date: Mon, 8 Dec 2025 11:36:17 +0000 Subject: [PATCH] feat(web): view transitions from timeline to viewer Change-Id: I0a37b417ee4c247dcc93d442c976eede6a6a6964 --- .../specs/search/search-gallery.e2e-spec.ts | 10 +- e2e/src/ui/specs/timeline/utils.ts | 21 +- .../asset-viewer/asset-viewer.ui-spec.ts | 273 ++++++++++++ web/src/app.css | 396 ++++++++++++++++++ web/src/lib/components/AdaptiveImage.svelte | 23 +- .../asset-viewer/asset-viewer.svelte | 334 ++++++++++----- .../editor/transform-tool/crop-area.svelte | 3 + .../asset-viewer/image-panorama-viewer.svelte | 14 +- .../asset-viewer/letterboxes.svelte | 68 +++ .../photo-sphere-viewer-adapter.svelte | 27 +- .../asset-viewer/photo-viewer.svelte | 26 +- .../asset-viewer/video-native-viewer.svelte | 6 +- .../asset-viewer/video-panorama-viewer.svelte | 7 +- .../asset-viewer/video-wrapper-viewer.svelte | 5 +- .../layouts/user-page-layout.svelte | 16 +- .../gallery-viewer/gallery-viewer.svelte | 124 +++++- .../navigation-bar/navigation-bar.svelte | 6 +- .../components/timeline/AssetLayout.svelte | 28 +- web/src/lib/components/timeline/Month.svelte | 46 +- .../lib/components/timeline/Scrubber.svelte | 5 +- .../lib/components/timeline/Timeline.svelte | 73 ++-- .../timeline/TimelineAssetViewer.svelte | 8 + .../ViewTransitionManager.svelte.spec.ts | 363 ++++++++++++++++ .../managers/ViewTransitionManager.svelte.ts | 94 +++++ web/src/lib/managers/app-manager.svelte.ts | 5 + web/src/lib/managers/event-manager.svelte.ts | 9 + .../managers/timeline-manager/utils.svelte.ts | 8 + web/src/lib/stores/asset-viewing.store.ts | 2 + .../lib/utils/base-event-manager.svelte.ts | 44 ++ web/src/lib/utils/invocationTracker.ts | 48 +-- web/src/lib/utils/transition-utils.ts | 84 ++++ web/src/routes/(user)/+layout.svelte | 5 +- .../[[assetId=id]]/+page.svelte | 21 +- web/src/routes/+layout.svelte | 16 +- 34 files changed, 1979 insertions(+), 239 deletions(-) create mode 100644 e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts create mode 100644 web/src/lib/components/asset-viewer/letterboxes.svelte create mode 100644 web/src/lib/managers/ViewTransitionManager.svelte.spec.ts create mode 100644 web/src/lib/managers/ViewTransitionManager.svelte.ts create mode 100644 web/src/lib/managers/app-manager.svelte.ts create mode 100644 web/src/lib/utils/transition-utils.ts diff --git a/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts b/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts index c3721b1c54..87f809de75 100644 --- a/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts +++ b/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts @@ -6,6 +6,7 @@ import { generateTimelineData, TimelineAssetConfig, TimelineData, + toAssetResponseDto, } from 'src/ui/generators/timeline'; import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network'; import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network'; @@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => { }; test.beforeAll(async () => { + test.fail( + process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1', + 'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1', + ); adminUserId = faker.string.uuid(); testContext.adminId = adminUserId; timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId }); @@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => { await context.route('**/api/search/metadata', async (route, request) => { if (request.method() === 'POST') { - const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id)); + const searchAssets = assets + .slice(0, 5) + .filter((asset) => !changes.assetDeletions.includes(asset.id)) + .map((asset) => toAssetResponseDto(asset)); return route.fulfill({ status: 200, contentType: 'application/json', diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index b7003295cf..c69fc4b270 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -143,7 +143,7 @@ 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); }, async getScrollTop(page: Page) { @@ -163,14 +163,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 = () => diff --git a/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts b/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts new file mode 100644 index 0000000000..1f5bdfdf2e --- /dev/null +++ b/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts @@ -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]); + }); + }); +}); diff --git a/web/src/app.css b/web/src/app.css index 1ff3bec99b..090b8fa392 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -75,6 +75,35 @@ --immich-dark-bg: 10 10 10; --immich-dark-fg: 229 231 235; --immich-dark-gray: 33 33 33; + + /* transitions */ + --immich-split-viewer-nav: enabled; + + /* view transition variables */ + /* Base animation duration for standard transitions (page fades, info panel) */ + --vt-duration-default: 250ms; + /* Duration for hero transitions (thumbnail to full viewer) */ + --vt-duration-hero: 280ms; + /* Duration for next/previous photo navigation */ + --vt-duration-viewer-navigation: 270ms; + /* Duration for slideshow mode transitions */ + --vt-duration-slideshow: 1s; + /* Easing function for slide animations (ease-out) */ + --vt-viewer-slide-easing: cubic-bezier(0.2, 0, 0, 1); + /* How far images slide in/out during navigation (% of viewport) */ + --vt-viewer-slide-distance: 15%; + /* Starting opacity for fly transitions (slide+fade effect) */ + --vt-viewer-opacity-start: 0.1; + /* Maximum blur during fly transitions (currently disabled) */ + --vt-viewer-blur-max: 0px; + + --vt-viewer-next-in: flyInRight; + --vt-viewer-next-out: flyOutLeft; + --vt-viewer-prev-in: flyInLeft; + --vt-viewer-prev-out: flyOutRight; + --vt-viewer-old-opacity: 1; + /* Easing function for memory and hero morph transitions */ + --vt-memory-easing: cubic-bezier(0.2, 0, 0, 1); } button:not(:disabled), @@ -176,3 +205,370 @@ @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; + } + html:active-view-transition-type(slideshow) { + &::view-transition-old(*) { + animation: var(--vt-duration-slideshow) linear crossfadeOut forwards; + } + &::view-transition-new(*) { + animation: var(--vt-duration-slideshow) linear crossfadeIn forwards; + } + &::view-transition-image-pair(*) { + isolation: auto; + } + } + html:active-view-transition-type(viewer-nav) { + &::view-transition-old(root) { + animation: var(--vt-duration-hero) 0s fadeOut forwards; + } + &::view-transition-new(root) { + animation: var(--vt-duration-hero) 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(letterbox-left), + ::view-transition-group(letterbox-right), + ::view-transition-group(letterbox-top), + ::view-transition-group(letterbox-bottom) { + animation-duration: var(--vt-duration-viewer-navigation); + animation-timing-function: var(--vt-viewer-slide-easing); + z-index: 4; + } + + ::view-transition-image-pair(letterbox-left), + ::view-transition-image-pair(letterbox-right), + ::view-transition-image-pair(letterbox-top), + ::view-transition-image-pair(letterbox-bottom) { + isolation: auto; + } + + ::view-transition-old(letterbox-left), + ::view-transition-old(letterbox-right), + ::view-transition-old(letterbox-top), + ::view-transition-old(letterbox-bottom), + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right), + ::view-transition-new(letterbox-top), + ::view-transition-new(letterbox-bottom) { + animation: none; + width: 100%; + height: 100%; + object-fit: fill; + background-color: var(--color-black); + } + + ::view-transition-group(exclude-leftbutton), + ::view-transition-group(exclude-rightbutton), + ::view-transition-group(exclude) { + animation: none; + z-index: 5; + } + ::view-transition-old(exclude-leftbutton), + ::view-transition-old(exclude-rightbutton), + ::view-transition-old(exclude) { + visibility: hidden; + } + ::view-transition-new(exclude-leftbutton), + ::view-transition-new(exclude-rightbutton), + ::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; + } + ::view-transition-old(next), + ::view-transition-old(next-old), + ::view-transition-new(next), + ::view-transition-new(next-new), + ::view-transition-old(previous), + ::view-transition-old(previous-old), + ::view-transition-new(previous), + ::view-transition-new(previous-new) { + animation-duration: var(--vt-duration-viewer-navigation); + animation-timing-function: var(--vt-viewer-slide-easing); + animation-fill-mode: forwards; + } + + ::view-transition-old(next), + ::view-transition-old(next-old), + ::view-transition-old(previous), + ::view-transition-old(previous-old) { + opacity: var(--vt-viewer-old-opacity); + } + + ::view-transition-old(next), + ::view-transition-old(next-old) { + animation-name: var(--vt-viewer-next-out); + } + + ::view-transition-new(next), + ::view-transition-new(next-new) { + animation-name: var(--vt-viewer-next-in); + } + + ::view-transition-old(previous), + ::view-transition-old(previous-old) { + animation-name: var(--vt-viewer-prev-out); + } + + ::view-transition-new(previous), + ::view-transition-new(previous-new) { + animation-name: var(--vt-viewer-prev-in); + } + + ::view-transition-old(next-old), + ::view-transition-new(next-new), + ::view-transition-old(previous-old), + ::view-transition-new(previous-new) { + overflow: hidden; + } + + ::view-transition-old(previous-old) { + z-index: -1; + } + + @keyframes flyInLeft { + from { + transform: translateX(calc(-1 * var(--vt-viewer-slide-distance))); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + to { + opacity: 1; + filter: blur(0); + } + } + + @keyframes flyOutLeft { + from { + opacity: 1; + filter: blur(0); + } + to { + transform: translateX(calc(-1 * var(--vt-viewer-slide-distance))); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + } + + @keyframes flyInRight { + from { + transform: translateX(var(--vt-viewer-slide-distance)); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + to { + opacity: 1; + filter: blur(0); + } + } + + @keyframes flyOutRight { + from { + opacity: 1; + filter: blur(0); + } + to { + transform: translateX(var(--vt-viewer-slide-distance)); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + } + + @keyframes panelSlideInRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } + } + + @keyframes panelSlideOutRight { + from { + transform: translateX(0); + } + to { + transform: translateX(100%); + } + } + + /* cubic fade curves so combined opacity stays close to 1.0 during crossfade */ + @keyframes fadeIn { + from { + opacity: 0; + } + 50% { + opacity: 0.85; + } + to { + opacity: 1; + } + } + @keyframes fadeOut { + from { + opacity: 1; + } + 50% { + opacity: 0.85; + } + to { + opacity: 0; + } + } + + @keyframes crossfadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @keyframes crossfadeOut { + from { + opacity: 1; + } + 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-transition-group(letterbox-left), + ::view-transition-group(letterbox-right), + ::view-transition-group(letterbox-top), + ::view-transition-group(letterbox-bottom) { + z-index: 100; + } + + ::view-transition-old(letterbox-left), + ::view-transition-old(letterbox-right), + ::view-transition-old(letterbox-top), + ::view-transition-old(letterbox-bottom), + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right), + ::view-transition-new(letterbox-top), + ::view-transition-new(letterbox-bottom) { + background-color: transparent; + } + + ::view-transition-group(previous), + ::view-transition-group(previous-old), + ::view-transition-group(next), + ::view-transition-group(next-old) { + width: 100% !important; + height: 100% !important; + transform: none !important; + } + + ::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; + } + } +} diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index b651bff98f..5fd5a7e45e 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -1,6 +1,7 @@ -
+
{@render backdrop?.()} -
+ + +
{#if show.alphaBackground} {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 702eeaa61b..ea0ae8ae79 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,3 +1,9 @@ + + -
+
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])} {:then [data, { default: PhotoSphereViewer }]} - + {:catch} {$t('errors.failed_to_load_asset')} {/await} diff --git a/web/src/lib/components/asset-viewer/letterboxes.svelte b/web/src/lib/components/asset-viewer/letterboxes.svelte new file mode 100644 index 0000000000..10c324cd4f --- /dev/null +++ b/web/src/lib/components/asset-viewer/letterboxes.svelte @@ -0,0 +1,68 @@ + + +{#if shouldShowLetterboxes} + {#each letterboxes as box (box.name)} +
+ {/each} +{/if} diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index d46b5e0dc1..1c0f4bdab6 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -1,7 +1,9 @@ -
+
{#await modules} {:then [PhotoSphereViewer, adapter, videoPlugin]} {#if projectionType === ProjectionType.EQUIRECTANGULAR} - + {:else}
- {#if !hideNavbar} + {#if !hideNavbar && !isAssetViewer} openFileUploadDialog()} /> {/if} + + {#if isAssetViewer} +
+ {/if}
- {#if sidebar} + {#if isAssetViewer} +
+ {:else if sidebar} {@render sidebar()} {:else} {/if} -
+
{@render children?.()}
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 5d1254f8d9..4007ed957f 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -3,10 +3,13 @@ import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import type { Action } from '$lib/components/asset-viewer/actions/action'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; + import { focusAsset } from '$lib/components/timeline/actions/focus-actions'; import { AssetAction } from '$lib/constants'; import Portal from '$lib/elements/Portal.svelte'; + import { eventManager } from '$lib/managers/event-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; + import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte'; import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import { Route } from '$lib/route'; @@ -27,9 +30,11 @@ import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { navigate } from '$lib/utils/navigation'; import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util'; + import { startViewerTransition } from '$lib/utils/transition-utils'; import { AssetVisibility, type AssetResponseDto } from '@immich/sdk'; import { modalManager } from '@immich/ui'; import { debounce } from 'lodash-es'; + import { onMount, tick } from 'svelte'; import { t } from 'svelte-i18n'; type Props = { @@ -65,6 +70,16 @@ }: Props = $props(); let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore; + + $effect(() => { + if ($isViewerOpen) { + document.documentElement.style.overflow = 'hidden'; + } + return () => { + document.documentElement.style.overflow = ''; + }; + }); + const navigationAssets = $derived(viewerAssets ?? assets); const geometry = $derived( @@ -107,6 +122,36 @@ const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0); + const scrollGalleryToAsset = async (assetId: string) => { + const index = assets.findIndex((asset) => asset.id === assetId); + if (index === -1) { + return; + } + const assetTopPage = geometry.getTop(index) + slidingWindowOffset; + const assetBottomPage = assetTopPage + geometry.getHeight(index); + const currentScrollTop = document.scrollingElement?.scrollTop ?? 0; + const visibleTop = currentScrollTop + pageHeaderOffset; + const visibleBottom = currentScrollTop + viewport.height; + + if (assetTopPage >= visibleTop && assetBottomPage <= visibleBottom) { + return; + } + const distanceToAlignTop = Math.abs(assetTopPage - pageHeaderOffset - currentScrollTop); + const distanceToAlignBottom = Math.abs(assetBottomPage - viewport.height - currentScrollTop); + const newScrollTop = + distanceToAlignTop < distanceToAlignBottom ? assetTopPage - pageHeaderOffset : assetBottomPage - viewport.height; + if (document.scrollingElement) { + document.scrollingElement.scrollTop = newScrollTop; + } + updateSlidingWindow(); + await tick(); + }; + + const scrollToAndFocusAsset = async (assetId: string) => { + await scrollGalleryToAsset(assetId); + focusAsset(assetId); + }; + const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true }); let lastIntersectedHeight = 0; @@ -356,6 +401,63 @@ nextAsset: getNextAsset(navigationAssets, $viewingAsset), previousAsset: getPreviousAsset(navigationAssets, $viewingAsset), }); + + let toViewerTransitionId = $state(null); + let toGalleryTransitionId = $state(null); + const transitionTargetId = $derived(toViewerTransitionId ?? toGalleryTransitionId); + + const handleThumbnailClick = (asset: AssetResponseDto, currentAsset: TimelineAsset) => { + if (assetInteraction.selectionActive) { + handleSelectAssets(currentAsset); + return; + } + + const doNavigate = () => void navigateToAsset(asset); + + if (!viewTransitionManager.isSupported()) { + doNavigate(); + return; + } + + startViewerTransition(asset.id, doNavigate, (id) => (toViewerTransitionId = id)); + }; + + const transitionToGalleryCallback = ({ id }: { id: string }) => { + void viewTransitionManager.startTransition({ + types: ['timeline'], + prepareOldSnapshot: () => { + void scrollGalleryToAsset(id); + }, + performUpdate: async () => { + eventManager.emit('ViewerCloseTransitionReady'); + toGalleryTransitionId = id; + await tick(); + }, + onFinished: () => { + toGalleryTransitionId = null; + focusAsset(id); + }, + }); + }; + + if (viewTransitionManager.isSupported()) { + onMount(() => eventManager.on({ ViewerCloseTransition: transitionToGalleryCallback })); + } + + const handleClose = async (asset: { id: string }) => { + const useTransition = viewTransitionManager.isSupported(); + if (useTransition) { + const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady'); + eventManager.emit('ViewerCloseTransition', { id: asset.id }); + await transitionReady; + } + assetViewingStore.showAssetViewer(false); + if (!useTransition) { + await tick(); + await scrollToAndFocusAsset(asset.id); + } + handlePromiseError(navigate({ targetRoute: 'current', assetId: null }, { noScroll: true })); + }; + {@const transitionName = transitionTargetId === asset.id ? 'hero' : undefined} +
{ - if (assetInteraction.selectionActive) { - handleSelectAssets(currentAsset); - return; - } - void navigateToAsset(asset); - }} + onClick={() => handleThumbnailClick(asset, currentAsset)} onSelect={() => handleSelectAssets(currentAsset)} onMouseEvent={() => assetMouseEventHandler(currentAsset)} {showArchiveIcon} @@ -416,10 +519,7 @@ onAction={handleAction} onRandom={handleRandom} onAssetChange={updateCurrentAsset} - onClose={() => { - assetViewingStore.showAssetViewer(false); - handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); - }} + onClose={(asset) => handleClose(asset)} /> {/await} diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 7be4a58131..7f1b4d625b 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -50,7 +50,11 @@ -