mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 15:39:20 +03:00
feat: web - view transitions from timeline to viewer, next/prev
feat: web - view transitions from timeline to viewer, next/prev feat: web - swipe feedback - show image while swiping/dragging left/right feat: web - swipe feedback - show image while swiping/dragging left/right tweak animation - no crossfade by default refactor(web): replace ViewTransitionManager event-driven API with phase-based callbacks Change-Id: Ia52f300a08a725062acc19574b10593e6a6a6964 fix(web): graceful degradation for ViewTransitionManager, rename AssetViewerFree to AssetViewerReady, extract onClick handler Change-Id: I4ad85d43e9922742910748a6487cd41f6a6a6964 Change-Id: Ie9c55914b0e87635e0d9e5889ca0ec3d6a6a6964 Change-Id: I0a37b417ee4c247dcc93d442c976eede6a6a6964
This commit is contained in:
@@ -148,7 +148,7 @@ test.describe('zoom and face editor interaction', () => {
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
|
||||
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
||||
const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]');
|
||||
await expect(async () => {
|
||||
const transform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
@@ -252,7 +252,7 @@ test.describe('face overlay via edit faces side panel', () => {
|
||||
await ensureDetailPanelVisible(page);
|
||||
await page.getByLabel('Edit people').click();
|
||||
|
||||
const faceThumbnail = page.locator('section div[role="button"]').first();
|
||||
const faceThumbnail = page.getByTestId('face-thumbnail').first();
|
||||
await expect(faceThumbnail).toBeVisible();
|
||||
|
||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
@@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => {
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.fail(
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||
);
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
@@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => {
|
||||
|
||||
await context.route('**/api/search/metadata', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
|
||||
const searchAssets = assets
|
||||
.slice(0, 5)
|
||||
.filter((asset) => !changes.assetDeletions.includes(asset.id))
|
||||
.map((asset) => toAssetResponseDto(asset));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
|
||||
@@ -164,12 +164,8 @@ export const assetViewerUtils = {
|
||||
},
|
||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||
await page
|
||||
.locator(
|
||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
||||
)
|
||||
.or(
|
||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
||||
)
|
||||
.locator(`img[data-testid="preview"][src*="${asset.id}"]`)
|
||||
.or(page.locator(`video[poster*="${asset.id}"]`))
|
||||
.waitFor();
|
||||
},
|
||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||
|
||||
273
e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts
Normal file
273
e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
import { assetViewerUtils } from 'src/ui/specs/timeline/utils';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer', () => {
|
||||
const rng = new SeededRandom(529);
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const yearMonths: string[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
yearMonths.push(`${year}-${Number(month)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
test.describe('/photos/:id', () => {
|
||||
test('Navigate to next asset via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to previous asset via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByTestId('next-asset').waitFor();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByTestId('previous-asset').waitFor();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate forward 5 times via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Navigate backward 5 times via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Navigate forward then backward via keyboard', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
// Navigate forward 3 times
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.getByTestId('next-asset').waitFor();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
}
|
||||
|
||||
// Navigate backward 3 times to return to original
|
||||
for (let i = 2; i >= 0; i--) {
|
||||
await page.getByTestId('previous-asset').waitFor();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
}
|
||||
|
||||
// Verify we're back at the original asset
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
});
|
||||
|
||||
test('Verify no next button on last asset', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${lastAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||
|
||||
// Verify next button doesn't exist
|
||||
await expect(page.getByLabel('View next asset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Verify no previous button on first asset', async ({ page }) => {
|
||||
const firstAsset = assets[0];
|
||||
await page.goto(`/photos/${firstAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
|
||||
|
||||
// Verify previous button doesn't exist
|
||||
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Delete photo advances to next', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
});
|
||||
test('Delete photo advances to next (2x)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||
});
|
||||
test('Delete last photo advances to prev', async ({ page }) => {
|
||||
const asset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
});
|
||||
test('Delete last photo advances to prev (2x)', async ({ page }) => {
|
||||
const asset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
|
||||
});
|
||||
});
|
||||
test.describe('/trash/photos/:id', () => {
|
||||
test('Delete trashed photo advances to next', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
});
|
||||
test('Delete trashed photo advances to next 2x', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||
});
|
||||
test('Delete trashed photo advances to prev', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||
});
|
||||
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
|
||||
});
|
||||
});
|
||||
});
|
||||
391
web/src/app.css
391
web/src/app.css
@@ -75,6 +75,33 @@
|
||||
--immich-dark-bg: 10 10 10;
|
||||
--immich-dark-fg: 229 231 235;
|
||||
--immich-dark-gray: 33 33 33;
|
||||
|
||||
/* transitions */
|
||||
--immich-split-viewer-nav: enabled;
|
||||
|
||||
/* view transition variables */
|
||||
/* Base animation duration for standard transitions (page fades, info panel) */
|
||||
--vt-duration-default: 250ms;
|
||||
/* Duration for hero transitions (thumbnail to full viewer) */
|
||||
--vt-duration-hero: 280ms;
|
||||
/* Duration for next/previous photo navigation */
|
||||
--vt-duration-viewer-navigation: 270ms;
|
||||
/* Duration for slideshow mode transitions */
|
||||
--vt-duration-slideshow: 1s;
|
||||
/* Easing function for slide animations (ease-out) */
|
||||
--vt-viewer-slide-easing: cubic-bezier(0.2, 0, 0, 1);
|
||||
/* How far images slide in/out during navigation (% of viewport) */
|
||||
--vt-viewer-slide-distance: 15%;
|
||||
/* Starting opacity for fly transitions (slide+fade effect) */
|
||||
--vt-viewer-opacity-start: 0.1;
|
||||
/* Maximum blur during fly transitions (currently disabled) */
|
||||
--vt-viewer-blur-max: 0px;
|
||||
|
||||
--vt-viewer-next-in: flyInRight;
|
||||
--vt-viewer-next-out: flyOutLeft;
|
||||
--vt-viewer-prev-in: flyInLeft;
|
||||
--vt-viewer-prev-out: flyOutRight;
|
||||
--vt-viewer-old-opacity: 1;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
@@ -176,3 +203,367 @@
|
||||
@apply bg-subtle rounded-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
::view-transition {
|
||||
background: var(--color-black);
|
||||
animation-duration: var(--vt-duration-default);
|
||||
}
|
||||
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
mix-blend-mode: normal;
|
||||
animation-duration: inherit;
|
||||
}
|
||||
|
||||
::view-transition-old(*) {
|
||||
animation-name: fadeOut;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
::view-transition-new(*) {
|
||||
animation-name: fadeIn;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
html:active-view-transition-type(slideshow) {
|
||||
&::view-transition-old(*) {
|
||||
animation: var(--vt-duration-slideshow) linear crossfadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(*) {
|
||||
animation: var(--vt-duration-slideshow) linear crossfadeIn forwards;
|
||||
}
|
||||
&::view-transition-image-pair(*) {
|
||||
isolation: auto;
|
||||
}
|
||||
}
|
||||
html:active-view-transition-type(viewer-nav) {
|
||||
&::view-transition-old(root) {
|
||||
animation: var(--vt-duration-hero) 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(root) {
|
||||
animation: var(--vt-duration-hero) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
::view-transition-image-pair(info) {
|
||||
isolation: auto;
|
||||
}
|
||||
::view-transition-old(info) {
|
||||
animation: var(--vt-duration-default) 0s panelSlideOutRight forwards;
|
||||
}
|
||||
::view-transition-new(info) {
|
||||
animation: var(--vt-duration-default) 0s panelSlideInRight forwards;
|
||||
}
|
||||
|
||||
::view-transition-group(detail-panel) {
|
||||
z-index: 1;
|
||||
}
|
||||
::view-transition-old(detail-panel),
|
||||
::view-transition-new(detail-panel) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-group(letterbox-left),
|
||||
::view-transition-group(letterbox-right),
|
||||
::view-transition-group(letterbox-top),
|
||||
::view-transition-group(letterbox-bottom) {
|
||||
animation-duration: var(--vt-duration-viewer-navigation);
|
||||
animation-timing-function: var(--vt-viewer-slide-easing);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
::view-transition-image-pair(letterbox-left),
|
||||
::view-transition-image-pair(letterbox-right),
|
||||
::view-transition-image-pair(letterbox-top),
|
||||
::view-transition-image-pair(letterbox-bottom) {
|
||||
isolation: auto;
|
||||
}
|
||||
|
||||
::view-transition-old(letterbox-left),
|
||||
::view-transition-old(letterbox-right),
|
||||
::view-transition-old(letterbox-top),
|
||||
::view-transition-old(letterbox-bottom),
|
||||
::view-transition-new(letterbox-left),
|
||||
::view-transition-new(letterbox-right),
|
||||
::view-transition-new(letterbox-top),
|
||||
::view-transition-new(letterbox-bottom) {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
background-color: var(--color-black);
|
||||
}
|
||||
|
||||
::view-transition-group(exclude-leftbutton),
|
||||
::view-transition-group(exclude-rightbutton),
|
||||
::view-transition-group(exclude) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
::view-transition-old(exclude-leftbutton),
|
||||
::view-transition-old(exclude-rightbutton),
|
||||
::view-transition-old(exclude) {
|
||||
visibility: hidden;
|
||||
}
|
||||
::view-transition-new(exclude-leftbutton),
|
||||
::view-transition-new(exclude-rightbutton),
|
||||
::view-transition-new(exclude) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
::view-transition-group(hero) {
|
||||
animation-duration: var(--vt-duration-hero);
|
||||
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
::view-transition-old(hero),
|
||||
::view-transition-new(hero) {
|
||||
animation: none;
|
||||
align-content: center;
|
||||
}
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old),
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new),
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old),
|
||||
::view-transition-new(previous),
|
||||
::view-transition-new(previous-new) {
|
||||
animation-duration: var(--vt-duration-viewer-navigation);
|
||||
animation-timing-function: var(--vt-viewer-slide-easing);
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old),
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old) {
|
||||
opacity: var(--vt-viewer-old-opacity);
|
||||
}
|
||||
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old) {
|
||||
animation-name: var(--vt-viewer-next-out);
|
||||
}
|
||||
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new) {
|
||||
animation-name: var(--vt-viewer-next-in);
|
||||
}
|
||||
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old) {
|
||||
animation-name: var(--vt-viewer-prev-out);
|
||||
}
|
||||
|
||||
::view-transition-new(previous),
|
||||
::view-transition-new(previous-new) {
|
||||
animation-name: var(--vt-viewer-prev-in);
|
||||
}
|
||||
|
||||
::view-transition-old(next-old),
|
||||
::view-transition-new(next-new),
|
||||
::view-transition-old(previous-old),
|
||||
::view-transition-new(previous-new) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-old(previous-old) {
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes flyInLeft {
|
||||
from {
|
||||
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyOutLeft {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyInRight {
|
||||
from {
|
||||
transform: translateX(var(--vt-viewer-slide-distance));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(var(--vt-viewer-slide-distance));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelSlideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelSlideOutRight {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* cubic fade curves so combined opacity stays close to 1.0 during crossfade */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes crossfadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes crossfadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(hero) {
|
||||
animation-name: none;
|
||||
}
|
||||
|
||||
::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
::view-transition-new(hero) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(viewer) {
|
||||
&::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(timeline) {
|
||||
&::view-transition-old(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-group(letterbox-left),
|
||||
::view-transition-group(letterbox-right),
|
||||
::view-transition-group(letterbox-top),
|
||||
::view-transition-group(letterbox-bottom) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
::view-transition-old(letterbox-left),
|
||||
::view-transition-old(letterbox-right),
|
||||
::view-transition-old(letterbox-top),
|
||||
::view-transition-old(letterbox-bottom),
|
||||
::view-transition-new(letterbox-left),
|
||||
::view-transition-new(letterbox-right),
|
||||
::view-transition-new(letterbox-top),
|
||||
::view-transition-new(letterbox-bottom) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
::view-transition-group(previous),
|
||||
::view-transition-group(previous-old),
|
||||
::view-transition-group(next),
|
||||
::view-transition-group(next-old) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old),
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-new(previous),
|
||||
::view-transition-new(previous-new),
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<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';
|
||||
@@ -21,6 +22,10 @@
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
showLetterboxes?: boolean;
|
||||
transitionName?: string | null | undefined;
|
||||
letterboxTransitionName?: string | undefined;
|
||||
imageClass?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
@@ -38,6 +43,10 @@
|
||||
sharedLink,
|
||||
objectFit = 'contain',
|
||||
container,
|
||||
showLetterboxes = true,
|
||||
transitionName,
|
||||
letterboxTransitionName,
|
||||
imageClass,
|
||||
onUrlChange,
|
||||
onImageReady,
|
||||
onError,
|
||||
@@ -101,9 +110,13 @@
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const { width, height, left, top } = $derived.by(() => {
|
||||
const scaledDimensions = $derived.by(() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
return scaleFn(imageDimensions, container);
|
||||
});
|
||||
|
||||
const { width, height, left, top } = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
@@ -154,7 +167,17 @@
|
||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
||||
{@render backdrop?.()}
|
||||
|
||||
<div class="absolute inset-0 pointer-events-none" style:left style:top style:width style:height>
|
||||
<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}
|
||||
>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<script module lang="ts">
|
||||
const useSplitNavTransitions =
|
||||
typeof document !== 'undefined' &&
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--immich-split-viewer-nav').trim() === 'enabled';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
@@ -13,6 +19,7 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
@@ -40,7 +47,7 @@
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
import ActivityStatus from './activity-status.svelte';
|
||||
import ActivityViewer from './activity-viewer.svelte';
|
||||
@@ -89,13 +96,14 @@
|
||||
onRandom,
|
||||
}: Props = $props();
|
||||
|
||||
const { setAssetId } = assetViewingStore;
|
||||
const { setAssetId, invisible } = assetViewingStore;
|
||||
const {
|
||||
restartProgress: restartSlideshowProgress,
|
||||
stopProgress: stopSlideshowProgress,
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowRepeat,
|
||||
slideshowTransition,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
@@ -109,6 +117,10 @@
|
||||
let sharedLink = getSharedLink();
|
||||
let fullscreenElement = $state<Element>();
|
||||
|
||||
let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
|
||||
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
|
||||
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
|
||||
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
let slideshowStartAssetId = $state<string>();
|
||||
|
||||
@@ -142,35 +154,40 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onAssetUpdate = (updatedAsset: AssetResponseDto) => {
|
||||
if (asset.id === updatedAsset.id) {
|
||||
cursor = { ...cursor, current: updatedAsset };
|
||||
}
|
||||
};
|
||||
let transitionName = $state<string | undefined>('hero');
|
||||
let letterboxTransitionName = $state<string | undefined>(undefined);
|
||||
let detailPanelTransitionName = $state<string | undefined>(undefined);
|
||||
|
||||
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 slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
slideshowStateUnsubscribe();
|
||||
slideshowNavigationUnsubscribe();
|
||||
const addInfoTransition = () => {
|
||||
detailPanelTransitionName = 'info';
|
||||
transitionName = 'hero';
|
||||
};
|
||||
|
||||
unsubscribes.push(
|
||||
eventManager.on({
|
||||
TransitionToAssetViewer: addInfoTransition,
|
||||
TransitionToTimeline: 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(() => {
|
||||
@@ -179,9 +196,14 @@
|
||||
isFaceEditMode.value = false;
|
||||
syncAssetViewerOpenClass(false);
|
||||
preloadManager.destroy();
|
||||
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
const closeViewer = () => {
|
||||
transitionName = 'hero';
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
@@ -194,65 +216,126 @@
|
||||
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 () => {
|
||||
result = await navigateFn();
|
||||
await eventManager.untilNext('AssetViewerReady');
|
||||
},
|
||||
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 = canTransition && slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
|
||||
const hasNext = useTransition ? await startTransition(types, targetTransition, navigate) : await navigate();
|
||||
|
||||
if (!slideShowPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await handleStopSlideshow();
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
if (slideShowPlaying) {
|
||||
order = slideShowAscending ? 'previous' : 'next';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
preloadManager.cancelBeforeNavigation(order);
|
||||
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void tracker.invoke(async () => {
|
||||
const isShuffle =
|
||||
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
|
||||
|
||||
let hasNext: boolean;
|
||||
|
||||
if (isShuffle) {
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
hasNext = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasNext =
|
||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
|
||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await handleStopSlideshow();
|
||||
}, $t('error_while_navigating'));
|
||||
void tracker.invoke(
|
||||
() => completeNavigation(order, skipTransition),
|
||||
(error: unknown) => handleError(error, $t('error_while_navigating')),
|
||||
() => eventManager.emit('AssetViewerAfterNavigate'),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Slide show mode
|
||||
*/
|
||||
|
||||
let assetViewerHtmlElement = $state<HTMLElement>();
|
||||
|
||||
const slideshowHistory = new SlideshowHistory((asset) => {
|
||||
@@ -277,10 +360,11 @@
|
||||
|
||||
const handleStopSlideshow = async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
if (!document.fullscreenElement) {
|
||||
return;
|
||||
}
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_exit_fullscreen'));
|
||||
} finally {
|
||||
@@ -391,15 +475,24 @@
|
||||
if (cursor.current.id === lastCursor?.current.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastCursor) {
|
||||
previewStackedAsset = undefined;
|
||||
ocrManager.showOverlay = false;
|
||||
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
|
||||
lastCursor = cursor;
|
||||
return;
|
||||
}
|
||||
if (!lastCursor) {
|
||||
preloadManager.initializePreloads(cursor, sharedLink);
|
||||
}
|
||||
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';
|
||||
@@ -470,13 +563,16 @@
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
|
||||
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}
|
||||
use:focusTrap
|
||||
bind:this={assetViewerHtmlElement}
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||
<div
|
||||
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
|
||||
style:view-transition-name="exclude"
|
||||
>
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
{album}
|
||||
@@ -508,16 +604,21 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||
<div
|
||||
data-test-id="previous-asset"
|
||||
class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"
|
||||
style:view-transition-name="exclude-leftbutton"
|
||||
>
|
||||
<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}
|
||||
@@ -530,6 +631,7 @@
|
||||
/>
|
||||
{:else if viewerKind === 'LiveVideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
{asset}
|
||||
assetId={asset.livePhotoVideoId!}
|
||||
cacheKey={asset.thumbhash}
|
||||
@@ -541,13 +643,20 @@
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'ImagePanaramaViewer'}
|
||||
<ImagePanoramaViewer {asset} />
|
||||
<ImagePanoramaViewer {asset} {transitionName} {letterboxTransitionName} />
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
||||
<PhotoViewer
|
||||
{transitionName}
|
||||
{letterboxTransitionName}
|
||||
cursor={{ ...cursor, current: asset }}
|
||||
{sharedLink}
|
||||
{onSwipe}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
{asset}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
@@ -581,15 +690,20 @@
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||
<div
|
||||
data-test-id="next-asset"
|
||||
class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"
|
||||
style:view-transition-name="exclude-rightbutton"
|
||||
>
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showDetailPanel || assetViewerManager.isShowEditor}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
transition:slide={{ axis: 'x', duration: 150 }}
|
||||
id="detail-panel"
|
||||
style:view-transition-name={detailPanelTransitionName}
|
||||
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
translate="yes"
|
||||
>
|
||||
@@ -605,7 +719,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor && $slideshowState === SlideshowState.None}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<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">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Icon } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { signalAssetViewerReady } from '$lib/managers/event-manager.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -74,6 +75,8 @@
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full select-none transition-transform motion-reduce:transition-none"
|
||||
style:transform={imageTransform}
|
||||
onload={() => signalAssetViewerReady()}
|
||||
onerror={() => signalAssetViewerReady()}
|
||||
/>
|
||||
<div
|
||||
class={[
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement | undefined | null;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
@@ -126,6 +126,9 @@
|
||||
});
|
||||
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
if (!htmlElement) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
const container = { width: containerWidth, height: containerHeight };
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
||||
@@ -389,7 +392,7 @@
|
||||
<div
|
||||
id="face-selector"
|
||||
bind:this={faceSelectorEl}
|
||||
class="fixed w-[min(200px,45vw)] min-w-48 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
|
||||
class="fixed z-20 w-[min(200px,45vw)] min-w-48 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
|
||||
use:trapEvents
|
||||
onwheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
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 { asset }: Props = $props();
|
||||
let { transitionName, letterboxTransitionName, asset }: Props = $props();
|
||||
|
||||
const assetId = $derived(asset.id);
|
||||
|
||||
@@ -20,11 +21,16 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
<div class="flex h-dvh w-dvw select-none place-content-center place-items-center">
|
||||
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
|
||||
<LoadingSpinner />
|
||||
{:then [data, { default: PhotoSphereViewer }]}
|
||||
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
{letterboxTransitionName}
|
||||
panorama={data}
|
||||
originalPanorama={getAssetUrl({ asset, forceOriginal: true })}
|
||||
/>
|
||||
{:catch}
|
||||
{$t('errors.failed_to_load_asset')}
|
||||
{/await}
|
||||
|
||||
68
web/src/lib/components/asset-viewer/letterboxes.svelte
Normal file
68
web/src/lib/components/asset-viewer/letterboxes.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<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,7 +1,9 @@
|
||||
<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 { signalAssetViewerReady } 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';
|
||||
@@ -41,6 +43,8 @@
|
||||
'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];
|
||||
@@ -48,11 +52,21 @@
|
||||
navbar?: boolean;
|
||||
};
|
||||
|
||||
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
||||
let {
|
||||
transitionName,
|
||||
letterboxTransitionName,
|
||||
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[] = [];
|
||||
|
||||
@@ -212,6 +226,7 @@
|
||||
zoomSpeed: 0.5,
|
||||
fisheye: false,
|
||||
});
|
||||
viewer.addEventListener('ready', () => signalAssetViewerReady(), { once: true });
|
||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel is 0-100
|
||||
@@ -256,7 +271,15 @@
|
||||
<AssetViewerEvents {onZoom} />
|
||||
|
||||
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
|
||||
<div class="h-full w-full mb-0" bind:this={container}></div>
|
||||
<div
|
||||
id="sphere"
|
||||
class="h-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} />
|
||||
|
||||
<style>
|
||||
/* Reset the default tooltip styling */
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
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 { signalAssetViewerReady } from '$lib/managers/event-manager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { getNaturalSize, scaleToCover, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
@@ -29,14 +30,26 @@
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
onReady?: () => void;
|
||||
transitionName?: string;
|
||||
letterboxTransitionName?: string;
|
||||
onError?: () => void;
|
||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||
}
|
||||
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
sharedLink,
|
||||
transitionName,
|
||||
letterboxTransitionName,
|
||||
onError,
|
||||
onSwipe,
|
||||
}: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const objectFit = $derived(
|
||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain',
|
||||
);
|
||||
const asset = $derived(cursor.current);
|
||||
|
||||
let visibleImageReady: boolean = $state(false);
|
||||
@@ -73,7 +86,8 @@
|
||||
}
|
||||
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight });
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const scaled = scaleFn(natural, { width: containerWidth, height: containerHeight });
|
||||
|
||||
return {
|
||||
contentWidth: scaled.width,
|
||||
@@ -193,18 +207,21 @@
|
||||
{asset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
||||
{objectFit}
|
||||
{onUrlChange}
|
||||
onImageReady={() => {
|
||||
visibleImageReady = true;
|
||||
onReady?.();
|
||||
signalAssetViewerReady();
|
||||
}}
|
||||
onError={() => {
|
||||
onError?.();
|
||||
onReady?.();
|
||||
signalAssetViewerReady();
|
||||
}}
|
||||
bind:imgRef={assetViewerManager.imgRef}
|
||||
bind:ref={adaptiveImage}
|
||||
{transitionName}
|
||||
{letterboxTransitionName}
|
||||
showLetterboxes={!blurredSlideshow}
|
||||
>
|
||||
{#snippet backdrop()}
|
||||
{#if blurredSlideshow}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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 { signalAssetViewerReady } from '$lib/managers/event-manager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import {
|
||||
autoPlayVideo,
|
||||
@@ -18,6 +19,7 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string;
|
||||
assetId: string;
|
||||
loopVideo: boolean;
|
||||
cacheKey: string | null;
|
||||
@@ -30,6 +32,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
assetId,
|
||||
loopVideo,
|
||||
cacheKey,
|
||||
@@ -58,7 +61,6 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// reactive on `assetFileUrl` changes
|
||||
if (assetFileUrl) {
|
||||
hasFocused = false;
|
||||
videoPlayer?.load();
|
||||
@@ -139,6 +141,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<video
|
||||
style:view-transition-name={transitionName}
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
@@ -147,6 +150,7 @@
|
||||
disablePictureInPicture
|
||||
class="h-full object-contain"
|
||||
{...useSwipe(onSwipe)}
|
||||
onloadedmetadata={() => signalAssetViewerReady()}
|
||||
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 }: Props = $props();
|
||||
const { asset, transitionName }: Props = $props();
|
||||
|
||||
const modules = Promise.all([
|
||||
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
|
||||
@@ -19,11 +19,12 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
<div class="flex h-full select-none place-content-center place-items-center">
|
||||
{#await modules}
|
||||
<LoadingSpinner />
|
||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
|
||||
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
|
||||
plugins={[videoPlugin]}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string;
|
||||
asset: AssetResponseDto;
|
||||
assetId?: string;
|
||||
projectionType: string | null | undefined;
|
||||
@@ -19,6 +20,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
asset,
|
||||
assetId,
|
||||
projectionType,
|
||||
@@ -36,9 +38,10 @@
|
||||
</script>
|
||||
|
||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<VideoPanoramaViewer {asset} />
|
||||
<VideoPanoramaViewer {transitionName} {asset} />
|
||||
{:else}
|
||||
<VideoNativeViewer
|
||||
{transitionName}
|
||||
{loopVideo}
|
||||
{cacheKey}
|
||||
assetId={effectiveAssetId}
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
<div
|
||||
role="button"
|
||||
tabindex={index}
|
||||
data-testid="face-thumbnail"
|
||||
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
|
||||
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
|
||||
import { appManager } from '$lib/managers/app-manager.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -44,12 +45,17 @@
|
||||
|
||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
||||
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
||||
let isAssetViewer = $derived(appManager.isAssetViewer);
|
||||
</script>
|
||||
|
||||
<header>
|
||||
{#if !hideNavbar}
|
||||
{#if !hideNavbar && !isAssetViewer}
|
||||
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
|
||||
{/if}
|
||||
|
||||
{#if isAssetViewer}
|
||||
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
|
||||
{/if}
|
||||
</header>
|
||||
<div
|
||||
tabindex="-1"
|
||||
@@ -58,13 +64,15 @@
|
||||
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
||||
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
||||
>
|
||||
{#if sidebar}
|
||||
{#if isAssetViewer}
|
||||
<div></div>
|
||||
{:else if sidebar}
|
||||
{@render sidebar()}
|
||||
{:else}
|
||||
<UserSidebar />
|
||||
{/if}
|
||||
|
||||
<main class="relative">
|
||||
<main class="relative w-full">
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { startViewerTransition, viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
||||
@@ -30,6 +33,7 @@
|
||||
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
@@ -107,6 +111,36 @@
|
||||
|
||||
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
|
||||
|
||||
const scrollGalleryToAsset = async (assetId: string) => {
|
||||
const index = assets.findIndex((asset) => asset.id === assetId);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
const assetTopPage = geometry.getTop(index) + slidingWindowOffset;
|
||||
const assetBottomPage = assetTopPage + geometry.getHeight(index);
|
||||
const currentScrollTop = document.scrollingElement?.scrollTop ?? 0;
|
||||
const visibleTop = currentScrollTop + pageHeaderOffset;
|
||||
const visibleBottom = currentScrollTop + viewport.height;
|
||||
|
||||
if (assetTopPage >= visibleTop && assetBottomPage <= visibleBottom) {
|
||||
return;
|
||||
}
|
||||
const distanceToAlignTop = Math.abs(assetTopPage - pageHeaderOffset - currentScrollTop);
|
||||
const distanceToAlignBottom = Math.abs(assetBottomPage - viewport.height - currentScrollTop);
|
||||
const newScrollTop =
|
||||
distanceToAlignTop < distanceToAlignBottom ? assetTopPage - pageHeaderOffset : assetBottomPage - viewport.height;
|
||||
if (document.scrollingElement) {
|
||||
document.scrollingElement.scrollTop = newScrollTop;
|
||||
}
|
||||
updateSlidingWindow();
|
||||
await tick();
|
||||
};
|
||||
|
||||
const scrollToAndFocusAsset = async (assetId: string) => {
|
||||
await scrollGalleryToAsset(assetId);
|
||||
focusAsset(assetId);
|
||||
};
|
||||
|
||||
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
|
||||
|
||||
let lastIntersectedHeight = 0;
|
||||
@@ -356,6 +390,63 @@
|
||||
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('TransitionToTimelineReady');
|
||||
toGalleryTransitionId = id;
|
||||
await tick();
|
||||
},
|
||||
onFinished: () => {
|
||||
toGalleryTransitionId = null;
|
||||
focusAsset(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
onMount(() => eventManager.on({ TransitionToTimeline: transitionToGalleryCallback }));
|
||||
}
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
const useTransition = viewTransitionManager.isSupported();
|
||||
if (useTransition) {
|
||||
const transitionReady = eventManager.untilNext('TransitionToTimelineReady');
|
||||
eventManager.emit('TransitionToTimeline', { id: asset.id });
|
||||
await transitionReady;
|
||||
}
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
if (!useTransition) {
|
||||
await tick();
|
||||
await scrollToAndFocusAsset(asset.id);
|
||||
}
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }, { noScroll: true }));
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -375,16 +466,17 @@
|
||||
{#each assets as asset, i (asset.id + '-' + i)}
|
||||
{#if isIntersecting(i)}
|
||||
{@const currentAsset = toTimelineAsset(asset)}
|
||||
<div class="absolute" style:overflow="clip" style={getStyle(i)}>
|
||||
{@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}
|
||||
>
|
||||
<Thumbnail
|
||||
readonly={disableAssetSelect}
|
||||
onClick={() => {
|
||||
if (assetInteraction.selectionActive) {
|
||||
handleSelectAssets(currentAsset);
|
||||
return;
|
||||
}
|
||||
void navigateToAsset(asset);
|
||||
}}
|
||||
onClick={() => handleThumbnailClick(asset, currentAsset)}
|
||||
onSelect={() => handleSelectAssets(currentAsset)}
|
||||
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
|
||||
{showArchiveIcon}
|
||||
@@ -416,10 +508,7 @@
|
||||
onAction={handleAction}
|
||||
onRandom={handleRandom}
|
||||
onAssetChange={updateCurrentAsset}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}}
|
||||
onClose={(asset) => handleClose(asset)}
|
||||
/>
|
||||
{/await}
|
||||
</Portal>
|
||||
|
||||
@@ -50,7 +50,11 @@
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
|
||||
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
|
||||
<nav
|
||||
id="dashboard-navbar"
|
||||
class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm relative z-10"
|
||||
style:view-transition-name="exclude"
|
||||
>
|
||||
<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,20 +1,19 @@
|
||||
<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<
|
||||
[
|
||||
{
|
||||
@@ -26,14 +25,20 @@
|
||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
||||
};
|
||||
|
||||
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const {
|
||||
animationTargetAssetId,
|
||||
suspendTransitions = false,
|
||||
viewerAssets,
|
||||
width,
|
||||
height,
|
||||
thumbnail,
|
||||
customThumbnailLayout,
|
||||
}: Props = $props();
|
||||
|
||||
const transitionDuration = $derived(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 -->
|
||||
@@ -41,11 +46,14 @@
|
||||
{#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'}
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
<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 } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { assetsSnapshot, filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.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 type { Snippet } from 'svelte';
|
||||
import { onMount, tick, 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();
|
||||
|
||||
@@ -37,10 +40,6 @@
|
||||
|
||||
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({
|
||||
@@ -50,6 +49,32 @@
|
||||
});
|
||||
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('TransitionToTimelineReady');
|
||||
const event = await eventManager.untilNext('TimelineLoaded');
|
||||
toTimelineTransitionAssetId = event.id;
|
||||
await tick();
|
||||
},
|
||||
onFinished: () => {
|
||||
toTimelineTransitionAssetId = null;
|
||||
focusAsset(asset.id);
|
||||
},
|
||||
});
|
||||
};
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
onMount(() => eventManager.on({ TransitionToTimeline: transitionToTimelineCallback }));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
@@ -93,7 +118,8 @@
|
||||
</div>
|
||||
|
||||
<AssetLayout
|
||||
{manager}
|
||||
{animationTargetAssetId}
|
||||
suspendTransitions={monthGroup.timelineManager.suspendTransitions}
|
||||
viewerAssets={dayGroup.viewerAssets}
|
||||
height={dayGroup.height}
|
||||
width={dayGroup.width}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
invisible: boolean;
|
||||
/** Offset from the top of the timeline (e.g., for headers) */
|
||||
timelineTopOffset?: number;
|
||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||
@@ -39,6 +40,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
invisible = false,
|
||||
timelineTopOffset = 0,
|
||||
timelineBottomOffset = 0,
|
||||
height = 0,
|
||||
@@ -438,7 +440,7 @@
|
||||
next = forward
|
||||
? (focusable[(index + 1) % focusable.length] as HTMLElement)
|
||||
: (focusable[(index - 1) % focusable.length] as HTMLElement);
|
||||
next.focus();
|
||||
next?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -509,6 +511,7 @@
|
||||
aria-valuemin={toScrollY(0)}
|
||||
data-id="scrubber"
|
||||
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
||||
class:invisible
|
||||
style:padding-top={PADDING_TOP + 'px'}
|
||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||
style:width
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
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/managers/ViewTransitionManager.svelte';
|
||||
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';
|
||||
@@ -102,6 +104,7 @@
|
||||
// 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);
|
||||
@@ -209,7 +212,7 @@
|
||||
timelineManager.viewportWidth = rect.width;
|
||||
}
|
||||
}
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
const scrollTarget = getScrollTarget();
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||
@@ -221,7 +224,7 @@
|
||||
await tick();
|
||||
focusAsset(scrollTarget);
|
||||
}
|
||||
invisible = false;
|
||||
invisible = isAssetViewerRoute(page) ? true : false;
|
||||
};
|
||||
|
||||
// note: only modified once in afterNavigate()
|
||||
@@ -239,10 +242,13 @@
|
||||
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(() => {
|
||||
void complete.finally(async () => {
|
||||
const isAssetViewerPage = isAssetViewerRoute(page);
|
||||
|
||||
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
||||
@@ -251,8 +257,13 @@
|
||||
if (isDirectNavigation) {
|
||||
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||
}
|
||||
|
||||
void scrollAfterNavigate();
|
||||
if (!isAssetViewerPage) {
|
||||
const scrollTarget = getScrollTarget();
|
||||
await tick();
|
||||
|
||||
eventManager.emit('TimelineLoaded', { id: scrollTarget });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -260,7 +271,7 @@
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
if (!enableRouting && !isAssetViewerRoute(page)) {
|
||||
invisible = false;
|
||||
}
|
||||
});
|
||||
@@ -524,6 +535,33 @@
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -544,19 +582,6 @@
|
||||
|
||||
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} />
|
||||
@@ -587,6 +612,7 @@
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
{invisible}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||
@@ -666,11 +692,11 @@
|
||||
style:width="100%"
|
||||
>
|
||||
<Month
|
||||
{toAssetViewerTransitionId}
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{monthGroup}
|
||||
manager={timelineManager}
|
||||
onDayGroupSelect={handleGroupSelect}
|
||||
>
|
||||
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||
@@ -684,13 +710,7 @@
|
||||
{asset}
|
||||
{albumUsers}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
}}
|
||||
onClick={(asset) => handleThumbnailClick(asset, dayGroup)}
|
||||
onSelect={() => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
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';
|
||||
@@ -98,6 +100,12 @@
|
||||
};
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
const transitionReady = eventManager.untilNext('TransitionToTimelineReady');
|
||||
eventManager.emit('TransitionToTimeline', { id: asset.id });
|
||||
await transitionReady;
|
||||
}
|
||||
|
||||
invisible = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||
|
||||
252
web/src/lib/managers/ViewTransitionManager.svelte.spec.ts
Normal file
252
web/src/lib/managers/ViewTransitionManager.svelte.spec.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
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 second transition', 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, 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, try a second — should be skipped
|
||||
await manager.startTransition({ performUpdate: secondPerformUpdate });
|
||||
expect(secondPerformUpdate).not.toHaveBeenCalled();
|
||||
|
||||
// Clean up
|
||||
resolveUpdate();
|
||||
resolveFinished();
|
||||
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, 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, 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, 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, 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, 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, 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('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);
|
||||
});
|
||||
});
|
||||
});
|
||||
108
web/src/lib/managers/ViewTransitionManager.svelte.ts
Normal file
108
web/src/lib/managers/ViewTransitionManager.svelte.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
interface TransitionRequest {
|
||||
types?: string[];
|
||||
prepareOldSnapshot?: () => void;
|
||||
performUpdate: () => Promise<void>;
|
||||
prepareNewSnapshot?: () => void;
|
||||
onFinished?: () => void;
|
||||
}
|
||||
|
||||
export class ViewTransitionManager {
|
||||
#activeViewTransition = $state<ViewTransition | null>(null);
|
||||
|
||||
get activeViewTransition() {
|
||||
return this.#activeViewTransition;
|
||||
}
|
||||
|
||||
isSupported() {
|
||||
return 'startViewTransition' in document;
|
||||
}
|
||||
|
||||
skipTransitions() {
|
||||
const skipped = !!this.#activeViewTransition;
|
||||
this.#activeViewTransition?.skipTransition();
|
||||
this.#activeViewTransition = null;
|
||||
return skipped;
|
||||
}
|
||||
|
||||
async startTransition({
|
||||
types,
|
||||
prepareOldSnapshot,
|
||||
performUpdate,
|
||||
prepareNewSnapshot,
|
||||
onFinished,
|
||||
}: TransitionRequest) {
|
||||
if (this.#activeViewTransition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isSupported()) {
|
||||
await performUpdate();
|
||||
onFinished?.();
|
||||
return;
|
||||
}
|
||||
|
||||
prepareOldSnapshot?.();
|
||||
await tick();
|
||||
|
||||
let transition: ViewTransition;
|
||||
try {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition = document.startViewTransition({
|
||||
update: async () => {
|
||||
await performUpdate();
|
||||
prepareNewSnapshot?.();
|
||||
await tick();
|
||||
},
|
||||
types,
|
||||
});
|
||||
} catch {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition = document.startViewTransition(async () => {
|
||||
await performUpdate();
|
||||
prepareNewSnapshot?.();
|
||||
await tick();
|
||||
});
|
||||
}
|
||||
|
||||
this.#activeViewTransition = transition;
|
||||
|
||||
// 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.finally(() => {
|
||||
this.#activeViewTransition = null;
|
||||
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();
|
||||
|
||||
export function startViewerTransition(
|
||||
assetId: string,
|
||||
navigate: () => void,
|
||||
setTransitionId: (id: string | null) => void,
|
||||
) {
|
||||
void viewTransitionManager.startTransition({
|
||||
types: ['viewer'],
|
||||
prepareOldSnapshot: () => {
|
||||
setTransitionId(assetId);
|
||||
},
|
||||
performUpdate: async () => {
|
||||
setTransitionId(null);
|
||||
const ready = eventManager.untilNext('AssetViewerReady');
|
||||
navigate();
|
||||
await ready;
|
||||
eventManager.emit('TransitionToAssetViewer');
|
||||
await tick();
|
||||
},
|
||||
});
|
||||
}
|
||||
10
web/src/lib/managers/app-manager.svelte.ts
Normal file
10
web/src/lib/managers/app-manager.svelte.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
let isAssetViewer = $state(false);
|
||||
|
||||
export const appManager = {
|
||||
get isAssetViewer() {
|
||||
return isAssetViewer;
|
||||
},
|
||||
set isAssetViewer(value: boolean) {
|
||||
isAssetViewer = value;
|
||||
},
|
||||
};
|
||||
@@ -19,76 +19,84 @@ import type {
|
||||
} from '@immich/sdk';
|
||||
|
||||
export type Events = {
|
||||
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
|
||||
AlbumCreate: [AlbumResponseDto];
|
||||
AlbumDelete: [AlbumResponseDto];
|
||||
AlbumShare: [];
|
||||
AlbumUpdate: [AlbumResponseDto];
|
||||
AlbumUserDelete: [{ albumId: string; userId: string }];
|
||||
AlbumUserUpdate: [{ albumId: string; userId: string; role: AlbumUserRole }];
|
||||
|
||||
ApiKeyCreate: [ApiKeyResponseDto];
|
||||
ApiKeyDelete: [ApiKeyResponseDto];
|
||||
ApiKeyUpdate: [ApiKeyResponseDto];
|
||||
|
||||
AppInit: [];
|
||||
|
||||
AssetEditsApplied: [string];
|
||||
AssetUpdate: [AssetResponseDto];
|
||||
AssetViewerAfterNavigate: [];
|
||||
AssetViewerReady: [];
|
||||
AssetsArchive: [string[]];
|
||||
AssetsDelete: [string[]];
|
||||
AssetsTag: [string[]];
|
||||
|
||||
AuthLogin: [LoginResponseDto];
|
||||
AuthLogout: [];
|
||||
AuthUserLoaded: [UserAdminResponseDto];
|
||||
|
||||
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
|
||||
ThemeChange: [ThemeSetting];
|
||||
|
||||
ApiKeyCreate: [ApiKeyResponseDto];
|
||||
ApiKeyUpdate: [ApiKeyResponseDto];
|
||||
ApiKeyDelete: [ApiKeyResponseDto];
|
||||
|
||||
AssetUpdate: [AssetResponseDto];
|
||||
AssetsArchive: [string[]];
|
||||
AssetsDelete: [string[]];
|
||||
AssetEditsApplied: [string];
|
||||
AssetsTag: [string[]];
|
||||
|
||||
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
|
||||
AlbumCreate: [AlbumResponseDto];
|
||||
AlbumUpdate: [AlbumResponseDto];
|
||||
AlbumDelete: [AlbumResponseDto];
|
||||
AlbumShare: [];
|
||||
AlbumUserUpdate: [{ albumId: string; userId: string; role: AlbumUserRole }];
|
||||
AlbumUserDelete: [{ albumId: string; userId: string }];
|
||||
|
||||
PersonUpdate: [PersonResponseDto];
|
||||
PersonThumbnailReady: [{ id: string }];
|
||||
PersonAssetDelete: [{ id: string; assetId: string }];
|
||||
|
||||
BackupDeleteStatus: [{ filename: string; isDeleting: boolean }];
|
||||
BackupDeleted: [{ filename: string }];
|
||||
BackupUpload: [{ progress: number; isComplete: boolean }];
|
||||
|
||||
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
|
||||
|
||||
LibraryCreate: [LibraryResponseDto];
|
||||
LibraryDelete: [{ id: string }];
|
||||
LibraryUpdate: [LibraryResponseDto];
|
||||
|
||||
PersonAssetDelete: [{ id: string; assetId: string }];
|
||||
PersonThumbnailReady: [{ id: string }];
|
||||
PersonUpdate: [PersonResponseDto];
|
||||
|
||||
QueueUpdate: [QueueResponseDto];
|
||||
|
||||
ReleaseEvent: [ReleaseEvent];
|
||||
|
||||
SessionLocked: [];
|
||||
|
||||
SharedLinkCreate: [SharedLinkResponseDto];
|
||||
SharedLinkUpdate: [SharedLinkResponseDto];
|
||||
SharedLinkDelete: [SharedLinkResponseDto];
|
||||
SharedLinkUpdate: [SharedLinkResponseDto];
|
||||
|
||||
SystemConfigUpdate: [SystemConfigDto];
|
||||
|
||||
TagCreate: [TagResponseDto];
|
||||
TagUpdate: [TagResponseDto];
|
||||
TagDelete: [TreeNode];
|
||||
TagUpdate: [TagResponseDto];
|
||||
|
||||
UserPinCodeReset: [];
|
||||
ThemeChange: [ThemeSetting];
|
||||
|
||||
TimelineLoaded: [{ id: string | null }];
|
||||
TransitionToAssetViewer: [];
|
||||
TransitionToTimeline: [{ id: string }];
|
||||
TransitionToTimelineReady: [];
|
||||
|
||||
UserAdminCreate: [UserAdminResponseDto];
|
||||
UserAdminUpdate: [UserAdminResponseDto];
|
||||
UserAdminRestore: [UserAdminResponseDto];
|
||||
// soft deleted
|
||||
UserAdminDelete: [UserAdminResponseDto];
|
||||
// confirmed permanently deleted from server
|
||||
UserAdminDeleted: [{ id: string }];
|
||||
|
||||
SessionLocked: [];
|
||||
|
||||
SystemConfigUpdate: [SystemConfigDto];
|
||||
|
||||
LibraryCreate: [LibraryResponseDto];
|
||||
LibraryUpdate: [LibraryResponseDto];
|
||||
LibraryDelete: [{ id: string }];
|
||||
|
||||
WorkflowCreate: [WorkflowResponseDto];
|
||||
WorkflowUpdate: [WorkflowResponseDto];
|
||||
WorkflowDelete: [WorkflowResponseDto];
|
||||
|
||||
ReleaseEvent: [ReleaseEvent];
|
||||
UserAdminRestore: [UserAdminResponseDto];
|
||||
UserAdminUpdate: [UserAdminResponseDto];
|
||||
UserPinCodeReset: [];
|
||||
|
||||
WebsocketConnect: [];
|
||||
|
||||
WorkflowCreate: [WorkflowResponseDto];
|
||||
WorkflowDelete: [WorkflowResponseDto];
|
||||
WorkflowUpdate: [WorkflowResponseDto];
|
||||
};
|
||||
|
||||
export const eventManager = new BaseEventManager<Events>();
|
||||
export const signalAssetViewerReady = () => eventManager.emit('AssetViewerReady');
|
||||
|
||||
@@ -2,3 +2,11 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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>();
|
||||
|
||||
@@ -30,6 +31,7 @@ function createAssetViewingStore() {
|
||||
setAsset,
|
||||
setAssetId,
|
||||
showAssetViewer,
|
||||
invisible,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,36 @@ 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: number = 10_000,
|
||||
): 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 unsubscribe = this.once(event, (...args: Events[T]) => {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(args[0] as Result);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
unsubscribe();
|
||||
reject(new Error(`untilNext('${String(event)}') timed out after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
||||
const listeners = this.getListeners(event);
|
||||
for (const listener of listeners) {
|
||||
|
||||
@@ -1,66 +1,36 @@
|
||||
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 {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
isStillValid: () => invocation === this.invocationsStarted,
|
||||
endInvocation: () => {
|
||||
this.invocationsEnded = invocation;
|
||||
this.invocationsEnded = Math.max(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>, localizedMessage: string) {
|
||||
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
|
||||
const invocation = this.startInvocation();
|
||||
try {
|
||||
return await invocable();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, localizedMessage);
|
||||
if (catchCallback) {
|
||||
catchCallback(error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
invocation.endInvocation();
|
||||
finallyCallback?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class:display-none={$showAssetViewer}>
|
||||
<div class:invisible={$showAssetViewer}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<UploadCover />
|
||||
@@ -33,7 +33,4 @@
|
||||
:root {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { afterNavigate, beforeNavigate, goto, onNavigate } 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,6 +8,7 @@
|
||||
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';
|
||||
@@ -77,6 +78,8 @@
|
||||
|
||||
let showNavigationLoadingBar = $state(false);
|
||||
|
||||
appManager.isAssetViewer = isAssetViewerRoute(page);
|
||||
|
||||
const getMyImmichLink = () => {
|
||||
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
||||
};
|
||||
@@ -102,8 +105,15 @@
|
||||
showNavigationLoadingBar = true;
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
onNavigate(({ to }) => {
|
||||
appManager.isAssetViewer = isAssetViewerRoute(to);
|
||||
});
|
||||
|
||||
afterNavigate(({ to, complete }) => {
|
||||
appManager.isAssetViewer = isAssetViewerRoute(to);
|
||||
void complete.finally(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
});
|
||||
});
|
||||
|
||||
const { serverRestarting } = websocketStore;
|
||||
|
||||
Reference in New Issue
Block a user