Compare commits

..

1 Commits

Author SHA1 Message Date
midzelis 137ef8cf51 feat(web): add zoom minimap preview overlay
Change-Id: I51d4defc2ad2b7853ec0a4944684a56f6a6a6964
2026-03-24 15:24:12 +00:00
51 changed files with 874 additions and 3033 deletions
@@ -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[data-testid="preview"]');
const imgLocator = page.getByTestId('preview');
await expect(async () => {
const transform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
@@ -0,0 +1,127 @@
import { expect, type Page, test } from '@playwright/test';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
const zoomIn = async (page: Page) => {
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await page.waitForTimeout(300);
};
const getImageTransform = (page: Page) => {
return page.getByTestId('preview').evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
};
test.describe('zoom minimap', () => {
const fixture = setupAssetViewerFixture(950);
test('minimap is not visible at 1x zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await expect(page.getByTestId('zoom-minimap')).toHaveCount(0);
});
test('minimap appears when zoomed in', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
await expect(page.getByTestId('zoom-minimap')).toBeVisible();
});
test('minimap contains thumbnail image', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const canvas = page.getByTestId('zoom-minimap-canvas');
await expect(canvas).toBeVisible();
const img = canvas.locator('img');
await expect(img).toBeVisible();
await expect(img).toHaveAttribute('src', /thumbnail/);
});
test('viewport rect is visible when zoomed', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const viewport = page.getByTestId('zoom-minimap-viewport');
await expect(viewport).toBeVisible();
const box = await viewport.boundingBox();
expect(box).toBeTruthy();
expect(box!.width).toBeGreaterThan(0);
expect(box!.height).toBeGreaterThan(0);
});
test('clicking minimap pans the image', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const transformBefore = await getImageTransform(page);
const canvas = page.getByTestId('zoom-minimap-canvas');
const canvasBox = await canvas.boundingBox();
expect(canvasBox).toBeTruthy();
// Click near the top-left corner of the minimap
await page.mouse.click(canvasBox!.x + 20, canvasBox!.y + 20);
await page.waitForTimeout(100);
const transformAfter = await getImageTransform(page);
expect(transformAfter).not.toBe(transformBefore);
});
test('zoom slider adjusts zoom level', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const slider = page.getByTestId('zoom-minimap-slider');
await expect(slider).toBeVisible();
const sliderBox = await slider.boundingBox();
expect(sliderBox).toBeTruthy();
const fillBefore = await page.getByTestId('zoom-minimap-slider-fill').evaluate((element) => {
return element.style.width;
});
// Click near the right end of the slider to increase zoom
await page.mouse.click(sliderBox!.x + sliderBox!.width * 0.8, sliderBox!.y + sliderBox!.height / 2);
await page.waitForTimeout(100);
const fillAfter = await page.getByTestId('zoom-minimap-slider-fill').evaluate((element) => {
return element.style.width;
});
expect(fillAfter).not.toBe(fillBefore);
});
test('minimap auto-hides after inactivity', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
await expect(page.getByTestId('zoom-minimap')).toBeVisible();
// Wait for the hide delay (1500ms) plus fade duration
await page.waitForTimeout(2000);
await expect(page.getByTestId('zoom-minimap')).toHaveCount(0);
});
});
@@ -6,7 +6,6 @@ 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';
@@ -31,10 +30,6 @@ 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 });
@@ -49,10 +44,7 @@ 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))
.map((asset) => toAssetResponseDto(asset));
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
return route.fulfill({
status: 200,
contentType: 'application/json',
+9 -12
View File
@@ -143,7 +143,7 @@ export const timelineUtils = {
return page.locator('#asset-grid');
},
async waitForTimelineLoad(page: Page) {
await page.locator('#asset-grid[data-initialized]').waitFor();
await expect(timelineUtils.locator(page)).toBeInViewport();
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
},
async getScrollTop(page: Page) {
@@ -163,17 +163,14 @@ export const assetViewerUtils = {
return page.locator('#immich-asset-viewer');
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
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');
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();
},
async expectActiveAssetToBe(page: Page, assetId: string) {
const activeElement = () =>
@@ -1,273 +0,0 @@
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]);
});
});
});
-2
View File
@@ -1013,7 +1013,6 @@
"editor_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise",
"editor_rotate_right": "Rotate 90° clockwise",
"editor_smart_crop": "Smart crop",
"email": "Email",
"email_notifications": "Email notifications",
"empty_folder": "This folder is empty",
@@ -2161,7 +2160,6 @@
"skip_to_folders": "Skip to folders",
"skip_to_tags": "Skip to tags",
"slideshow": "Slideshow",
"slideshow_ken_burns_effect": "Ken Burns effect",
"slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings",
-8
View File
@@ -824,9 +824,6 @@ importers:
simple-icons:
specifier: ^15.15.0
version: 15.22.0
smartcrop:
specifier: ^2.0.5
version: 2.0.5
socket.io-client:
specifier: ~4.8.0
version: 4.8.3
@@ -10907,9 +10904,6 @@ packages:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
smartcrop@2.0.5:
resolution: {integrity: sha512-aXoHTM8XlC51g96kgZkYxZ2mx09/ibOrIVLiUNOFozV/MHmFSgEr1/5CKVBoFD5vd+re2wSy0xra21CyjRITzA==}
snake-case@3.0.4:
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
@@ -24112,8 +24106,6 @@ snapshots:
smart-buffer@4.2.0: {}
smartcrop@2.0.5: {}
snake-case@3.0.4:
dependencies:
dot-case: 3.0.4
-1
View File
@@ -53,7 +53,6 @@
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
"simple-icons": "^15.15.0",
"smartcrop": "^2.0.5",
"socket.io-client": "~4.8.0",
"svelte-gestures": "^5.2.2",
"svelte-i18n": "^4.0.1",
-609
View File
@@ -75,35 +75,6 @@
--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),
@@ -205,583 +176,3 @@
@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;
}
::view-transition-old(memory-overlay),
::view-transition-old(memory-controls),
::view-transition-new(memory-overlay),
::view-transition-new(memory-controls) {
width: 100%;
height: 100%;
object-fit: none;
object-position: left top;
}
html:active-view-transition-type(memory) {
&::view-transition-group(hero),
&::view-transition-group(hero-out) {
animation-duration: var(--vt-duration-memory);
animation-timing-function: var(--vt-memory-easing);
overflow: hidden;
z-index: 1;
}
&::view-transition-group(memory-overlay),
&::view-transition-group(memory-controls) {
animation: none;
z-index: 5;
}
&::view-transition-group(memory-overlay-prev),
&::view-transition-group(memory-overlay-next) {
animation: none;
z-index: 2;
opacity: 0.25;
}
&::view-transition-image-pair(memory-overlay),
&::view-transition-image-pair(memory-controls) {
isolation: auto;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls) {
animation: 120ms linear fadeOut forwards;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls) {
animation: 200ms linear calc(var(--vt-duration-memory) - 200ms) fadeIn forwards;
opacity: 0;
}
&::view-transition-old(memory-overlay-prev),
&::view-transition-old(memory-overlay-next) {
display: none;
}
&::view-transition-new(memory-overlay-prev),
&::view-transition-new(memory-overlay-next) {
animation: none;
width: 100%;
height: 100%;
object-fit: none;
object-position: left top;
}
&::view-transition-image-pair(hero) {
isolation: auto;
}
&::view-transition-old(hero) {
display: none;
}
&::view-transition-new(hero) {
animation: none;
object-fit: cover;
width: 100%;
height: 100%;
}
&::view-transition-image-pair(hero-out) {
isolation: auto;
}
&::view-transition-old(hero-out) {
display: none;
}
&::view-transition-new(hero-out) {
animation: var(--vt-duration-memory) var(--vt-memory-easing) dimDown forwards;
object-fit: cover;
width: 100%;
height: 100%;
}
&::view-transition-group(memory-departing) {
animation: none;
}
&::view-transition-old(memory-departing) {
animation: calc(var(--vt-duration-memory) * 0.4) linear fadeFromDim forwards;
}
&::view-transition-new(memory-departing) {
animation: none;
visibility: hidden;
}
}
html:active-view-transition-type(memory-enter) {
&::view-transition-group(hero) {
animation-duration: var(--vt-duration-hero);
animation-timing-function: var(--vt-memory-easing);
overflow: hidden;
}
&::view-transition-old(hero),
&::view-transition-new(hero) {
animation: none;
object-fit: cover;
width: 100%;
height: 100%;
}
&::view-transition-group(memory-overlay),
&::view-transition-group(memory-controls),
&::view-transition-group(memory-nav-buttons) {
animation: none;
z-index: 5;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls),
&::view-transition-old(memory-nav-buttons) {
animation: none;
visibility: hidden;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls),
&::view-transition-new(memory-nav-buttons) {
animation: 200ms linear var(--vt-duration-hero) fadeIn forwards;
opacity: 0;
}
}
::view-transition-old(memory-fade-out) {
animation: 500ms linear crossfadeOut forwards;
}
::view-transition-new(memory-fade-in) {
animation: 500ms linear crossfadeIn forwards;
}
html:active-view-transition-type(memory-nav-fast) {
&::view-transition-old(memory-fade-out) {
animation-duration: 250ms;
}
&::view-transition-new(memory-fade-in) {
animation-duration: 250ms;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls) {
animation-duration: 100ms;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls) {
animation: 100ms linear 150ms fadeIn forwards;
opacity: 0;
}
}
html:active-view-transition-type(memory-nav) {
&::view-transition-group(memory-overlay),
&::view-transition-group(memory-controls) {
animation: none;
z-index: 5;
}
&::view-transition-image-pair(memory-overlay),
&::view-transition-image-pair(memory-controls) {
isolation: auto;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls) {
animation: 150ms linear fadeOut forwards;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls) {
animation: 200ms linear 300ms fadeIn forwards;
opacity: 0;
}
&::view-transition-group(memory-overlay-prev),
&::view-transition-group(memory-overlay-next) {
animation: none;
opacity: 0.25;
}
&::view-transition-old(memory-overlay-prev),
&::view-transition-old(memory-overlay-next) {
display: none;
}
&::view-transition-new(memory-overlay-prev),
&::view-transition-new(memory-overlay-next) {
animation: none;
}
}
::view-transition-old(next),
::view-transition-old(next-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 fadeFromDim {
from {
opacity: 0.25;
}
to {
opacity: 0;
}
}
@keyframes dimDown {
from {
opacity: 1;
}
to {
opacity: 0.25;
}
}
@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;
}
html:active-view-transition-type(viewer-nav) {
&::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;
}
}
html:active-view-transition-type(memory-enter) {
&::view-transition-group(hero) {
animation-duration: 0s;
}
&::view-transition-old(hero) {
animation: var(--vt-duration-default) fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) fadeIn forwards;
}
}
}
}
+53 -9
View File
@@ -1,4 +1,5 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import type { ZoomImageWheelState } from '@zoom-image/core';
import { createZoomImageWheel } from '@zoom-image/core';
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
@@ -8,22 +9,66 @@ type TouchEventLike = {
};
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
export const MAX_ZOOM = 10;
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
let zoomInstance = createZoomImageWheel(node, {
maxZoom: MAX_ZOOM,
initialState: assetViewerManager.zoomState,
zoomTarget: options?.zoomTarget,
});
const unsubscribes = [
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
];
let needsResync = false;
const createInstance = () => {
zoomInstance.cleanup();
zoomInstance = createZoomImageWheel(node, {
maxZoom: MAX_ZOOM,
initialState: { ...assetViewerManager.zoomState, enable: true },
zoomTarget: options?.zoomTarget,
});
node.style.overflow = 'visible';
unsubscribeStore?.();
unsubscribeStore = zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state));
needsResync = false;
};
const applyDirectTransform = (state: ZoomImageWheelState) => {
const target = options?.zoomTarget ?? node.querySelector('img');
if (target) {
(target as HTMLElement).style.transformOrigin = '0 0';
(target as HTMLElement).style.transform =
`translate(${state.currentPositionX}px, ${state.currentPositionY}px) scale(${state.currentZoom})`;
needsResync = true;
}
};
const resyncIfNeeded = () => {
if (needsResync) {
createInstance();
}
};
let unsubscribeStore = zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state));
const unsubscribeManager = assetViewerManager.on({
ZoomChange: (state) => zoomInstance.setState(state),
DirectTransform: (state) => applyDirectTransform(state),
ZoomEnabled: (enabled) => {
if (enabled && needsResync) {
createInstance();
} else {
zoomInstance.setState({ enable: enabled });
}
},
});
const controller = new AbortController();
const { signal } = controller;
node.addEventListener('pointerdown', () => assetViewerManager.cancelZoomAnimation(), { capture: true, signal });
node.addEventListener('pointerdown', resyncIfNeeded, { signal });
node.addEventListener('wheel', resyncIfNeeded, { signal });
// Intercept events in capture phase to prevent zoom-image from seeing interactions on
// overlay elements (e.g. OCR text boxes), preserving browser defaults like text selection.
@@ -141,9 +186,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
if (options?.zoomTarget) {
options.zoomTarget.style.willChange = '';
}
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
unsubscribeManager();
unsubscribeStore?.();
zoomInstance.cleanup();
},
};
+2 -21
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { thumbhash } from '$lib/actions/thumbhash';
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import ImageLayer from '$lib/components/ImageLayer.svelte';
@@ -19,10 +18,6 @@
sharedLink?: SharedLinkResponseDto;
objectFit?: 'contain' | 'cover';
container: Size;
showLetterboxes?: boolean;
transitionName?: string | null | undefined;
letterboxTransitionName?: string | undefined;
imageClass?: string;
onUrlChange?: (url: string) => void;
onImageReady?: () => void;
onError?: () => void;
@@ -46,10 +41,6 @@
sharedLink,
objectFit = 'contain',
container,
showLetterboxes = true,
transitionName,
letterboxTransitionName,
imageClass,
onUrlChange,
onImageReady,
onError,
@@ -175,20 +166,10 @@
});
</script>
<div class="relative h-full w-full overflow-hidden" bind:this={ref}>
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
{@render backdrop?.()}
<Letterboxes {letterboxTransitionName} show={showLetterboxes} {scaledDimensions} {container} />
<div
class={['absolute inset-0 pointer-events-none', imageClass]}
style:left
style:top
style:width
style:height
style:view-transition-name={transitionName}
data-transition-name={transitionName}
>
<div class="absolute inset-0 pointer-events-none" style:left style:top style:width style:height>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
@@ -1,9 +1,3 @@
<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';
@@ -19,7 +13,6 @@
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 { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
@@ -34,7 +27,6 @@
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,
@@ -48,7 +40,7 @@
import { onDestroy, onMount, untrack } from 'svelte';
import type { SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fly, slide } from 'svelte/transition';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
@@ -97,14 +89,13 @@
onRandom,
}: Props = $props();
const { setAssetId, invisible } = assetViewingStore;
const { setAssetId } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
slideshowRepeat,
slideshowTransition,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
@@ -118,10 +109,6 @@
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>();
@@ -155,40 +142,35 @@
}
};
let transitionName = $state<string | undefined>('hero');
let letterboxTransitionName = $state<string | undefined>(undefined);
let detailPanelTransitionName = $state<string | undefined>(undefined);
const onAssetUpdate = (updatedAsset: AssetResponseDto) => {
if (asset.id === updatedAsset.id) {
cursor = { ...cursor, current: updatedAsset };
}
};
let unsubscribes: (() => void)[] = [];
onMount(() => {
syncAssetViewerOpenClass(true);
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
}
});
const addInfoTransition = () => {
detailPanelTransitionName = 'info';
transitionName = 'hero';
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
});
return () => {
slideshowStateUnsubscribe();
slideshowNavigationUnsubscribe();
};
unsubscribes.push(
eventManager.on({
ViewerOpenTransition: addInfoTransition,
ViewerCloseTransition: addInfoTransition,
}),
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
}
}),
slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
}),
);
});
onDestroy(() => {
@@ -197,16 +179,10 @@
isFaceEditMode.value = false;
syncAssetViewerOpenClass(false);
preloadManager.destroy();
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
});
const closeViewer = () => {
transitionName = 'hero';
const id = stack?.primaryAssetId ?? asset.id;
onClose?.({ ...asset, id });
onClose?.(asset);
};
const closeEditor = async () => {
@@ -218,143 +194,65 @@
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;
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: () => {
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: () => {
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 = slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
let hasNext: boolean;
if (slideShowPlaying && useTransition) {
hasNext = false;
await crossfadeViewerContent(async () => {
hasNext = await navigate();
}, 1000);
} else if (canTransition && useTransition) {
hasNext = await startTransition(types, targetTransition, navigate);
} else {
hasNext = await navigate();
}
if (!slideShowPlaying) {
return;
}
if (hasNext) {
$restartSlideshowProgress = true;
return;
}
if ($slideshowRepeat && slideshowStartAssetId) {
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
return;
}
await handleStopSlideshow();
};
const tracker = new InvocationTracker();
let navigating = $state(false);
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
const navigateAsset = (order?: 'previous' | 'next') => {
if (!order) {
if (slideShowPlaying) {
order = slideShowAscending ? 'previous' : 'next';
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
} else {
return;
}
}
preloadManager.cancelBeforeNavigation(order);
if (tracker.isActive()) {
return;
}
navigating = true;
void tracker.invoke(
() => completeNavigation(order, skipTransition),
(error: unknown) => handleError(error, $t('error_while_navigating')),
() => {
navigating = false;
eventManager.emit('ViewerAfterNavigate');
},
);
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 setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
return;
}
await handleStopSlideshow();
}, $t('error_while_navigating'));
};
/**
* Slide show mode
*/
let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => {
@@ -379,11 +277,10 @@
const handleStopSlideshow = async () => {
try {
if (!document.fullscreenElement) {
return;
if (document.fullscreenElement) {
document.body.style.cursor = '';
await document.exitFullscreen();
}
document.body.style.cursor = '';
await document.exitFullscreen();
} catch (error) {
handleError(error, $t('errors.unable_to_exit_fullscreen'));
} finally {
@@ -392,24 +289,11 @@
}
};
const handleStackedAssetMouseEnter = (stackedAsset: AssetResponseDto) => {
if ((previewStackedAsset ?? cursor.current).id === stackedAsset.id) {
return;
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
if (isMouseOver) {
isFaceEditMode.value = false;
}
isFaceEditMode.value = false;
void crossfadeViewerContent(() => {
previewStackedAsset = stackedAsset;
});
};
const handleStackSlideshowMouseLeave = () => {
if (!previewStackedAsset) {
return;
}
removeCrossfadeOverlay();
previewStackedAsset = undefined;
};
const handlePreAction = (action: Action) => {
@@ -510,24 +394,15 @@
if (cursor.current.id === lastCursor?.current.id) {
return;
}
if (lastCursor) {
previewStackedAsset = undefined;
ocrManager.showOverlay = false;
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
lastCursor = cursor;
return;
}
preloadManager.initializePreloads(cursor, sharedLink);
if (!lastCursor) {
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';
@@ -598,17 +473,13 @@
<section
id="immich-asset-viewer"
class="fixed inset-s-0 top-0 z-10 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
class:invisible={$invisible}
data-navigating={navigating || undefined}
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
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"
style:view-transition-name="exclude"
>
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{asset}
{album}
@@ -619,7 +490,7 @@
onAction={handleAction}
{onUndoDelete}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? closeViewer : undefined}
onClose={onClose ? () => onClose(asset) : undefined}
{playOriginalVideo}
{setPlayOriginalVideo}
/>
@@ -640,21 +511,16 @@
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
<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="exclude-leftbutton"
>
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<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
{transitionName}
asset={previewStackedAsset!}
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
@@ -667,7 +533,6 @@
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{transitionName}
{asset}
assetId={asset.livePhotoVideoId!}
cacheKey={asset.thumbhash}
@@ -679,20 +544,13 @@
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer {asset} {transitionName} {letterboxTransitionName} />
<ImagePanoramaViewer {asset} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
{transitionName}
{letterboxTransitionName}
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
/>
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{transitionName}
{asset}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
@@ -726,20 +584,15 @@
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
<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="exclude-rightbutton"
>
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
{#if showDetailPanel || assetViewerManager.isShowEditor}
<div
transition:slide={{ axis: 'x', duration: 150 }}
transition:fly={{ 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"
translate="yes"
>
@@ -755,14 +608,9 @@
</div>
{/if}
{#if stack && withStacked && !assetViewerManager.isShowEditor && $slideshowState === SlideshowState.None}
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}
<!-- 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={handleStackSlideshowMouseLeave}
>
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
@@ -775,12 +623,11 @@
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
removeCrossfadeOverlay();
cursor.current = stackedAsset;
previewStackedAsset = undefined;
isFaceEditMode.value = false;
}}
onMouseEvent={({ isMouseOver }) => isMouseOver && handleStackedAssetMouseEnter(stackedAsset)}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
@@ -7,7 +7,6 @@
import { Icon } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { eventManager } from '$lib/managers/event-manager.svelte';
interface Props {
asset: AssetResponseDto;
@@ -75,8 +74,6 @@
alt={$getAltText(toTimelineAsset(asset))}
class="h-full select-none transition-transform motion-reduce:transition-none"
style:transform={imageTransform}
onload={() => eventManager.emit('ViewerOpenTransitionReady')}
onerror={() => eventManager.emit('ViewerOpenTransitionReady')}
/>
<div
class={[
@@ -1,7 +1,7 @@
<script lang="ts">
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { Button, HStack, Icon, IconButton } from '@immich/ui';
import { mdiAutoFix, mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
import { Button, HStack, IconButton } from '@immich/ui';
import { mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
import { t } from 'svelte-i18n';
interface AspectRatioOption {
@@ -137,19 +137,5 @@
<span class="text-sm text-white">{ratio.label}</span>
</HStack>
{/each}
<HStack>
<Button
class="w-14 h-14 m-2"
shape="round"
color="secondary"
variant="outline"
loading={transformManager.isApplyingSmartCrop}
aria-label={$t('editor_smart_crop')}
onclick={() => transformManager.applySmartCrop()}
>
<Icon icon={mdiAutoFix} size="1.75em" />
</Button>
<span class="text-sm text-white">{$t('editor_smart_crop')}</span>
</HStack>
</div>
</div>
@@ -4,14 +4,13 @@
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 = {
transitionName?: string;
letterboxTransitionName?: string;
asset: AssetResponseDto;
};
let { transitionName, letterboxTransitionName, asset }: Props = $props();
let { asset }: Props = $props();
const assetId = $derived(asset.id);
@@ -21,16 +20,11 @@
};
</script>
<div class="flex h-dvh w-dvw select-none place-content-center place-items-center">
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
{transitionName}
{letterboxTransitionName}
panorama={data}
originalPanorama={getAssetUrl({ asset, forceOriginal: true })}
/>
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}
@@ -1,68 +0,0 @@
<script lang="ts">
interface Props {
letterboxTransitionName?: string | undefined;
show?: boolean;
scaledDimensions: {
width: number;
height: number;
};
container: {
width: number;
height: number;
};
}
let { letterboxTransitionName, show = true, scaledDimensions, container }: Props = $props();
const shouldShowLetterboxes = $derived(show && !!letterboxTransitionName);
const letterboxes = $derived.by(() => {
const { width, height } = scaledDimensions;
const horizontalOffset = (container.width - width) / 2;
const verticalOffset = (container.height - height) / 2;
return [
{
name: 'letterbox-left',
width: horizontalOffset + 'px',
height: container.height + 'px',
left: '0px',
top: '0px',
},
{
name: 'letterbox-right',
width: horizontalOffset + 'px',
height: container.height + 'px',
left: container.width - horizontalOffset + 'px',
top: '0px',
},
{
name: 'letterbox-top',
width: width + 'px',
height: verticalOffset + 'px',
left: horizontalOffset + 'px',
top: '0px',
},
{
name: 'letterbox-bottom',
width: width + 'px',
height: verticalOffset + 'px',
left: horizontalOffset + 'px',
top: container.height - verticalOffset + 'px',
},
];
});
</script>
{#if shouldShowLetterboxes}
{#each letterboxes as box (box.name)}
<div
class="absolute"
style:view-transition-name={box.name}
style:left={box.left}
style:top={box.top}
style:width={box.width}
style:height={box.height}
></div>
{/each}
{/if}
@@ -1,9 +1,7 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
@@ -43,8 +41,6 @@
'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text';
type Props = {
transitionName?: string;
letterboxTransitionName?: string;
panorama: string | { source: string };
originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
@@ -52,21 +48,11 @@
navbar?: boolean;
};
let {
transitionName,
letterboxTransitionName,
panorama,
originalPanorama,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
}: Props = $props();
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer;
const fullscreenDimensions = { width: globalThis.innerWidth || 0, height: globalThis.innerHeight || 0 };
let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = [];
@@ -224,7 +210,6 @@
zoomSpeed: 0.5,
fisheye: false,
});
viewer.addEventListener('ready', () => eventManager.emit('ViewerOpenTransitionReady'), { once: true });
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel is 0-100
@@ -269,15 +254,7 @@
<AssetViewerEvents {onZoom} />
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
<div
id="sphere"
class="h-full w-full h-dvh w-dvw mb-0"
bind:this={container}
style:view-transition-name={transitionName}
></div>
<!-- Zero-sized letterboxes for view transitions from/to regular photos -->
<Letterboxes {letterboxTransitionName} scaledDimensions={fullscreenDimensions} container={fullscreenDimensions} />
<div class="h-full w-full mb-0" bind:this={container}></div>
<style>
/* Reset the default tooltip styling */
@@ -5,11 +5,10 @@
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import ZoomMinimap from '$lib/components/asset-viewer/zoom-minimap.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
@@ -18,12 +17,11 @@
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import type { Size } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { KenBurnsAnimation } from '$lib/utils/ken-burns';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import type { AssetCursor } from './asset-viewer.svelte';
@@ -32,33 +30,17 @@
cursor: AssetCursor;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
transitionName?: string;
letterboxTransitionName?: string;
onReady?: () => void;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
};
let {
cursor,
element = $bindable(),
sharedLink,
transitionName,
letterboxTransitionName,
onError,
onSwipe,
}: Props = $props();
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
const { slideshowState, slideshowLook, kenBurnsEffect, slideshowDelay } = slideshowStore;
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
let visibleImageReady: boolean = $state(false);
const kenBurns = new KenBurnsAnimation();
let adaptiveImage = $state<HTMLDivElement | undefined>();
onMount(() =>
eventManager.on({
ViewTransitionOldSnapshotPending: () => kenBurns.freeze(),
}),
);
let previousAssetId: string | undefined;
$effect.pre(() => {
@@ -68,16 +50,13 @@
}
previousAssetId = id;
untrack(() => {
kenBurns.cancel();
assetViewerManager.resetZoomState();
visibleImageReady = false;
$boundingBoxesArray = [];
adaptiveImage?.style.removeProperty('transform');
});
});
onDestroy(() => {
kenBurns.cancel();
$boundingBoxesArray = [];
});
@@ -89,8 +68,6 @@
height: containerHeight,
});
const isCoverMode = $derived($slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover);
let imageDimensions = $state<Size>({ width: 0, height: 0 });
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
@@ -158,6 +135,8 @@
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
);
let adaptiveImage = $state<HTMLDivElement | undefined>();
const faceToNameMap = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string | undefined>();
@@ -176,47 +155,6 @@
const faces = $derived(Array.from(faceToNameMap.keys()));
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
const unassignedFaces = $derived((asset.unassignedFaces ?? []) as Faces[]);
const kenBurnsActive = $derived($slideshowState === SlideshowState.PlaySlideshow && $kenBurnsEffect);
$effect(() => {
if (!kenBurnsActive || !visibleImageReady || !adaptiveImage || !assetViewerManager.imgRef) {
return;
}
assetViewerManager.zoomState = { ...untrack(() => assetViewerManager.zoomState), enable: false };
void kenBurns.startWithSmartCrop(adaptiveImage, {
imgRef: assetViewerManager.imgRef,
faces,
fallbackFaces: unassignedFaces,
contentWidth: overlaySize.width,
contentHeight: overlaySize.height,
containerWidth,
containerHeight,
slideshowLook: $slideshowLook,
isCoverMode,
slideshowDelay: $slideshowDelay,
assetId: asset.id,
});
return () => {
kenBurns.cancel();
if (viewTransitionManager.activeViewTransition === null) {
assetViewerManager.resetZoomState();
}
};
});
$effect(() => {
if (viewTransitionManager.activeViewTransition === null) {
kenBurns.resume();
} else {
kenBurns.pause();
}
});
</script>
<AssetViewerEvents {onCopy} {onZoom} />
@@ -244,23 +182,20 @@
{asset}
{sharedLink}
{container}
objectFit={isCoverMode ? 'cover' : 'contain'}
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
{onUrlChange}
onImageReady={() => {
visibleImageReady = true;
eventManager.emit('ViewerOpenTransitionReady');
onReady?.();
}}
onError={() => {
onError?.();
eventManager.emit('ViewerOpenTransitionReady');
onReady?.();
}}
bind:imgRef={assetViewerManager.imgRef}
bind:imgNaturalSize={imageDimensions}
bind:imgScaledSize={scaledDimensions}
bind:ref={adaptiveImage}
{transitionName}
{letterboxTransitionName}
showLetterboxes={!blurredSlideshow}
>
{#snippet backdrop()}
{#if blurredSlideshow}
@@ -313,6 +248,8 @@
{/snippet}
</AdaptiveImage>
<ZoomMinimap {containerWidth} {containerHeight} {asset} {sharedLink} />
{#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
@@ -3,7 +3,6 @@
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import {
autoPlayVideo,
@@ -20,7 +19,6 @@
import { fade } from 'svelte/transition';
type Props = {
transitionName?: string;
assetId: string;
imageSize: Size;
loopVideo: boolean;
@@ -34,7 +32,6 @@
};
let {
transitionName,
assetId,
imageSize,
loopVideo,
@@ -64,6 +61,7 @@
});
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
hasFocused = false;
videoPlayer?.load();
@@ -144,7 +142,6 @@
</div>
{:else}
<video
style:view-transition-name={transitionName}
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
@@ -153,7 +150,6 @@
disablePictureInPicture
class="h-full object-contain"
{...useSwipe(onSwipe)}
onloadedmetadata={() => eventManager.emit('ViewerOpenTransitionReady')}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
@@ -3,13 +3,13 @@
import type { AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
transitionName?: string;
asset: AssetResponseDto;
}
const { asset, transitionName }: Props = $props();
const { asset }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
@@ -19,12 +19,11 @@
]);
</script>
<div class="flex h-full select-none place-content-center place-items-center">
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await modules}
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer
{transitionName}
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
plugins={[videoPlugin]}
@@ -5,7 +5,6 @@
import type { AssetResponseDto } from '@immich/sdk';
type Props = {
transitionName?: string;
asset: AssetResponseDto;
assetId?: string;
projectionType: string | null | undefined;
@@ -20,7 +19,6 @@
};
let {
transitionName,
asset,
assetId,
projectionType,
@@ -38,10 +36,9 @@
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {transitionName} {asset} />
<VideoPanoramaViewer {asset} />
{:else}
<VideoNativeViewer
{transitionName}
{loopVideo}
{cacheKey}
assetId={effectiveAssetId}
@@ -0,0 +1,272 @@
<script lang="ts">
import { MAX_ZOOM } from '$lib/actions/zoom-image';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetUrls } from '$lib/utils';
import { scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { TUNABLES } from '$lib/utils/tunables';
import { clamp } from 'lodash-es';
import { fade } from 'svelte/transition';
interface Props {
containerWidth: number;
containerHeight: number;
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
}
let { containerWidth, containerHeight, asset, sharedLink }: Props = $props();
const MINIMAP_MAX = 192;
const MINIMAP_MIN = 100;
const minimapSize = $derived(clamp(Math.min(containerWidth, containerHeight) * 0.25, MINIMAP_MIN, MINIMAP_MAX));
const thumbnailUrl = $derived(getAssetUrls(asset, sharedLink).thumbnail);
const imageDimensions = $derived({
width: asset.width && asset.width > 0 ? asset.width : 1,
height: asset.height && asset.height > 0 ? asset.height : 1,
});
const container = $derived({ width: containerWidth, height: containerHeight });
// Scale the full container into the minimap square
const containerInMinimap = $derived(scaleToFit(container, { width: minimapSize, height: minimapSize }));
const minimapContainerScale = $derived(containerInMinimap.width / containerWidth);
const containerOffsetX = $derived((minimapSize - containerInMinimap.width) / 2);
const containerOffsetY = $derived((minimapSize - containerInMinimap.height) / 2);
// Position the image within the minimap's container representation
const imageInMinimap: ContentMetrics = $derived.by(() => {
const fitted = scaleToFit(imageDimensions, containerInMinimap);
return {
contentWidth: fitted.width,
contentHeight: fitted.height,
offsetX: containerOffsetX + (containerInMinimap.width - fitted.width) / 2,
offsetY: containerOffsetY + (containerInMinimap.height - fitted.height) / 2,
};
});
const { FADE_DURATION, HIDE_DELAY } = TUNABLES.MINIMAP;
let isDragging = $state(false);
let isDraggingZoom = $state(false);
let isRecentActivity = $state(false);
let hideTimer: ReturnType<typeof setTimeout> | null = null;
const resetHideTimer = () => {
isRecentActivity = true;
if (hideTimer !== null) {
clearTimeout(hideTimer);
}
hideTimer = setTimeout(() => {
isRecentActivity = false;
hideTimer = null;
}, HIDE_DELAY);
};
const isZoomed = $derived(assetViewerManager.zoom > 1);
const isVisible = $derived((isZoomed && isRecentActivity) || isDragging || isDraggingZoom);
$effect(() => {
// Track zoom state changes to reset the hide timer
const _state = assetViewerManager.zoomState;
void _state;
if (isZoomed) {
resetHideTimer();
}
return () => {
if (hideTimer !== null) {
clearTimeout(hideTimer);
hideTimer = null;
}
};
});
const zoomPercent = $derived(((assetViewerManager.zoom - 1) / (MAX_ZOOM - 1)) * 100);
const zoomLabel = $derived(assetViewerManager.zoom.toFixed(1) + 'x');
const clampPanPosition = (positionX: number, positionY: number, zoom: number) => ({
positionX: clamp(positionX, -(containerWidth * (zoom - 1)), 0),
positionY: clamp(positionY, -(containerHeight * (zoom - 1)), 0),
});
const minimapToContainerPosition = (minimapX: number, minimapY: number) => {
const containerX = (minimapX - containerOffsetX) / minimapContainerScale;
const containerY = (minimapY - containerOffsetY) / minimapContainerScale;
const { currentZoom } = assetViewerManager.zoomState;
const rawPositionX = containerWidth / 2 - containerX * currentZoom;
const rawPositionY = containerHeight / 2 - containerY * currentZoom;
return clampPanPosition(rawPositionX, rawPositionY, currentZoom);
};
const panToMinimapPosition = (event: PointerEvent) => {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const minimapX = event.clientX - rect.left;
const minimapY = event.clientY - rect.top;
const { positionX, positionY } = minimapToContainerPosition(minimapX, minimapY);
assetViewerManager.directTransform({ currentPositionX: positionX, currentPositionY: positionY });
};
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
isDragging = true;
assetViewerManager.setZoomEnabled(false);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
panToMinimapPosition(event);
event.preventDefault();
event.stopPropagation();
};
const onPointerMove = (event: PointerEvent) => {
if (!isDragging) {
return;
}
panToMinimapPosition(event);
};
const onPointerUp = () => {
isDragging = false;
assetViewerManager.setZoomEnabled(true);
};
const zoomAroundCenter = (newZoom: number) => {
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const centerX = containerWidth / 2;
const centerY = containerHeight / 2;
const zoomTargetX = (centerX - currentPositionX) / currentZoom;
const zoomTargetY = (centerY - currentPositionY) / currentZoom;
const newPositionX = -zoomTargetX * newZoom + centerX;
const newPositionY = -zoomTargetY * newZoom + centerY;
assetViewerManager.directTransform({
currentZoom: newZoom,
currentPositionX: clamp(newPositionX, -(containerWidth * (newZoom - 1)), 0),
currentPositionY: clamp(newPositionY, -(containerHeight * (newZoom - 1)), 0),
});
};
const setZoomFromSlider = (event: PointerEvent) => {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const percent = clamp((event.clientX - rect.left) / rect.width, 0, 1);
zoomAroundCenter(1 + percent * (MAX_ZOOM - 1));
};
const WHEEL_ZOOM_RATIO = 0.1;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const { currentZoom } = assetViewerManager.zoomState;
const delta = -clamp(event.deltaY, -0.5, 0.5);
const newZoom = clamp(currentZoom + delta * WHEEL_ZOOM_RATIO * currentZoom, 1, MAX_ZOOM);
zoomAroundCenter(newZoom);
};
const onZoomSliderPointerDown = (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
isDraggingZoom = true;
assetViewerManager.setZoomEnabled(false);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
setZoomFromSlider(event);
event.preventDefault();
event.stopPropagation();
};
const onZoomSliderPointerMove = (event: PointerEvent) => {
if (!isDraggingZoom) {
return;
}
setZoomFromSlider(event);
};
const onZoomSliderPointerUp = () => {
isDraggingZoom = false;
assetViewerManager.setZoomEnabled(true);
};
const viewportRect = $derived.by(() => {
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
// Visible area in container coordinates
const visibleLeft = -currentPositionX / currentZoom;
const visibleTop = -currentPositionY / currentZoom;
const visibleWidth = containerWidth / currentZoom;
const visibleHeight = containerHeight / currentZoom;
// Map to minimap coordinates
return {
left: visibleLeft * minimapContainerScale + containerOffsetX,
top: visibleTop * minimapContainerScale + containerOffsetY,
width: visibleWidth * minimapContainerScale,
height: visibleHeight * minimapContainerScale,
};
});
</script>
{#if isVisible}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-[68px] right-14 md:right-4 z-10 rounded-lg border border-white/30 bg-black/60 p-1 backdrop-blur-sm"
data-testid="zoom-minimap"
transition:fade={{ duration: FADE_DURATION }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative overflow-hidden rounded bg-black"
class:cursor-grabbing={isDragging}
class:cursor-pointer={!isDragging}
data-testid="zoom-minimap-canvas"
style="width: {minimapSize}px; height: {minimapSize}px;"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
onwheel={onWheel}
>
<img
src={thumbnailUrl}
alt=""
class="absolute pointer-events-none"
draggable="false"
style="left: {imageInMinimap.offsetX}px; top: {imageInMinimap.offsetY}px; width: {imageInMinimap.contentWidth}px; height: {imageInMinimap.contentHeight}px;"
/>
<div
class={[
'absolute border-2 border-white bg-white/20 pointer-events-none rounded-sm',
isDragging && 'border-white/80',
]}
data-testid="zoom-minimap-viewport"
style="left: {viewportRect.left}px; top: {viewportRect.top}px; width: {viewportRect.width}px; height: {viewportRect.height}px;"
></div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative mt-1 h-3 rounded-full bg-white/20 cursor-pointer"
class:cursor-grabbing={isDraggingZoom}
data-testid="zoom-minimap-slider"
style="width: {minimapSize}px;"
onpointerdown={onZoomSliderPointerDown}
onpointermove={onZoomSliderPointerMove}
onpointerup={onZoomSliderPointerUp}
onpointercancel={onZoomSliderPointerUp}
>
<div
class="absolute top-0 left-0 h-full rounded-full bg-white/80 pointer-events-none"
data-testid="zoom-minimap-slider-fill"
style="width: {zoomPercent}%;"
></div>
<span
class="absolute inset-0 flex items-center justify-center text-[9px] font-semibold pointer-events-none select-none leading-none"
style="color: #000; text-shadow: 0 0 3px rgba(255,255,255,0.8);"
>
{zoomLabel}
</span>
</div>
</div>
{/if}
@@ -6,11 +6,10 @@
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 {
@@ -45,17 +44,12 @@
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);
</script>
<header>
{#if !hideNavbar && !isAssetViewer}
{#if !hideNavbar}
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
{/if}
{#if isAssetViewer}
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
{/if}
</header>
<div
tabindex="-1"
@@ -64,15 +58,13 @@
{hideNavbar ? 'pt-(--navbar-height)' : ''}
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
>
{#if isAssetViewer}
<div></div>
{:else if sidebar}
{#if sidebar}
{@render sidebar()}
{:else}
<UserSidebar />
{/if}
<main class="relative w-full">
<main class="relative">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
{@render children?.()}
</div>
@@ -1,32 +1,57 @@
<script lang="ts">
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import type { Size } from '$lib/utils/container-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { assetViewerFadeDuration } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize } from '@immich/sdk';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
interface Props {
asset: AssetResponseDto;
transitionName?: string;
asset: TimelineAsset;
onImageLoad: () => void;
onError?: () => void;
}
const { asset, transitionName, onImageLoad, onError }: Props = $props();
const { asset, onImageLoad }: Props = $props();
let containerWidth = $state(0);
let containerHeight = $state(0);
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let loader = $state<HTMLImageElement>();
const container: Size = $derived({ width: containerWidth, height: containerHeight });
const onLoadCallback = () => {
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
onImageLoad();
};
onMount(() => {
if (loader?.complete) {
onLoadCallback();
}
loader?.addEventListener('load', onLoadCallback);
return () => {
loader?.removeEventListener('load', onLoadCallback);
};
});
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
</script>
<div
class="relative h-full w-full overflow-hidden"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
{#if containerWidth > 0 && containerHeight > 0}
<AdaptiveImage {asset} {container} {transitionName} showLetterboxes={false} onImageReady={onImageLoad} {onError} />
{:else}
<DelayedLoadingSpinner />
{/if}
</div>
{#if !imageLoaded}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
{/if}
{#if !imageLoaded}
<DelayedLoadingSpinner />
{:else if imageLoaded}
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
<img
class="h-full w-full rounded-2xl object-contain transition-all"
src={assetFileUrl}
alt={$getAltText(asset)}
draggable="false"
/>
</div>
{/if}
@@ -20,14 +20,11 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { QueryParameter } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
@@ -55,7 +52,6 @@
} from '@mdi/js';
import type { NavigationTarget, Page } from '@sveltejs/kit';
import { DateTime } from 'luxon';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion';
@@ -68,7 +64,6 @@
let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined);
const currentAssetId = $derived(current?.asset.id);
const currentAssetDto = $derived(current ? current.memory.assets[current.assetIndex] : undefined);
const currentMemoryAssetFull = $derived.by(async () =>
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
);
@@ -81,14 +76,6 @@
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
let transition = $state({
name: undefined as string | undefined,
previousPanel: undefined as string | undefined,
nextPanel: undefined as string | undefined,
active: false,
});
const showTransitionOverlays = $derived(transition.active || transition.name === 'hero');
const showNavButtonOverlay = $derived(transition.name === 'hero');
const { isViewing } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
@@ -99,6 +86,18 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: { id: string }) => {
if ($isViewing) {
return asset;
}
if (!asset) {
return;
}
await goto(asHref(asset));
};
const setProgressDuration = (asset: TimelineAsset) => {
if (asset.isVideo) {
const timeParts = asset.duration!.split(':').map(Number);
@@ -113,177 +112,11 @@
}
};
const scrollToTop = () => {
if (window.scrollY === 0) {
return Promise.resolve();
}
window.scrollTo({ top: 0, behavior: 'smooth' });
return new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, 500);
window.addEventListener(
'scrollend',
() => {
clearTimeout(timeout);
resolve();
},
{ once: true },
);
});
};
const withMemoryTransition = async (
asset: { id: string } | undefined,
config: Omit<Parameters<typeof viewTransitionManager.startTransition>[0], 'onFinished'> & {
onFinished?: () => void;
},
) => {
if ($isViewing || !asset) {
return;
}
await scrollToTop();
transition.active = true;
viewTransitionManager
.startTransition({
...config,
onFinished: () => {
transition.previousPanel = undefined;
transition.nextPanel = undefined;
transition.name = undefined;
transition.active = false;
config.onFinished?.();
},
})
.catch((error: unknown) => console.error('[Memory] transition failed:', error));
};
const navigateWithTransition = (asset?: { id: string }) =>
withMemoryTransition(asset, {
types: ['memory-nav'],
prepareOldSnapshot: () => {
transition.name = 'memory-fade-out';
},
performUpdate: async () => {
await goto(asHref(asset!));
await eventManager.untilNext('ViewerOpenTransitionReady');
},
prepareNewSnapshot: () => {
transition.name = 'memory-fade-in';
},
});
const handleNextAsset = () => {
const next = current?.next;
if (next && next.memory.id !== current?.memory.id) {
void navigateToMemory('next', next.asset);
} else {
void navigateWithTransition(next?.asset);
}
};
const handlePreviousAsset = () => {
const previous = current?.previous;
if (previous && previous.memory.id !== current?.memory.id) {
void navigateToMemory('previous', previous.asset);
} else {
void navigateWithTransition(previous?.asset);
}
};
const navigateToMemory = (direction: 'next' | 'previous', asset?: { id: string }) => {
const isNext = direction === 'next';
const useHeroMorph = !mediaQueryManager.reducedMotion;
return withMemoryTransition(asset, {
types: ['memory'],
prepareOldSnapshot: () => {
if (useHeroMorph) {
if (isNext) {
transition.nextPanel = 'hero';
transition.previousPanel = 'memory-departing';
} else {
transition.previousPanel = 'hero';
transition.nextPanel = 'memory-departing';
}
transition.name = 'hero-out';
} else {
transition.name = 'memory-fade-out';
}
},
performUpdate: async () => {
transition.nextPanel = undefined;
transition.previousPanel = undefined;
if (useHeroMorph) {
if (isNext) {
transition.previousPanel = 'hero-out';
} else {
transition.nextPanel = 'hero-out';
}
}
transition.name = useHeroMorph ? 'hero' : 'memory-fade-in';
await goto(asHref(asset!));
await eventManager.untilNext('ViewerOpenTransitionReady');
},
});
};
const handleNextMemory = () => void navigateToMemory('next', current?.nextMemory?.assets[0]);
const handlePreviousMemory = () => void navigateToMemory('previous', current?.previousMemory?.assets[0]);
const closeMemoryViewer = () => {
if (current && current.assetIndex > 0 && !mediaQueryManager.reducedMotion) {
const firstAsset = current.memory.assets[0];
void withMemoryTransition(firstAsset, {
types: ['memory-nav', 'memory-nav-fast'],
prepareOldSnapshot: () => {
transition.name = 'memory-fade-out';
},
performUpdate: async () => {
await goto(asHref(firstAsset));
await eventManager.untilNext('ViewerOpenTransitionReady');
},
prepareNewSnapshot: () => {
transition.name = 'memory-fade-in';
},
onFinished: () => closeToTimeline(),
});
} else {
closeToTimeline();
}
};
const closeToTimeline = () => {
const memoryId = current?.memory.id;
let cardImage: HTMLElement | null | undefined;
void viewTransitionManager.startTransition({
types: ['memory-enter'],
prepareOldSnapshot: () => {
transition.name = 'hero';
},
performUpdate: async () => {
transition.name = undefined;
await goto(Route.photos());
await tick();
const memoryCard = memoryId
? document.querySelector<HTMLElement>(`[data-memory-id="${CSS.escape(memoryId)}"]`)
: null;
memoryCard?.scrollIntoView({ behavior: 'instant', inline: 'nearest', block: 'nearest' });
cardImage = memoryCard?.querySelector<HTMLElement>('img');
if (cardImage) {
cardImage.style.viewTransitionName = 'hero';
await tick();
}
},
onFinished: () => {
if (cardImage) {
cardImage.style.viewTransitionName = '';
cardImage = null;
}
},
});
};
const handleEscape = closeMemoryViewer;
const handleNextAsset = () => handleNavigate(current?.next?.asset);
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
const handleEscape = async () => goto(Route.photos());
const handleSelectAll = () =>
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
@@ -327,17 +160,13 @@
}
};
const handleProgress = (progress: number) => {
const handleProgress = async (progress: number) => {
if (!progressBarController) {
return;
}
if (progress === 1 && !paused && !transition.active) {
if (current?.next) {
handleNextAsset();
} else {
handlePromiseError(handleAction('handleProgressLast', 'pause'));
}
if (progress === 1 && !paused) {
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
}
};
@@ -441,18 +270,7 @@
playerInitialized = false;
};
const resolveTransitionIfPending = () => {
if (viewTransitionManager.activeViewTransition) {
transition.name = 'hero';
eventManager.emit('ViewerOpenTransitionReady');
requestAnimationFrame(() => {
transition.name = undefined;
});
}
};
const handleMemoryImageReady = () => {
resolveTransitionIfPending();
const resetAndPlay = () => {
handlePromiseError(handleAction('resetAndPlay', 'reset'));
handlePromiseError(handleAction('resetAndPlay', 'play'));
};
@@ -467,7 +285,7 @@
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
} else if (isVideo) {
// Image assets will start playing when the image is loaded. Only autostart video assets.
handleMemoryImageReady();
resetAndPlay();
}
playerInitialized = true;
};
@@ -495,7 +313,7 @@
$effect(() => {
if (progressBarController) {
handleProgress(progressBarController.current);
handlePromiseError(handleProgress(progressBarController.current));
}
});
@@ -564,7 +382,7 @@
bind:clientWidth={viewport.width}
>
{#if current}
<ControlAppBar onClose={closeMemoryViewer} forceDark multiRow>
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
{#snippet leading()}
{#if current}
<p class="text-lg">
@@ -640,11 +458,7 @@
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
>
<!-- PREVIOUS MEMORY -->
<div
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.previousMemory
? ''
: 'opacity-0!'}"
>
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
<button
type="button"
class="relative h-full w-full rounded-2xl"
@@ -657,7 +471,6 @@
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('previous_memory')}
draggable="false"
style:view-transition-name={transition.previousPanel}
/>
{:else}
<enhanced:img
@@ -670,10 +483,7 @@
{/if}
{#if current.previousMemory}
<div
class="absolute bottom-4 end-4 text-start text-white"
style:view-transition-name={transition.active ? 'memory-overlay-prev' : undefined}
>
<div class="absolute bottom-4 end-4 text-start text-white">
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
</div>
@@ -682,42 +492,39 @@
</div>
<!-- CURRENT MEMORY -->
<div class="main-view relative isolate h-full w-[70vw] rounded-2xl bg-black">
{#key current.asset.id}
{#if current.asset.isVideo}
<MemoryVideoViewer
asset={current.asset}
bind:videoPlayer
videoViewerMuted={$videoViewerMuted}
videoViewerVolume={$videoViewerVolume}
/>
{:else if currentAssetDto}
<MemoryPhotoViewer
asset={currentAssetDto}
transitionName={transition.name}
onImageLoad={handleMemoryImageReady}
onError={resolveTransitionIfPending}
/>
{/if}
{/key}
<div
class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black"
>
<div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id}
{#if current.asset.isVideo}
<MemoryVideoViewer
asset={current.asset}
bind:videoPlayer
videoViewerMuted={$videoViewerMuted}
videoViewerVolume={$videoViewerVolume}
/>
{:else}
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
{/if}
{/key}
<div
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
class:opacity-0={galleryInView}
class:opacity-100={!galleryInView}
style:view-transition-name={showTransitionOverlays ? 'memory-controls' : undefined}
>
<div class="flex items-center">
<IconButton
icon={isSaved ? mdiHeart : mdiHeartOutline}
shape="round"
variant="ghost"
color="secondary"
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
onclick={() => handleSaveMemory()}
class="w-12 h-12"
/>
<!-- <IconButton
<div
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
class:opacity-0={galleryInView}
class:opacity-100={!galleryInView}
>
<div class="flex items-center">
<IconButton
icon={isSaved ? mdiHeart : mdiHeartOutline}
shape="round"
variant="ghost"
color="secondary"
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
onclick={() => handleSaveMemory()}
class="w-12 h-12"
/>
<!-- <IconButton
icon={mdiShareVariantOutline}
shape="round"
variant="ghost"
@@ -725,46 +532,42 @@
color="secondary"
aria-label={$t('share')}
/> -->
<ButtonContextMenu
icon={mdiDotsVertical}
title={$t('menu')}
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
direction="left"
size="medium"
align="bottom-right"
>
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
<MenuOption
onClick={() => handleDeleteMemoryAsset()}
text={$t('remove_photo_from_memory')}
icon={mdiImageMinusOutline}
/>
<!-- shortcut={{ key: 'l', shift: shared }} -->
</ButtonContextMenu>
</div>
<div>
{#await currentMemoryAssetFull then asset}
{#if asset}
<IconButton
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
<ButtonContextMenu
icon={mdiDotsVertical}
title={$t('menu')}
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
direction="left"
size="medium"
align="bottom-right"
>
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
<MenuOption
onClick={() => handleDeleteMemoryAsset()}
text={$t('remove_photo_from_memory')}
icon={mdiImageMinusOutline}
/>
{/if}
{/await}
<!-- shortcut={{ key: 'l', shift: shared }} -->
</ButtonContextMenu>
</div>
<div>
{#await currentMemoryAssetFull then asset}
{#if asset}
<IconButton
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
/>
{/if}
{/await}
</div>
</div>
</div>
<!-- CONTROL BUTTONS -->
<div
class="absolute inset-0 pointer-events-none"
style:view-transition-name={showNavButtonOverlay ? 'memory-nav-buttons' : undefined}
>
<!-- CONTROL BUTTONS -->
{#if current.previous}
<div class="absolute top-1/2 inset-s-0 ms-4 dark pointer-events-auto">
<div class="absolute top-1/2 start-0 ms-4 dark">
<IconButton
shape="round"
aria-label={$t('previous_memory')}
@@ -778,7 +581,7 @@
{/if}
{#if current.next}
<div class="absolute top-1/2 inset-e-0 me-4 dark pointer-events-auto">
<div class="absolute top-1/2 end-0 me-4 dark">
<IconButton
shape="round"
aria-label={$t('next_memory')}
@@ -790,32 +593,25 @@
/>
</div>
{/if}
</div>
<div
class="absolute start-8 top-4 text-sm font-medium text-white"
style:view-transition-name={showTransitionOverlays ? 'memory-overlay' : undefined}
>
<p>
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
locale: $locale,
})}
</p>
<p>
{#await currentMemoryAssetFull then asset}
{asset?.exifInfo?.city || ''}
{asset?.exifInfo?.country || ''}
{/await}
</p>
<div class="absolute start-8 top-4 text-sm font-medium text-white">
<p>
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
locale: $locale,
})}
</p>
<p>
{#await currentMemoryAssetFull then asset}
{asset?.exifInfo?.city || ''}
{asset?.exifInfo?.country || ''}
{/await}
</p>
</div>
</div>
</div>
<!-- NEXT MEMORY -->
<div
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.nextMemory
? ''
: 'opacity-0!'}"
>
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
<button
type="button"
class="relative h-full w-full rounded-2xl"
@@ -828,7 +624,6 @@
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('next_memory')}
draggable="false"
style:view-transition-name={transition.nextPanel}
/>
{:else}
<enhanced:img
@@ -841,10 +636,7 @@
{/if}
{#if current.nextMemory}
<div
class="absolute bottom-4 start-4 text-start text-white"
style:view-transition-name={transition.active ? 'memory-overlay-next' : undefined}
>
<div class="absolute bottom-4 start-4 text-start text-white">
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
</div>
@@ -888,6 +680,8 @@
<style>
.main-view {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
box-shadow:
0 4px 4px 0 rgba(0, 0, 0, 0.3),
0 8px 12px 6px rgba(0, 0, 0, 0.15);
}
</style>
@@ -3,13 +3,10 @@
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';
@@ -30,11 +27,9 @@
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 = {
@@ -70,16 +65,6 @@
}: 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(
@@ -122,36 +107,6 @@
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;
@@ -401,63 +356,6 @@
nextAsset: getNextAsset(navigationAssets, $viewingAsset),
previousAsset: getPreviousAsset(navigationAssets, $viewingAsset),
});
let toViewerTransitionId = $state<string | null>(null);
let toGalleryTransitionId = $state<string | null>(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 }));
};
</script>
<svelte:document
@@ -477,17 +375,16 @@
{#each assets as asset, i (asset.id + '-' + i)}
{#if isIntersecting(i)}
{@const currentAsset = toTimelineAsset(asset)}
{@const transitionName = transitionTargetId === asset.id ? 'hero' : undefined}
<div
class="absolute"
style:overflow="clip"
style={getStyle(i)}
style:view-transition-name={transitionName}
data-transition-name={transitionName}
>
<div class="absolute" style:overflow="clip" style={getStyle(i)}>
<Thumbnail
readonly={disableAssetSelect}
onClick={() => handleThumbnailClick(asset, currentAsset)}
onClick={() => {
if (assetInteraction.selectionActive) {
handleSelectAssets(currentAsset);
return;
}
void navigateToAsset(asset);
}}
onSelect={() => handleSelectAssets(currentAsset)}
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
{showArchiveIcon}
@@ -519,7 +416,10 @@
onAction={handleAction}
onRandom={handleRandom}
onAssetChange={updateCurrentAsset}
onClose={(asset) => handleClose(asset)}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
{/await}
</Portal>
@@ -50,11 +50,7 @@
<svelte:window bind:innerWidth />
<nav
id="dashboard-navbar"
class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm relative z-10"
style:view-transition-name="exclude"
>
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
<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
@@ -1,19 +1,20 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { filterIntersecting } 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';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
type Props = {
animationTargetAssetId?: string | null;
suspendTransitions?: boolean;
viewerAssets: ViewerAsset[];
width: number;
height: number;
manager: VirtualScrollManager;
thumbnail: Snippet<
[
{
@@ -25,20 +26,14 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
let { isUploading } = uploadAssetsStore;
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
const {
animationTargetAssetId,
suspendTransitions = false,
viewerAssets,
width,
height,
thumbnail,
customThumbnailLayout,
}: Props = $props();
const transitionDuration = $derived(suspendTransitions && !$isUploading ? 0 : 150);
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
</script>
<!-- Image grid -->
@@ -46,14 +41,11 @@
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
{@const transitionName = animationTargetAssetId === 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"
data-transition-name={transitionName}
style:view-transition-name={transitionName}
style:top={position.top + 'px'}
style:inset-inline-start={position.left + 'px'}
style:width={position.width + 'px'}
+10 -36
View File
@@ -1,37 +1,34 @@
<script lang="ts">
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot, filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.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 { onMount, tick, type Snippet } from 'svelte';
import type { Snippet } from 'svelte';
type Props = {
toAssetViewerTransitionId?: string | null;
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
assetInteraction: AssetInteraction;
monthGroup: MonthGroup;
manager: VirtualScrollManager;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
};
let {
toAssetViewerTransitionId,
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
assetInteraction,
monthGroup,
manager,
onDayGroupSelect,
}: Props = $props();
@@ -40,6 +37,10 @@
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
@@ -49,32 +50,6 @@
});
return getDateLocaleString(date);
};
let toTimelineTransitionAssetId = $state<string | null>(null);
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
const transitionToTimelineCallback = ({ id }: { id: string }) => {
const asset = monthGroup.findAssetById({ id });
if (!asset) {
return;
}
void viewTransitionManager.startTransition({
types: ['timeline'],
performUpdate: async () => {
eventManager.emit('ViewerCloseTransitionReady');
const event = await eventManager.untilNext('TimelineLoaded');
toTimelineTransitionAssetId = event.id;
await tick();
},
onFinished: () => {
toTimelineTransitionAssetId = null;
focusAsset(asset.id);
},
});
};
if (viewTransitionManager.isSupported()) {
onMount(() => eventManager.on({ ViewerCloseTransition: transitionToTimelineCallback }));
}
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
@@ -118,8 +93,7 @@
</div>
<AssetLayout
{animationTargetAssetId}
suspendTransitions={monthGroup.timelineManager.suspendTransitions}
{manager}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}
@@ -11,7 +11,6 @@
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) */
@@ -40,7 +39,6 @@
}
let {
invisible = false,
timelineTopOffset = 0,
timelineBottomOffset = 0,
height = 0,
@@ -440,7 +438,7 @@
next = forward
? (focusable[(index + 1) % focusable.length] as HTMLElement)
: (focusable[(index - 1) % focusable.length] as HTMLElement);
next?.focus();
next.focus();
}
}
}
@@ -511,7 +509,6 @@
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
+26 -47
View File
@@ -11,8 +11,6 @@
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { startViewerTransition } from '$lib/utils/transition-utils';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
@@ -104,7 +102,6 @@
// Overall scroll percentage through the entire timeline (0-1)
let timelineScrollPercent: number = $state(0);
let scrubberWidth = $state(0);
let toAssetViewerTransitionId = $state<string | null>(null);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const maxMd = $derived(mediaQueryManager.maxMd);
@@ -212,7 +209,7 @@
timelineManager.viewportWidth = rect.width;
}
}
const scrollTarget = getScrollTarget();
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -224,7 +221,7 @@
await tick();
focusAsset(scrollTarget);
}
invisible = isAssetViewerRoute(page) ? true : false;
invisible = false;
};
// note: only modified once in afterNavigate()
@@ -242,13 +239,10 @@
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
});
const getScrollTarget = () => {
return $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(async () => {
void complete.finally(() => {
const isAssetViewerPage = isAssetViewerRoute(page);
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
@@ -257,13 +251,8 @@
if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
}
void scrollAfterNavigate();
if (!isAssetViewerPage) {
const scrollTarget = getScrollTarget();
await tick();
eventManager.emit('TimelineLoaded', { id: scrollTarget });
}
void scrollAfterNavigate();
});
});
@@ -271,7 +260,7 @@
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
onMount(() => {
if (!enableRouting && !isAssetViewerRoute(page)) {
if (!enableRouting) {
invisible = false;
}
});
@@ -535,33 +524,6 @@
}
});
const defaultThumbnailClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleThumbnailClick = (asset: TimelineAsset, dayGroup: DayGroup) => {
if (typeof onThumbnailClick === 'function' || isSelectionMode || assetInteraction.selectionActive) {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, defaultThumbnailClick);
} else {
defaultThumbnailClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
return;
}
const openViewer = () => void navigate({ targetRoute: 'current', assetId: asset.id });
startViewerTransition(asset.id, openViewer, (id) => (toAssetViewerTransitionId = id));
};
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
@@ -582,6 +544,19 @@
assetInteraction.selectAll = timelineManager.assetCount === assetInteraction.selectedAssets.length;
};
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
@@ -612,7 +587,6 @@
{#if timelineManager.months.length > 0}
<Scrubber
{timelineManager}
{invisible}
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight}
@@ -644,7 +618,6 @@
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}
@@ -693,11 +666,11 @@
style:width="100%"
>
<Month
{toAssetViewerTransitionId}
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
manager={timelineManager}
onDayGroupSelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
@@ -711,7 +684,13 @@
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => handleThumbnailClick(asset, dayGroup)}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
@@ -4,8 +4,6 @@
import { AssetAction } from '$lib/constants';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-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 { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -100,12 +98,6 @@
};
const handleClose = async (asset: { id: string }) => {
if (viewTransitionManager.isSupported()) {
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
eventManager.emit('ViewerCloseTransition', { id: asset.id });
await transitionReady;
}
invisible = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
@@ -1,376 +0,0 @@
import { ViewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
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);
// Start first — it will be blocked on updateCallbackDone
const firstPromise = manager.startTransition({
performUpdate: async () => {},
});
// Flush microtasks so the first transition reaches the startViewTransition call
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();
// Clean up first promise
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();
// 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: Promise.resolve(), 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;
});
// 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: Promise.resolve(), skipTransition: vi.fn() };
});
const promise = manager.startTransition({ performUpdate: async () => {} });
await new Promise<void>((r) => queueMicrotask(r));
manager.skipTransitions();
resolveUpdate();
resolveFinished();
await promise;
// Now start a second transition — it should NOT be skipped
const secondUpdate = vi.fn().mockResolvedValue(undefined);
const secondFinished = Promise.resolve();
const secondUpdateDone = Promise.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;
void updateFn();
return {
updateCallbackDone: secondUpdateDone,
finished: secondFinished,
ready: Promise.resolve(),
skipTransition: vi.fn(),
};
});
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',
);
// Simulate transition finishing after error
resolveFinished();
await new Promise<void>((r) => queueMicrotask(r));
// Manager should accept new transitions after cleanup
const secondUpdate = vi.fn().mockResolvedValue(undefined);
const secondFinished = Promise.resolve();
const secondUpdateDone = Promise.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;
void updateFn();
return {
updateCallbackDone: secondUpdateDone,
finished: secondFinished,
ready: Promise.resolve(),
skipTransition: vi.fn(),
};
});
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);
// 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: Promise.resolve(),
finished: Promise.resolve(),
ready: Promise.resolve(),
skipTransition: vi.fn(),
};
});
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');
// 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: Promise.reject(readyError),
ready: Promise.reject(readyError),
skipTransition: vi.fn(),
};
});
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 });
// Flush microtasks so ready rejection fires and aborts the signal
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;
// 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: Promise.resolve(),
finished: Promise.resolve(),
ready: Promise.resolve(),
skipTransition: vi.fn(),
};
});
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);
});
});
});
@@ -1,102 +0,0 @@
import { tick } from 'svelte';
interface TransitionRequest {
types?: string[];
prepareOldSnapshot?: () => void;
performUpdate: (signal: AbortSignal) => Promise<void>;
prepareNewSnapshot?: () => void;
onFinished?: () => void;
}
export class ViewTransitionManager {
#activeViewTransition = $state<ViewTransition | null>(null);
#activeOnFinished: (() => void) | undefined;
get activeViewTransition() {
return this.#activeViewTransition;
}
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();
}
if (!this.isSupported()) {
await performUpdate(AbortSignal.timeout(10_000));
onFinished?.();
return;
}
prepareOldSnapshot?.();
await tick();
const abortController = new AbortController();
let transition: ViewTransition;
try {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition({
update: async () => {
await performUpdate(abortController.signal);
prepareNewSnapshot?.();
await tick();
},
types,
});
} catch {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition(async () => {
await performUpdate(abortController.signal);
prepareNewSnapshot?.();
await tick();
});
}
this.#activeViewTransition = transition;
this.#activeOnFinished = onFinished;
// eslint-disable-next-line tscompat/tscompat
void transition.ready.catch((error: unknown) => {
abortController.abort(error);
});
// Let animation run in the background — don't block the caller.
// This allows skipTransitions() to abort mid-animation for rapid navigation.
// eslint-disable-next-line tscompat/tscompat
void transition.finished
.catch(() => {})
.finally(() => {
if (this.#activeViewTransition === transition) {
this.#activeViewTransition = null;
this.#activeOnFinished = undefined;
onFinished?.();
}
});
// Wait only until the DOM update completes (both snapshots captured),
// not for the animation to finish.
// eslint-disable-next-line tscompat/tscompat
await transition.updateCallbackDone;
}
}
export const viewTransitionManager = new ViewTransitionManager();
@@ -1,5 +0,0 @@
class AppManager {
isAssetViewer = $state(false);
}
export const appManager = new AppManager();
@@ -18,6 +18,8 @@ const createDefaultZoomState = (): ZoomImageWheelState => ({
export type Events = {
Zoom: [];
ZoomChange: [ZoomImageWheelState];
DirectTransform: [ZoomImageWheelState];
ZoomEnabled: [boolean];
Copy: [];
};
@@ -87,6 +89,15 @@ export class AssetViewerManager extends BaseEventManager<Events> {
this.#zoomState = state;
}
directTransform(state: Partial<ZoomImageWheelState>) {
this.#zoomState = { ...this.#zoomState, ...state };
this.emit('DirectTransform', this.#zoomState);
}
setZoomEnabled(enabled: boolean) {
this.emit('ZoomEnabled', enabled);
}
cancelZoomAnimation() {
if (this.#animationFrameId !== null) {
cancelAnimationFrame(this.#animationFrameId);
@@ -5,7 +5,6 @@ import { normalizeTransformEdits } from '$lib/utils/editor';
import { handleError } from '$lib/utils/handle-error';
import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk';
import { clamp } from 'lodash-es';
import smartcrop from 'smartcrop';
import { tick } from 'svelte';
export type CropAspectRatio =
@@ -57,12 +56,10 @@ class TransformManager implements EditToolManager {
isInteracting = $state(false);
isDragging = $state(false);
isApplyingSmartCrop = $state(false);
animationFrame = $state<ReturnType<typeof requestAnimationFrame> | null>(null);
dragAnchor = $state({ x: 0, y: 0 });
resizeSide = $state(ResizeBoundary.None);
imgElement = $state<HTMLImageElement | null>(null);
asset = $state<AssetResponseDto | null>(null);
cropAreaEl = $state<HTMLElement | null>(null);
overlayEl = $state<HTMLElement | null>(null);
cropFrame = $state<HTMLElement | null>(null);
@@ -188,7 +185,6 @@ class TransformManager implements EditToolManager {
}
async onActivate(asset: AssetResponseDto, edits: EditActions): Promise<void> {
this.asset = asset;
const originalSize = getDimensions(asset.exifInfo!);
this.originalImageSize = { width: originalSize.width ?? 0, height: originalSize.height ?? 0 };
@@ -247,7 +243,6 @@ class TransformManager implements EditToolManager {
this.cropImageScale = 1;
this.cropAspectRatio = 'free';
this.hasChanges = false;
this.asset = null;
}
mirror(axis: 'horizontal' | 'vertical') {
@@ -824,72 +819,6 @@ class TransformManager implements EditToolManager {
this.draw();
}
async applySmartCrop() {
const img = this.imgElement;
if (!img || !this.cropAreaEl || !this.asset) {
return;
}
this.isApplyingSmartCrop = true;
try {
const allFaces = [
...(this.asset.people?.flatMap((person) => person.faces ?? []) ?? []),
...(this.asset.unassignedFaces ?? []),
];
const boosts =
allFaces.length > 0
? allFaces.map((face) => ({
x: (face.boundingBoxX1 / face.imageWidth) * img.naturalWidth,
y: (face.boundingBoxY1 / face.imageHeight) * img.naturalHeight,
width: ((face.boundingBoxX2 - face.boundingBoxX1) / face.imageWidth) * img.naturalWidth,
height: ((face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight) * img.naturalHeight,
weight: 1,
}))
: undefined;
let requestWidth: number;
let requestHeight: number;
if (this.cropAspectRatio === 'free') {
requestWidth = Math.round(this.region.width / this.cropImageScale);
requestHeight = Math.round(this.region.height / this.cropImageScale);
} else {
const [aspectW, aspectH] = this.cropAspectRatio.split(':').map(Number);
const fitScale = Math.min(img.naturalWidth / aspectW, img.naturalHeight / aspectH);
requestWidth = Math.round(aspectW * fitScale);
requestHeight = Math.round(aspectH * fitScale);
}
const result = await smartcrop.crop(img, {
width: requestWidth,
height: requestHeight,
boost: boosts ?? undefined,
});
const { x, y, width, height } = result.topCrop;
const displayRegion = this.constrainToBounds(
{
x: x * this.cropImageScale,
y: y * this.cropImageScale,
width: width * this.cropImageScale,
height: height * this.cropImageScale,
},
{ width: this.cropAreaEl.clientWidth, height: this.cropAreaEl.clientHeight },
);
this.hasChanges = true;
this.region.x = displayRegion.x;
this.region.y = displayRegion.y;
this.region.width = displayRegion.width;
this.region.height = displayRegion.height;
this.draw();
} catch (error) {
handleError(error, 'Failed to apply smart crop');
} finally {
this.isApplyingSmartCrop = false;
}
}
resetCrop() {
this.cropAspectRatio = 'free';
this.region = {
@@ -89,16 +89,6 @@ export type Events = {
ReleaseEvent: [ReleaseEvent];
WebsocketConnect: [];
TimelineLoaded: [{ id: string | null }];
TimelineScrolledToAsset: [{ id: string }];
ViewerAfterNavigate: [];
ViewerCloseTransition: [{ id: string }];
ViewerCloseTransitionReady: [];
ViewerOpenTransition: [];
ViewerOpenTransitionReady: [];
ViewTransitionOldSnapshotPending: [];
};
export const eventManager = new BaseEventManager<Events>();
@@ -2,11 +2,3 @@ import type { TimelineAsset } from './types';
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
export function* filterIntersecting<T extends { intersecting: boolean }>(items: T[]) {
for (const item of items) {
if (item.intersecting) {
yield item;
}
}
}
@@ -21,7 +21,6 @@
slideshowTransition,
slideshowAutoplay,
slideshowRepeat,
kenBurnsEffect,
slideshowState,
} = slideshowStore;
@@ -39,7 +38,6 @@
let tempSlideshowTransition = $state($slideshowTransition);
let tempSlideshowAutoplay = $state($slideshowAutoplay);
let tempSlideshowRepeat = $state($slideshowRepeat);
let tempKenBurnsEffect = $state($kenBurnsEffect);
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
@@ -72,7 +70,6 @@
$slideshowTransition = tempSlideshowTransition;
$slideshowAutoplay = tempSlideshowAutoplay;
$slideshowRepeat = tempSlideshowRepeat;
$kenBurnsEffect = tempKenBurnsEffect;
$slideshowState = SlideshowState.PlaySlideshow;
onClose();
};
@@ -110,10 +107,6 @@
<Switch bind:checked={tempSlideshowTransition} />
</Field>
<Field label={$t('slideshow_ken_burns_effect')}>
<Switch bind:checked={tempKenBurnsEffect} />
</Field>
<Field label={$t('slideshow_repeat')} description={$t('slideshow_repeat_description')}>
<Switch bind:checked={tempSlideshowRepeat} />
</Field>
@@ -5,7 +5,6 @@ import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>();
const invisible = writable<boolean>(false);
const viewState = writable<boolean>(false);
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
@@ -31,7 +30,6 @@ function createAssetViewingStore() {
setAsset,
setAssetId,
showAssetViewer,
invisible,
};
}
-2
View File
@@ -41,7 +41,6 @@ function createSlideshowStore() {
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
const kenBurnsEffect = persisted<boolean>('slideshow-ken-burns-effect', false);
return {
restartProgress: {
@@ -74,7 +73,6 @@ function createSlideshowStore() {
slideshowTransition,
slideshowAutoplay,
slideshowRepeat,
kenBurnsEffect,
};
}
@@ -43,50 +43,6 @@ export class BaseEventManager<Events extends EventsBase> {
};
}
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) {
+39 -9
View File
@@ -1,36 +1,66 @@
import { handleError } from '$lib/utils/handle-error';
/**
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
* This class helps manage concurrent operations by tracking which invocations are active
* and allowing operations to check if they're still valid.
*/
export class InvocationTracker {
/** Counter for the number of invocations that have been started */
invocationsStarted = 0;
/** Counter for the number of invocations that have been completed */
invocationsEnded = 0;
constructor() {}
/**
* Starts a new invocation and returns an object with utilities to manage the invocation lifecycle.
* @returns An object containing methods to manage the invocation:
* - isInvalidInvocationError: Checks if an error is an invalid invocation error
* - checkStillValid: Throws an error if the invocation is no longer valid
* - endInvocation: Marks the invocation as complete
*/
startInvocation() {
this.invocationsStarted++;
const invocation = this.invocationsStarted;
return {
isStillValid: () => invocation === this.invocationsStarted,
/**
* Throws an error if this invocation is no longer valid
* @throws {Error} If the invocation is no longer valid
*/
isStillValid: () => {
if (invocation !== this.invocationsStarted) {
return false;
}
return true;
},
/**
* Marks this invocation as complete
*/
endInvocation: () => {
this.invocationsEnded = Math.max(this.invocationsEnded, invocation);
this.invocationsEnded = invocation;
},
};
}
/**
* Checks if there are any active invocations
* @returns True if there are active invocations, false otherwise
*/
isActive() {
return this.invocationsStarted !== this.invocationsEnded;
}
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
const invocation = this.startInvocation();
try {
return await invocable();
} catch (error: unknown) {
if (catchCallback) {
catchCallback(error);
} else {
console.error(error);
}
handleError(error, localizedMessage);
} finally {
invocation.endInvocation();
finallyCallback?.();
}
}
}
-237
View File
@@ -1,237 +0,0 @@
import type { Faces } from '$lib/stores/people.store';
import { SlideshowLook } from '$lib/stores/slideshow.store';
import type { Point } from '$lib/utils/container-utils';
import { TUNABLES } from '$lib/utils/tunables';
import { clamp } from 'lodash-es';
import smartcrop from 'smartcrop';
const KEN_BURNS_MAX_ZOOM_SPEED = 0.08;
const KEN_BURNS_MAX_PAN_SPEED = 8;
export interface KenBurnsKeyframes {
startTransform: string;
endTransform: string;
duration: number;
}
interface KenBurnsInput {
faces: Faces[];
fallbackFaces: Faces[];
smartCropCenter: Point | undefined;
contentWidth: number;
contentHeight: number;
containerWidth: number;
containerHeight: number;
slideshowLook: SlideshowLook;
isCoverMode: boolean;
slideshowDelay: number;
assetId: string;
}
export async function computeSmartCropCenter(
imgRef: HTMLImageElement,
faces: Faces[],
containerWidth: number,
containerHeight: number,
): Promise<Point | undefined> {
if (!TUNABLES.KEN_BURNS.SMARTCROP) {
return undefined;
}
if (!TUNABLES.KEN_BURNS.FACE_BOOST && faces.length > 0) {
return undefined;
}
const boosts =
TUNABLES.KEN_BURNS.FACE_BOOST && faces.length > 0
? faces.map((face) => ({
x: (face.boundingBoxX1 / face.imageWidth) * imgRef.naturalWidth,
y: (face.boundingBoxY1 / face.imageHeight) * imgRef.naturalHeight,
width: ((face.boundingBoxX2 - face.boundingBoxX1) / face.imageWidth) * imgRef.naturalWidth,
height: ((face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight) * imgRef.naturalHeight,
weight: 1,
}))
: undefined;
const result = await smartcrop.crop(imgRef, {
width: containerWidth,
height: containerHeight,
...(boosts && { boost: boosts }),
});
const { x, y, width, height } = result.topCrop;
return {
x: (x + width / 2) / imgRef.naturalWidth,
y: (y + height / 2) / imgRef.naturalHeight,
};
}
const selectKenBurnsFace = (faces: Faces[]): Faces | null => {
let best: Faces | null = null;
let bestArea = 0;
for (const face of faces) {
const area = (face.boundingBoxX2 - face.boundingBoxX1) * (face.boundingBoxY2 - face.boundingBoxY1);
if (area > bestArea) {
best = face;
bestArea = area;
}
}
return best;
};
export function computeKenBurnsKeyframes({
faces,
fallbackFaces,
smartCropCenter,
contentWidth,
contentHeight,
containerWidth,
containerHeight,
slideshowLook,
isCoverMode,
slideshowDelay,
assetId,
}: KenBurnsInput): KenBurnsKeyframes {
const blurredBackground = slideshowLook === SlideshowLook.BlurredBackground;
const minZoom =
!blurredBackground && contentWidth > 0 && contentHeight > 0
? Math.min(Math.max(containerWidth / contentWidth, containerHeight / contentHeight), 2)
: 1;
const slideDurationMs = slideshowDelay * 1000;
const maxZoomChange = KEN_BURNS_MAX_ZOOM_SPEED * (slideDurationMs / 1000);
const face = selectKenBurnsFace(faces) ?? selectKenBurnsFace(fallbackFaces);
let targetScale: number;
let endX = 0;
let endY = 0;
if (face && contentWidth > 0) {
const faceHeightFraction = (face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight;
const faceTargetZoom = (0.4 * containerHeight) / (faceHeightFraction * contentHeight);
targetScale = clamp(faceTargetZoom, Math.max(minZoom, 1.2), 2);
targetScale = clamp(targetScale, Math.max(minZoom, 1.2), minZoom + maxZoomChange);
const targetNormalizedX =
TUNABLES.KEN_BURNS.FACE_BOOST && smartCropCenter
? smartCropCenter.x
: (face.boundingBoxX1 + face.boundingBoxX2) / 2 / face.imageWidth;
const targetNormalizedY =
TUNABLES.KEN_BURNS.FACE_BOOST && smartCropCenter
? smartCropCenter.y
: (face.boundingBoxY1 + face.boundingBoxY2) / 2 / face.imageHeight;
endX = (((0.5 - targetNormalizedX) * contentWidth) / containerWidth) * 100;
endY = (((0.5 - targetNormalizedY) * contentHeight) / containerHeight) * 100;
} else {
targetScale = clamp(minZoom, 1.2, 2);
targetScale = clamp(targetScale, Math.max(minZoom, 1.2), minZoom + maxZoomChange);
if (smartCropCenter && contentWidth > 0) {
endX = (((0.5 - smartCropCenter.x) * contentWidth) / containerWidth) * 100;
endY = (((0.5 - smartCropCenter.y) * contentHeight) / containerHeight) * 100;
}
}
const clampWidth = blurredBackground || isCoverMode ? containerWidth : contentWidth;
const clampHeight = blurredBackground || isCoverMode ? containerHeight : contentHeight;
const maxTranslateX = Math.max(0, (clampWidth / (2 * containerWidth) - 1 / (2 * targetScale)) * 100);
const maxTranslateY = Math.max(0, (clampHeight / (2 * containerHeight) - 1 / (2 * targetScale)) * 100);
endX = clamp(endX, -maxTranslateX, maxTranslateX);
endY = clamp(endY, -maxTranslateY, maxTranslateY);
const panDist = Math.hypot(endX, endY);
const maxPan = KEN_BURNS_MAX_PAN_SPEED * (slideDurationMs / 1000);
if (panDist > maxPan && panDist > 0) {
const ratio = maxPan / panDist;
endX *= ratio;
endY *= ratio;
}
const zoomIn = Number.parseInt(assetId.at(-1) ?? '0', 16) < 8;
const startTransform = zoomIn
? `scale(${minZoom}) translate(0%, 0%)`
: `scale(${targetScale}) translate(${endX}%, ${endY}%)`;
const endTransform = zoomIn
? `scale(${targetScale}) translate(${endX}%, ${endY}%)`
: `scale(${minZoom}) translate(0%, 0%)`;
return { startTransform, endTransform, duration: slideDurationMs };
}
export class KenBurnsAnimation {
#animation: Animation | undefined;
#element: HTMLElement | undefined;
#cancelToken: { value: boolean } | undefined;
async startWithSmartCrop(
element: HTMLElement,
input: Omit<KenBurnsInput, 'smartCropCenter'> & { imgRef: HTMLImageElement },
) {
this.cancel();
const token = { value: false };
this.#cancelToken = token;
const { imgRef, faces, fallbackFaces, containerWidth, containerHeight, ...rest } = input;
const allFaces = faces.length > 0 ? faces : fallbackFaces;
const smartCropCenter = await computeSmartCropCenter(imgRef, allFaces, containerWidth, containerHeight);
if (token.value) {
return;
}
const keyframes = computeKenBurnsKeyframes({
faces,
fallbackFaces,
smartCropCenter,
containerWidth,
containerHeight,
...rest,
});
this.start(element, keyframes);
}
start(element: HTMLElement, { startTransform, endTransform, duration }: KenBurnsKeyframes) {
this.cancel();
this.#element = element;
element.style.transformOrigin = '50% 50%';
element.style.transform = startTransform;
const keyframes: Keyframe[] = [{ transform: startTransform, easing: 'ease-in-out' }, { transform: endTransform }];
this.#animation = element.animate(keyframes, { duration, fill: 'forwards' });
}
freeze() {
if (!this.#animation || !this.#element) {
return;
}
const frozen = getComputedStyle(this.#element).transform;
this.#animation.cancel();
this.#animation = undefined;
if (frozen && frozen !== 'none') {
this.#element.style.transform = frozen;
}
}
pause() {
this.#animation?.pause();
}
resume() {
this.#animation?.play();
}
cancel() {
if (this.#cancelToken) {
this.#cancelToken.value = true;
this.#cancelToken = undefined;
}
this.#animation?.cancel();
this.#animation = undefined;
this.#element?.style.removeProperty('transform-origin');
this.#element = undefined;
}
}
-104
View File
@@ -1,104 +0,0 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { tick } from 'svelte';
function startHeroTransition(
type: string,
id: string,
navigate: () => void,
setTransitionId: (id: string | null) => void,
) {
void viewTransitionManager.startTransition({
types: [type],
prepareOldSnapshot: () => {
setTransitionId(id);
},
performUpdate: async (signal) => {
setTransitionId(null);
const ready = eventManager.untilNext('ViewerOpenTransitionReady', { signal });
navigate();
await ready;
eventManager.emit('ViewerOpenTransition');
await tick();
},
});
}
export function startViewerTransition(
assetId: string,
navigate: () => void,
setTransitionId: (id: string | null) => void,
) {
startHeroTransition('viewer', assetId, navigate, setTransitionId);
}
export function startMemoryTransition(
memoryId: string,
navigate: () => void,
setTransitionId: (id: string | null) => void,
) {
startHeroTransition('memory-enter', memoryId, navigate, setTransitionId);
}
let activeOverlay: HTMLElement | undefined;
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();
eventManager.emit('ViewTransitionOldSnapshotPending');
const clone = viewerContent.cloneNode(true) as HTMLElement;
Object.assign(clone.style, {
position: 'absolute',
inset: '0',
zIndex: '1',
pointerEvents: 'none',
backgroundColor: 'black',
});
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;
}
});
}
+3 -3
View File
@@ -31,8 +31,8 @@ export const TUNABLES = {
IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
},
KEN_BURNS: {
SMARTCROP: getBoolean(storage.getItem('KEN_BURNS.SMARTCROP'), true),
FACE_BOOST: getBoolean(storage.getItem('KEN_BURNS.FACE_BOOST'), true),
MINIMAP: {
FADE_DURATION: getNumber(storage.getItem('MINIMAP.FADE_DURATION'), 150),
HIDE_DELAY: getNumber(storage.getItem('MINIMAP.HIDE_DELAY'), 1500),
},
};
+4 -1
View File
@@ -24,7 +24,7 @@
});
</script>
<div class:invisible={$showAssetViewer}>
<div class:display-none={$showAssetViewer}>
{@render children?.()}
</div>
<UploadCover />
@@ -33,4 +33,7 @@
:root {
overscroll-behavior: none;
}
.display-none {
display: none;
}
</style>
@@ -6,16 +6,13 @@
import { timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { LoadingSpinner } from '@immich/ui';
import { linear } from 'svelte/easing';
import { onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
import type { PageData } from './$types';
interface Props {
@@ -44,14 +41,9 @@
handlePromiseError(goto(Route.photos()));
}
function onViewAssets(assetIds: string[]) {
void viewTransitionManager.startTransition({
types: ['viewer'],
performUpdate: async () => {
await setAssetId(assetIds[0]);
closeTimelinePanel();
},
});
async function onViewAssets(assetIds: string[]) {
await setAssetId(assetIds[0]);
closeTimelinePanel();
}
function onClusterSelect(assetIds: string[], bbox: SelectionBBox) {
@@ -85,10 +77,7 @@
</div>
{#if isTimelinePanelVisible && selectedClusterBBox}
<div
transition:fly={{ x: 400, duration: 150, easing: linear }}
class="h-1/2 min-h-0 w-full pt-2 sm:h-full sm:w-1/3 sm:ps-2 sm:pt-0"
>
<div class="h-1/2 min-h-0 w-full pt-2 sm:h-full sm:w-1/3 sm:ps-2 sm:pt-0">
<MapTimelinePanel
bbox={selectedClusterBBox}
{selectedClusterIds}
@@ -100,7 +89,7 @@
</div>
</UserPageLayout>
<Portal target="body">
{#if $showAssetViewer && !isTimelinePanelVisible}
{#if $showAssetViewer}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={{ current: $viewingAsset }}
@@ -1,5 +1,5 @@
<script lang="ts">
import { beforeNavigate, goto } from '$app/navigation';
import { beforeNavigate } from '$app/navigation';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
@@ -38,9 +38,8 @@
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { startMemoryTransition } from '$lib/utils/transition-utils';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel, type CarouselImageItem } from '@immich/ui';
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -100,16 +99,6 @@
src: getAssetMediaUrl({ id: memory.assets[0].id }),
})),
);
let memoryTransitionId = $state<string | null>(null);
const handleMemoryCardClick = (item: CarouselImageItem) => {
startMemoryTransition(
item.id ?? item.href,
() => void goto(item.href),
(id) => (memoryTransitionId = id),
);
};
</script>
<UserPageLayout hideNavbar={assetInteraction.selectionActive} scrollbar={false}>
@@ -123,33 +112,7 @@
withStacked
>
{#if $preferences.memories.enabled}
{#snippet memoryCard(item: CarouselImageItem)}
<a
class="relative me-2 inline-block aspect-3/4 h-54 rounded-xl last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video"
href={item.href}
data-memory-id={item.id}
onclick={(e) => {
e.preventDefault();
handleMemoryCardClick(item);
}}
style:box-shadow="rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px"
>
<img
class="h-full w-full rounded-xl object-cover"
src={item.src}
alt={item.alt ?? item.title}
draggable="false"
style:view-transition-name={memoryTransitionId === (item.id ?? item.href) ? 'hero' : undefined}
/>
<div
class="absolute inset-s-0 top-0 h-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
></div>
<p class="absolute inset-s-4 bottom-2 text-lg text-white max-md:text-sm">
{item.title}
</p>
</a>
{/snippet}
<ImageCarousel {items} child={memoryCard} />
<ImageCarousel {items} />
{/if}
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />
+3 -13
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, beforeNavigate, goto, onNavigate } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
@@ -8,7 +8,6 @@
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import VersionAnnouncement from '$lib/components/VersionAnnouncement.svelte';
import { appManager } from '$lib/managers/app-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte';
@@ -78,8 +77,6 @@
let showNavigationLoadingBar = $state(false);
appManager.isAssetViewer = isAssetViewerRoute(page);
const getMyImmichLink = () => {
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
};
@@ -105,15 +102,8 @@
showNavigationLoadingBar = true;
});
onNavigate(({ to }) => {
appManager.isAssetViewer = isAssetViewerRoute(to);
});
afterNavigate(({ to, complete }) => {
appManager.isAssetViewer = isAssetViewerRoute(to);
void complete.finally(() => {
showNavigationLoadingBar = false;
});
afterNavigate(() => {
showNavigationLoadingBar = false;
});
const { serverRestarting } = websocketStore;