From 168414d1abe87f90bb2b20e168d1942518463dd5 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, next/prev feat: web - view transitions from timeline to viewer, next/prev feat: web - swipe feedback - show image while swiping/dragging left/right feat: web - swipe feedback - show image while swiping/dragging left/right tweak animation - no crossfade by default refactor(web): replace ViewTransitionManager event-driven API with phase-based callbacks Change-Id: Ia52f300a08a725062acc19574b10593e6a6a6964 fix(web): graceful degradation for ViewTransitionManager, rename AssetViewerFree to AssetViewerReady, extract onClick handler Change-Id: I4ad85d43e9922742910748a6487cd41f6a6a6964 Change-Id: Ie9c55914b0e87635e0d9e5889ca0ec3d6a6a6964 Change-Id: I0a37b417ee4c247dcc93d442c976eede6a6a6964 --- .../asset-viewer/face-overlay.e2e-spec.ts | 4 +- .../specs/search/search-gallery.e2e-spec.ts | 10 +- e2e/src/ui/specs/timeline/utils.ts | 8 +- .../asset-viewer/asset-viewer.ui-spec.ts | 273 ++++++++++++ server/Dockerfile | 2 +- web/src/app.css | 391 ++++++++++++++++++ web/src/lib/components/AdaptiveImage.svelte | 32 +- .../asset-viewer/asset-viewer.svelte | 288 +++++++++---- .../editor/transform-tool/crop-area.svelte | 3 + .../face-editor/face-editor.svelte | 7 +- .../asset-viewer/image-panorama-viewer.svelte | 14 +- .../asset-viewer/letterboxes.svelte | 68 +++ .../photo-sphere-viewer-adapter.svelte | 27 +- .../asset-viewer/photo-viewer.svelte | 21 +- .../asset-viewer/video-native-viewer.svelte | 6 +- .../asset-viewer/video-panorama-viewer.svelte | 7 +- .../asset-viewer/video-wrapper-viewer.svelte | 5 +- .../faces-page/person-side-panel.svelte | 1 + .../layouts/user-page-layout.svelte | 16 +- .../gallery-viewer/gallery-viewer.svelte | 114 ++++- .../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 | 252 +++++++++++ .../managers/ViewTransitionManager.svelte.ts | 108 +++++ web/src/lib/managers/app-manager.svelte.ts | 10 + web/src/lib/managers/event-manager.svelte.ts | 96 +++-- .../managers/timeline-manager/utils.svelte.ts | 8 + web/src/lib/stores/asset-viewing.store.ts | 2 + .../lib/utils/base-event-manager.svelte.ts | 30 ++ web/src/lib/utils/focus-util.ts | 2 +- web/src/lib/utils/invocationTracker.ts | 48 +-- web/src/routes/(user)/+layout.svelte | 5 +- web/src/routes/+layout.svelte | 16 +- 37 files changed, 1766 insertions(+), 274 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 diff --git a/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts index c69503cf11..918988c83f 100644 --- a/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts +++ b/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts @@ -148,7 +148,7 @@ test.describe('zoom and face editor interaction', () => { await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]'); + const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]'); await expect(async () => { const transform = await imgLocator.evaluate((element) => { return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform; @@ -252,7 +252,7 @@ test.describe('face overlay via edit faces side panel', () => { await ensureDetailPanelVisible(page); await page.getByLabel('Edit people').click(); - const faceThumbnail = page.locator('section div[role="button"]').first(); + const faceThumbnail = page.getByTestId('face-thumbnail').first(); await expect(faceThumbnail).toBeVisible(); const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3'); 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..aff60df6af 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -164,12 +164,8 @@ export const assetViewerUtils = { }, 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"]`), - ) + .locator(`img[data-testid="preview"][src*="${asset.id}"]`) + .or(page.locator(`video[poster*="${asset.id}"]`)) .waitFor(); }, async expectActiveAssetToBe(page: Page, assetId: string) { 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/server/Dockerfile b/server/Dockerfile index 9cc53c1095..b7015072c7 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -33,7 +33,7 @@ RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web --frozen-lockfile --force install && \ - pnpm --filter @immich/sdk --filter immich-web build + NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @immich/sdk --filter immich-web build FROM builder AS cli diff --git a/web/src/app.css b/web/src/app.css index 1ff3bec99b..1595bc8f30 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -75,6 +75,33 @@ --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; } button:not(:disabled), @@ -176,3 +203,367 @@ @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: cubic-bezier(0.2, 0, 0, 1); + } + ::view-transition-old(hero), + ::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 901aa906b1..5831f15285 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -1,6 +1,7 @@ + -
+
{#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 926383d9c2..06854d161c 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} ($boundingBoxesArray = [peopleWithFaces[index]])} onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 614b1377fb..31a52670cf 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -6,10 +6,11 @@ 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 { appManager } from '$lib/managers/app-manager.svelte'; import type { HeaderButtonActionItem } from '$lib/types'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui'; - import type { Snippet } from 'svelte'; + import { type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; interface Props { @@ -44,12 +45,17 @@ let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden'); let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full'); + let isAssetViewer = $derived(appManager.isAssetViewer);
- {#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..dfd6eb5d20 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,8 +3,11 @@ 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 { startViewerTransition, viewTransitionManager } from '$lib/managers/ViewTransitionManager.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 AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte'; @@ -30,6 +33,7 @@ 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 = { @@ -107,6 +111,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 +390,64 @@ 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 () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + eventManager.emit('TransitionToTimelineReady'); + toGalleryTransitionId = id; + await tick(); + }, + onFinished: () => { + toGalleryTransitionId = null; + focusAsset(id); + }, + }); + }; + + if (viewTransitionManager.isSupported()) { + onMount(() => eventManager.on({ TransitionToTimeline: transitionToGalleryCallback })); + } + + const handleClose = async (asset: { id: string }) => { + const useTransition = viewTransitionManager.isSupported(); + if (useTransition) { + const transitionReady = eventManager.untilNext('TransitionToTimelineReady'); + eventManager.emit('TransitionToTimeline', { 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 +509,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..b265aa37fd 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 @@ -