mirror of
https://github.com/immich-app/immich.git
synced 2026-07-23 13:54:05 +03:00
Compare commits
4 Commits
fix/auto-h
...
push-txxyu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa9234ec5c | ||
|
|
682bbce88e | ||
|
|
4957bb15d3 | ||
|
|
4f33aed350 |
@@ -143,8 +143,9 @@ export const timelineUtils = {
|
|||||||
return page.locator('#asset-grid');
|
return page.locator('#asset-grid');
|
||||||
},
|
},
|
||||||
async waitForTimelineLoad(page: Page) {
|
async waitForTimelineLoad(page: Page) {
|
||||||
await expect(timelineUtils.locator(page)).toBeInViewport();
|
await page.locator('#asset-grid[data-initialized]').waitFor();
|
||||||
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
||||||
|
await page.locator('#virtual-timeline:not(.invisible)').waitFor();
|
||||||
},
|
},
|
||||||
async getScrollTop(page: Page) {
|
async getScrollTop(page: Page) {
|
||||||
const queryTop = () =>
|
const queryTop = () =>
|
||||||
@@ -163,14 +164,17 @@ export const assetViewerUtils = {
|
|||||||
return page.locator('#immich-asset-viewer');
|
return page.locator('#immich-asset-viewer');
|
||||||
},
|
},
|
||||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||||
await page
|
const imgLocator = page.locator(`[data-viewer-content] img[data-testid="preview"][src*="${asset.id}"]`);
|
||||||
.locator(
|
const videoLocator = page.locator(`[data-viewer-content] video[poster*="${asset.id}"]`);
|
||||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
await imgLocator.or(videoLocator).waitFor();
|
||||||
)
|
|
||||||
.or(
|
if ((await videoLocator.count()) === 0) {
|
||||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
await expect
|
||||||
)
|
.poll(() => imgLocator.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0))
|
||||||
.waitFor();
|
.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator('#immich-asset-viewer')).not.toHaveAttribute('data-navigating');
|
||||||
},
|
},
|
||||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||||
const activeElement = () =>
|
const activeElement = () =>
|
||||||
|
|||||||
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
15
package-lock.json
generated
Normal file
15
package-lock.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "immich-monorepo",
|
||||||
|
"version": "2.7.5",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "immich-monorepo",
|
||||||
|
"version": "2.7.5",
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=10.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
server/tsconfig.build.tsbuildinfo
Normal file
1
server/tsconfig.build.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
520
web/src/app.css
520
web/src/app.css
@@ -75,6 +75,11 @@
|
|||||||
--immich-dark-bg: 10 10 10;
|
--immich-dark-bg: 10 10 10;
|
||||||
--immich-dark-fg: 229 231 235;
|
--immich-dark-fg: 229 231 235;
|
||||||
--immich-dark-gray: 33 33 33;
|
--immich-dark-gray: 33 33 33;
|
||||||
|
|
||||||
|
/* view transition variables */
|
||||||
|
--vt-duration-default: 250ms;
|
||||||
|
--vt-duration-hero: 280ms;
|
||||||
|
--vt-memory-easing: cubic-bezier(0.2, 0, 0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:not(:disabled),
|
button:not(:disabled),
|
||||||
@@ -175,3 +180,518 @@
|
|||||||
@apply bg-subtle rounded-lg;
|
@apply bg-subtle rounded-lg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
::view-transition {
|
||||||
|
background: var(--color-black);
|
||||||
|
animation-duration: var(--vt-duration-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(*),
|
||||||
|
::view-transition-new(*) {
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
animation-duration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(*) {
|
||||||
|
animation-name: fadeOut;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
::view-transition-new(*) {
|
||||||
|
animation-name: fadeIn;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(root) {
|
||||||
|
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||||
|
}
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-image-pair(info) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
::view-transition-old(info) {
|
||||||
|
animation: var(--vt-duration-default) 0s panelSlideOutRight forwards;
|
||||||
|
}
|
||||||
|
::view-transition-new(info) {
|
||||||
|
animation: var(--vt-duration-default) 0s panelSlideInRight forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(detail-panel) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
::view-transition-old(detail-panel),
|
||||||
|
::view-transition-new(detail-panel) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(exclude-previousbutton),
|
||||||
|
::view-transition-group(exclude-nextbutton),
|
||||||
|
::view-transition-group(exclude) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
::view-transition-old(exclude-previousbutton),
|
||||||
|
::view-transition-old(exclude-nextbutton),
|
||||||
|
::view-transition-old(exclude) {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
::view-transition-new(exclude-previousbutton),
|
||||||
|
::view-transition-new(exclude-nextbutton),
|
||||||
|
::view-transition-new(exclude) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(hero) {
|
||||||
|
animation-duration: var(--vt-duration-hero);
|
||||||
|
animation-timing-function: var(--vt-memory-easing);
|
||||||
|
}
|
||||||
|
::view-transition-old(hero) {
|
||||||
|
animation: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
::view-transition-new(hero) {
|
||||||
|
animation: none;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
::view-transition-old(memory-overlay),
|
||||||
|
::view-transition-old(memory-controls),
|
||||||
|
::view-transition-new(memory-overlay),
|
||||||
|
::view-transition-new(memory-controls) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: none;
|
||||||
|
object-position: left top;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(memory) {
|
||||||
|
&::view-transition-group(hero),
|
||||||
|
&::view-transition-group(hero-out) {
|
||||||
|
animation-duration: var(--vt-duration-memory);
|
||||||
|
animation-timing-function: var(--vt-memory-easing);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
&::view-transition-group(memory-overlay),
|
||||||
|
&::view-transition-group(memory-controls) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
&::view-transition-group(memory-overlay-prev),
|
||||||
|
&::view-transition-group(memory-overlay-next) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
&::view-transition-image-pair(memory-overlay),
|
||||||
|
&::view-transition-image-pair(memory-controls) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay),
|
||||||
|
&::view-transition-old(memory-controls) {
|
||||||
|
animation: 120ms linear fadeOut forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay),
|
||||||
|
&::view-transition-new(memory-controls) {
|
||||||
|
animation: 200ms linear calc(var(--vt-duration-memory) - 200ms) fadeIn forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay-prev),
|
||||||
|
&::view-transition-old(memory-overlay-next) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay-prev),
|
||||||
|
&::view-transition-new(memory-overlay-next) {
|
||||||
|
animation: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: none;
|
||||||
|
object-position: left top;
|
||||||
|
}
|
||||||
|
&::view-transition-image-pair(hero) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
&::view-transition-old(hero) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::view-transition-new(hero) {
|
||||||
|
animation: none;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
&::view-transition-image-pair(hero-out) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
&::view-transition-old(hero-out) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::view-transition-new(hero-out) {
|
||||||
|
animation: var(--vt-duration-memory) var(--vt-memory-easing) dimDown forwards;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
&::view-transition-group(memory-departing) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-departing) {
|
||||||
|
animation: calc(var(--vt-duration-memory) * 0.4) linear fadeFromDim forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-departing) {
|
||||||
|
animation: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(memory-enter) {
|
||||||
|
&::view-transition-group(hero) {
|
||||||
|
animation-duration: var(--vt-duration-hero);
|
||||||
|
animation-timing-function: var(--vt-memory-easing);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
&::view-transition-old(hero),
|
||||||
|
&::view-transition-new(hero) {
|
||||||
|
animation: none;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
&::view-transition-group(memory-overlay),
|
||||||
|
&::view-transition-group(memory-controls),
|
||||||
|
&::view-transition-group(memory-nav-buttons) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay),
|
||||||
|
&::view-transition-old(memory-controls),
|
||||||
|
&::view-transition-old(memory-nav-buttons) {
|
||||||
|
animation: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay),
|
||||||
|
&::view-transition-new(memory-controls),
|
||||||
|
&::view-transition-new(memory-nav-buttons) {
|
||||||
|
animation: 200ms linear var(--vt-duration-hero) fadeIn forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(memory-fade-out) {
|
||||||
|
animation: 500ms linear crossfadeOut forwards;
|
||||||
|
}
|
||||||
|
::view-transition-new(memory-fade-in) {
|
||||||
|
animation: 500ms linear crossfadeIn forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(memory-nav-fast) {
|
||||||
|
&::view-transition-old(memory-fade-out) {
|
||||||
|
animation-duration: 250ms;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-fade-in) {
|
||||||
|
animation-duration: 250ms;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay),
|
||||||
|
&::view-transition-old(memory-controls) {
|
||||||
|
animation-duration: 100ms;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay),
|
||||||
|
&::view-transition-new(memory-controls) {
|
||||||
|
animation: 100ms linear 150ms fadeIn forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(memory-nav) {
|
||||||
|
&::view-transition-group(memory-overlay),
|
||||||
|
&::view-transition-group(memory-controls) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
&::view-transition-image-pair(memory-overlay),
|
||||||
|
&::view-transition-image-pair(memory-controls) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay),
|
||||||
|
&::view-transition-old(memory-controls) {
|
||||||
|
animation: 150ms linear fadeOut forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay),
|
||||||
|
&::view-transition-new(memory-controls) {
|
||||||
|
animation: 200ms linear 300ms fadeIn forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
&::view-transition-group(memory-overlay-prev),
|
||||||
|
&::view-transition-group(memory-overlay-next) {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay-prev),
|
||||||
|
&::view-transition-old(memory-overlay-next) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay-prev),
|
||||||
|
&::view-transition-new(memory-overlay-next) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(next),
|
||||||
|
::view-transition-old(next-old),
|
||||||
|
::view-transition-new(next),
|
||||||
|
::view-transition-new(next-new),
|
||||||
|
::view-transition-old(previous),
|
||||||
|
::view-transition-old(previous-old),
|
||||||
|
::view-transition-new(previous),
|
||||||
|
::view-transition-new(previous-new) {
|
||||||
|
animation-duration: var(--vt-duration-viewer-navigation);
|
||||||
|
animation-timing-function: var(--vt-viewer-slide-easing);
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(next),
|
||||||
|
::view-transition-old(next-old),
|
||||||
|
::view-transition-old(previous),
|
||||||
|
::view-transition-old(previous-old) {
|
||||||
|
opacity: var(--vt-viewer-old-opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(next),
|
||||||
|
::view-transition-old(next-old) {
|
||||||
|
animation-name: var(--vt-viewer-next-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(next),
|
||||||
|
::view-transition-new(next-new) {
|
||||||
|
animation-name: var(--vt-viewer-next-in);
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(previous),
|
||||||
|
::view-transition-old(previous-old) {
|
||||||
|
animation-name: var(--vt-viewer-prev-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(previous),
|
||||||
|
::view-transition-new(previous-new) {
|
||||||
|
animation-name: var(--vt-viewer-prev-in);
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(next-old),
|
||||||
|
::view-transition-new(next-new),
|
||||||
|
::view-transition-old(previous-old),
|
||||||
|
::view-transition-new(previous-new) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(previous-old) {
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeFromDim {
|
||||||
|
from {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dimDown {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flyInLeft {
|
||||||
|
from {
|
||||||
|
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||||
|
opacity: var(--vt-viewer-opacity-start);
|
||||||
|
filter: blur(var(--vt-viewer-blur-max));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flyOutLeft {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||||
|
opacity: var(--vt-viewer-opacity-start);
|
||||||
|
filter: blur(var(--vt-viewer-blur-max));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flyInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(var(--vt-viewer-slide-distance));
|
||||||
|
opacity: var(--vt-viewer-opacity-start);
|
||||||
|
filter: blur(var(--vt-viewer-blur-max));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flyOutRight {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(var(--vt-viewer-slide-distance));
|
||||||
|
opacity: var(--vt-viewer-opacity-start);
|
||||||
|
filter: blur(var(--vt-viewer-blur-max));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes panelSlideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes panelSlideOutRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
::view-transition-group(hero) {
|
||||||
|
animation-name: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(hero) {
|
||||||
|
animation: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(hero) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(viewer) {
|
||||||
|
&::view-transition-old(hero) {
|
||||||
|
animation: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::view-transition-new(hero) {
|
||||||
|
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(timeline) {
|
||||||
|
&::view-transition-old(hero) {
|
||||||
|
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-new(hero) {
|
||||||
|
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(letterbox-left),
|
||||||
|
::view-transition-group(letterbox-right),
|
||||||
|
::view-transition-group(letterbox-top),
|
||||||
|
::view-transition-group(letterbox-bottom) {
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(letterbox-left),
|
||||||
|
::view-transition-old(letterbox-right),
|
||||||
|
::view-transition-old(letterbox-top),
|
||||||
|
::view-transition-old(letterbox-bottom),
|
||||||
|
::view-transition-new(letterbox-left),
|
||||||
|
::view-transition-new(letterbox-right),
|
||||||
|
::view-transition-new(letterbox-top),
|
||||||
|
::view-transition-new(letterbox-bottom) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(viewer-nav) {
|
||||||
|
&::view-transition-group(previous),
|
||||||
|
&::view-transition-group(previous-old),
|
||||||
|
&::view-transition-group(next),
|
||||||
|
&::view-transition-group(next-old) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::view-transition-old(previous),
|
||||||
|
&::view-transition-old(previous-old),
|
||||||
|
&::view-transition-old(next),
|
||||||
|
&::view-transition-old(next-old) {
|
||||||
|
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
|
||||||
|
transform-origin: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::view-transition-new(previous),
|
||||||
|
&::view-transition-new(previous-new),
|
||||||
|
&::view-transition-new(next),
|
||||||
|
&::view-transition-new(next-new) {
|
||||||
|
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
|
||||||
|
transform-origin: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(memory-enter) {
|
||||||
|
&::view-transition-group(hero) {
|
||||||
|
animation-duration: 0s;
|
||||||
|
}
|
||||||
|
&::view-transition-old(hero) {
|
||||||
|
animation: var(--vt-duration-default) fadeOut forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-new(hero) {
|
||||||
|
animation: var(--vt-duration-default) fadeIn forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
sharedLink?: SharedLinkResponseDto;
|
sharedLink?: SharedLinkResponseDto;
|
||||||
objectFit?: 'contain' | 'cover';
|
objectFit?: 'contain' | 'cover';
|
||||||
container: Size;
|
container: Size;
|
||||||
|
imageClass?: string;
|
||||||
|
transitionName?: string;
|
||||||
onUrlChange?: (url: string) => void;
|
onUrlChange?: (url: string) => void;
|
||||||
onImageReady?: () => void;
|
onImageReady?: () => void;
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
@@ -35,6 +37,8 @@
|
|||||||
sharedLink,
|
sharedLink,
|
||||||
objectFit = 'contain',
|
objectFit = 'contain',
|
||||||
container,
|
container,
|
||||||
|
imageClass,
|
||||||
|
transitionName,
|
||||||
onUrlChange,
|
onUrlChange,
|
||||||
onImageReady,
|
onImageReady,
|
||||||
onError,
|
onError,
|
||||||
@@ -152,11 +156,12 @@
|
|||||||
{@render backdrop?.()}
|
{@render backdrop?.()}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 pointer-events-none"
|
class={['absolute inset-0 pointer-events-none', imageClass]}
|
||||||
style:inset-inline-start={insetInlineStart}
|
style:inset-inline-start={insetInlineStart}
|
||||||
style:top
|
style:top
|
||||||
style:width
|
style:width
|
||||||
style:height
|
style:height
|
||||||
|
style:view-transition-name={transitionName ?? assetViewerManager.transitionName}
|
||||||
>
|
>
|
||||||
{#if show.alphaBackground}
|
{#if show.alphaBackground}
|
||||||
<AlphaBackground />
|
<AlphaBackground />
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
@@ -13,6 +19,7 @@
|
|||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-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 { getAssetActions } from '$lib/services/asset.service';
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||||
@@ -24,6 +31,7 @@
|
|||||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { crossfadeViewerContent, removeCrossfadeOverlay } from '$lib/utils/transition-utils';
|
||||||
import {
|
import {
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
@@ -37,7 +45,7 @@
|
|||||||
import { onDestroy, onMount, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
import ActivityStatus from './activity-status.svelte';
|
import ActivityStatus from './activity-status.svelte';
|
||||||
import ActivityViewer from './activity-viewer.svelte';
|
import ActivityViewer from './activity-viewer.svelte';
|
||||||
@@ -94,6 +102,7 @@
|
|||||||
slideshowNavigation,
|
slideshowNavigation,
|
||||||
slideshowState,
|
slideshowState,
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
|
slideshowTransition,
|
||||||
} = slideshowStore;
|
} = slideshowStore;
|
||||||
const stackThumbnailSize = 60;
|
const stackThumbnailSize = 60;
|
||||||
const stackSelectedThumbnailSize = 65;
|
const stackSelectedThumbnailSize = 65;
|
||||||
@@ -107,6 +116,10 @@
|
|||||||
let sharedLink = getSharedLink();
|
let sharedLink = getSharedLink();
|
||||||
let fullscreenElement = $state<Element>();
|
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 playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||||
let slideshowStartAssetId = $state<string>();
|
let slideshowStartAssetId = $state<string>();
|
||||||
|
|
||||||
@@ -140,14 +153,46 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssetUpdate = (updatedAsset: AssetResponseDto) => {
|
let detailPanelTransitionName = $state<string | undefined>();
|
||||||
if (asset.id === updatedAsset.id) {
|
let navigationBarTransitionName = $state<string | undefined>();
|
||||||
cursor = { ...cursor, current: updatedAsset };
|
let previousButtonTransitionName = $state<string | undefined>();
|
||||||
}
|
let nextButtonTransitionName = $state<string | undefined>();
|
||||||
|
let letterboxTransitionName = $state<string | undefined>();
|
||||||
|
|
||||||
|
const activateViewTransitionNames = () => {
|
||||||
|
detailPanelTransitionName = 'info';
|
||||||
|
assetViewerManager.transitionName = 'hero';
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
syncAssetViewerOpenClass(true);
|
syncAssetViewerOpenClass(true);
|
||||||
|
|
||||||
|
const unsubAssetViewerEvents = assetViewerManager.on({
|
||||||
|
ViewerOpenTransition: activateViewTransitionNames,
|
||||||
|
ViewerCloseTransition: activateViewTransitionNames,
|
||||||
|
});
|
||||||
|
const unsubViewTransitionEvents = viewTransitionManager.on({
|
||||||
|
PrepareOldSnapshot: (types) => {
|
||||||
|
if (types.includes('timeline')) {
|
||||||
|
navigationBarTransitionName = 'exclude';
|
||||||
|
previousButtonTransitionName = 'exclude-previousbutton';
|
||||||
|
nextButtonTransitionName = 'exclude-nextbutton';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PrepareNewSnapshot: (types) => {
|
||||||
|
const isViewer = types.includes('viewer');
|
||||||
|
navigationBarTransitionName = isViewer ? 'exclude' : undefined;
|
||||||
|
previousButtonTransitionName = isViewer ? 'exclude-previousbutton' : undefined;
|
||||||
|
nextButtonTransitionName = isViewer ? 'exclude-nextbutton' : undefined;
|
||||||
|
},
|
||||||
|
Finished: () => {
|
||||||
|
navigationBarTransitionName = undefined;
|
||||||
|
previousButtonTransitionName = undefined;
|
||||||
|
nextButtonTransitionName = undefined;
|
||||||
|
assetViewerManager.transitionName = undefined;
|
||||||
|
detailPanelTransitionName = undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||||
if (value === SlideshowState.PlaySlideshow) {
|
if (value === SlideshowState.PlaySlideshow) {
|
||||||
slideshowHistory.reset();
|
slideshowHistory.reset();
|
||||||
@@ -157,7 +202,6 @@
|
|||||||
handlePromiseError(handleStopSlideshow());
|
handlePromiseError(handleStopSlideshow());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||||
if (value === SlideshowNavigation.Shuffle) {
|
if (value === SlideshowNavigation.Shuffle) {
|
||||||
slideshowHistory.reset();
|
slideshowHistory.reset();
|
||||||
@@ -166,6 +210,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
unsubAssetViewerEvents();
|
||||||
|
unsubViewTransitionEvents();
|
||||||
slideshowStateUnsubscribe();
|
slideshowStateUnsubscribe();
|
||||||
slideshowNavigationUnsubscribe();
|
slideshowNavigationUnsubscribe();
|
||||||
};
|
};
|
||||||
@@ -191,65 +237,127 @@
|
|||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTransitionName = (kind: 'old' | 'new', direction: string | null | undefined) => {
|
||||||
|
if (direction === 'previous' || direction === 'next') {
|
||||||
|
return useSplitNavTransitions ? `${direction}-${kind}` : direction;
|
||||||
|
}
|
||||||
|
return direction ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTransitionNames = () => {
|
||||||
|
detailPanelTransitionName = undefined;
|
||||||
|
assetViewerManager.transitionName = undefined;
|
||||||
|
letterboxTransitionName = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTransition = async (
|
||||||
|
types: string[],
|
||||||
|
targetTransition: string | null,
|
||||||
|
navigateFn: () => Promise<boolean>,
|
||||||
|
) => {
|
||||||
|
const oldName = getTransitionName('old', targetTransition);
|
||||||
|
const newName = getTransitionName('new', targetTransition);
|
||||||
|
|
||||||
|
let result = false;
|
||||||
|
|
||||||
|
await viewTransitionManager.startTransition({
|
||||||
|
types,
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
assetViewerManager.transitionName = oldName;
|
||||||
|
letterboxTransitionName = targetTransition ? `${targetTransition}-old` : undefined;
|
||||||
|
detailPanelTransitionName = 'detail-panel';
|
||||||
|
},
|
||||||
|
performUpdate: async (signal) => {
|
||||||
|
const ready = eventManager.untilNext('ViewerOpenTransitionReady', { signal });
|
||||||
|
result = await navigateFn();
|
||||||
|
await ready;
|
||||||
|
},
|
||||||
|
prepareNewSnapshot: () => {
|
||||||
|
assetViewerManager.transitionName = newName;
|
||||||
|
letterboxTransitionName = targetTransition ? `${targetTransition}-new` : undefined;
|
||||||
|
},
|
||||||
|
onFinished: clearTransitionNames,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeNavigation = async (order: 'previous' | 'next', skipTransition: boolean) => {
|
||||||
|
preloadManager.cancelBeforeNavigation(order);
|
||||||
|
const skipped = viewTransitionManager.skipTransitions();
|
||||||
|
const canTransition = viewTransitionManager.isSupported() && !skipped && !skipTransition;
|
||||||
|
|
||||||
|
let navigate: () => Promise<boolean>;
|
||||||
|
let types: string[];
|
||||||
|
let targetTransition: string | null;
|
||||||
|
|
||||||
|
if (slideShowPlaying && slideShowShuffle) {
|
||||||
|
navigate = async () => {
|
||||||
|
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||||
|
if (!next) {
|
||||||
|
const asset = await onRandom?.();
|
||||||
|
if (asset) {
|
||||||
|
slideshowHistory.queue(asset);
|
||||||
|
next = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
types = ['slideshow'];
|
||||||
|
targetTransition = null;
|
||||||
|
} else {
|
||||||
|
navigate = async () => {
|
||||||
|
const target = order === 'previous' ? previousAsset : nextAsset;
|
||||||
|
return navigateToAsset(target);
|
||||||
|
};
|
||||||
|
types = slideShowPlaying ? ['slideshow'] : ['viewer-nav'];
|
||||||
|
targetTransition = slideShowPlaying ? null : order;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
||||||
|
const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition;
|
||||||
|
const useTransition = canTransition && slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
|
||||||
|
const hasNext = useTransition ? await startTransition(types, targetTransition, navigate) : await navigate();
|
||||||
|
|
||||||
|
if (!slideShowPlaying) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNext) {
|
||||||
|
$restartSlideshowProgress = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($slideshowRepeat && slideshowStartAssetId) {
|
||||||
|
await assetViewerManager.setAssetId(slideshowStartAssetId);
|
||||||
|
$restartSlideshowProgress = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleStopSlideshow();
|
||||||
|
};
|
||||||
|
|
||||||
const tracker = new InvocationTracker();
|
const tracker = new InvocationTracker();
|
||||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
let navigating = $state(false);
|
||||||
|
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
if (slideShowPlaying) {
|
||||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
order = slideShowAscending ? 'previous' : 'next';
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
preloadManager.cancelBeforeNavigation(order);
|
|
||||||
|
|
||||||
if (tracker.isActive()) {
|
if (tracker.isActive()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void tracker.invoke(async () => {
|
navigating = true;
|
||||||
const isShuffle =
|
void tracker
|
||||||
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
|
.invoke(() => completeNavigation(order, skipTransition), $t('error_while_navigating'))
|
||||||
|
.finally(() => (navigating = false));
|
||||||
let hasNext: boolean;
|
|
||||||
|
|
||||||
if (isShuffle) {
|
|
||||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
|
||||||
if (!hasNext) {
|
|
||||||
const asset = await onRandom?.();
|
|
||||||
if (asset) {
|
|
||||||
slideshowHistory.queue(asset);
|
|
||||||
hasNext = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hasNext =
|
|
||||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasNext) {
|
|
||||||
$restartSlideshowProgress = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
|
||||||
await assetViewerManager.setAssetId(slideshowStartAssetId);
|
|
||||||
$restartSlideshowProgress = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleStopSlideshow();
|
|
||||||
}, $t('error_while_navigating'));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Slide show mode
|
|
||||||
*/
|
|
||||||
|
|
||||||
let assetViewerHtmlElement = $state<HTMLElement>();
|
let assetViewerHtmlElement = $state<HTMLElement>();
|
||||||
|
|
||||||
const slideshowHistory = new SlideshowHistory((asset) => {
|
const slideshowHistory = new SlideshowHistory((asset) => {
|
||||||
@@ -274,9 +382,11 @@
|
|||||||
|
|
||||||
const handleStopSlideshow = async () => {
|
const handleStopSlideshow = async () => {
|
||||||
try {
|
try {
|
||||||
if (document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
await document.exitFullscreen();
|
return;
|
||||||
}
|
}
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
await document.exitFullscreen();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_exit_fullscreen'));
|
handleError(error, $t('errors.unable_to_exit_fullscreen'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -285,8 +395,22 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
const handleStackedAssetMouseEnter = (stackedAsset: AssetResponseDto) => {
|
||||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
if ((previewStackedAsset ?? cursor.current).id === stackedAsset.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assetViewerManager.closeFaceEditMode();
|
||||||
|
void crossfadeViewerContent(() => {
|
||||||
|
previewStackedAsset = stackedAsset;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStackedAssetMouseLeave = () => {
|
||||||
|
if (!previewStackedAsset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeCrossfadeOverlay();
|
||||||
|
previewStackedAsset = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreAction = (action: Action) => {
|
const handlePreAction = (action: Action) => {
|
||||||
@@ -379,14 +503,21 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (lastCursor) {
|
if (lastCursor) {
|
||||||
|
previewStackedAsset = undefined;
|
||||||
|
ocrManager.showOverlay = false;
|
||||||
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
|
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
|
||||||
}
|
} else {
|
||||||
if (!lastCursor) {
|
|
||||||
preloadManager.initializePreloads(cursor, sharedLink);
|
preloadManager.initializePreloads(cursor, sharedLink);
|
||||||
}
|
}
|
||||||
lastCursor = cursor;
|
lastCursor = cursor;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onAssetUpdate = (update: AssetResponseDto) => {
|
||||||
|
if (asset.id === update.id) {
|
||||||
|
cursor = { ...cursor, current: update };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const viewerKind = $derived.by(() => {
|
const viewerKind = $derived.by(() => {
|
||||||
if (previewStackedAsset) {
|
if (previewStackedAsset) {
|
||||||
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
||||||
@@ -457,13 +588,16 @@
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
id="immich-asset-viewer"
|
id="immich-asset-viewer"
|
||||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
class="fixed inset-s-0 top-0 z-10 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||||
|
data-navigating={navigating || undefined}
|
||||||
use:focusTrap
|
use:focusTrap
|
||||||
bind:this={assetViewerHtmlElement}
|
bind:this={assetViewerHtmlElement}
|
||||||
>
|
>
|
||||||
<!-- Top navigation bar -->
|
|
||||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||||
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
<div
|
||||||
|
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
|
||||||
|
style:view-transition-name={navigationBarTransitionName}
|
||||||
|
>
|
||||||
<AssetViewerNavBar
|
<AssetViewerNavBar
|
||||||
{asset}
|
{asset}
|
||||||
{album}
|
{album}
|
||||||
@@ -496,16 +630,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && previousAsset}
|
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && previousAsset}
|
||||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
<div
|
||||||
|
data-test-id="previous-asset"
|
||||||
|
class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"
|
||||||
|
style:view-transition-name={previousButtonTransitionName}
|
||||||
|
>
|
||||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Asset Viewer -->
|
|
||||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||||
{#if viewerKind === 'StackVideoViewer'}
|
{#if viewerKind === 'StackVideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
asset={previewStackedAsset!}
|
asset={previewStackedAsset!}
|
||||||
|
assetId={previewStackedAsset!.id}
|
||||||
cacheKey={previewStackedAsset!.thumbhash}
|
cacheKey={previewStackedAsset!.thumbhash}
|
||||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||||
loopVideo={true}
|
loopVideo={true}
|
||||||
@@ -569,15 +707,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && nextAsset}
|
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && nextAsset}
|
||||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
<div
|
||||||
|
data-test-id="next-asset"
|
||||||
|
class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"
|
||||||
|
style:view-transition-name={nextButtonTransitionName}
|
||||||
|
>
|
||||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showDetailPanel || assetViewerManager.isShowEditor}
|
{#if showDetailPanel || assetViewerManager.isShowEditor}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:slide={{ axis: 'x', duration: 150 }}
|
||||||
id="detail-panel"
|
id="detail-panel"
|
||||||
|
style:view-transition-name={detailPanelTransitionName}
|
||||||
class={[
|
class={[
|
||||||
'row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light',
|
'row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light',
|
||||||
showDetailPanel ? 'w-90' : 'w-100',
|
showDetailPanel ? 'w-90' : 'w-100',
|
||||||
@@ -592,9 +735,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
{#if stack && withStacked && $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||||
{@const stackedAssets = stack.assets}
|
{@const stackedAssets = stack.assets}
|
||||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
id="stack-slideshow"
|
||||||
|
class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none"
|
||||||
|
onmouseleave={handleStackedAssetMouseLeave}
|
||||||
|
>
|
||||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||||
<div
|
<div
|
||||||
@@ -607,10 +755,11 @@
|
|||||||
dimmed={stackedAsset.id !== asset.id}
|
dimmed={stackedAsset.id !== asset.id}
|
||||||
asset={toTimelineAsset(stackedAsset)}
|
asset={toTimelineAsset(stackedAsset)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
removeCrossfadeOverlay();
|
||||||
cursor.current = stackedAsset;
|
cursor.current = stackedAsset;
|
||||||
previewStackedAsset = undefined;
|
previewStackedAsset = undefined;
|
||||||
}}
|
}}
|
||||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
onMouseEvent={({ isMouseOver }) => isMouseOver && handleStackedAssetMouseEnter(stackedAsset)}
|
||||||
readonly
|
readonly
|
||||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||||
showStackedIcon={false}
|
showStackedIcon={false}
|
||||||
@@ -630,7 +779,7 @@
|
|||||||
|
|
||||||
{#if isShared && album && assetViewerManager.isShowActivityPanel && authManager.authenticated}
|
{#if isShared && album && assetViewerManager.isShowActivityPanel && authManager.authenticated}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:slide={{ axis: 'x', duration: 150 }}
|
||||||
id="activity-panel"
|
id="activity-panel"
|
||||||
class="row-start-1 row-span-5 w-90 md:w-115 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
|
class="row-start-1 row-span-5 w-90 md:w-115 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
|
||||||
translate="yes"
|
translate="yes"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
@@ -74,6 +75,8 @@
|
|||||||
alt={$getAltText(toTimelineAsset(asset))}
|
alt={$getAltText(toTimelineAsset(asset))}
|
||||||
class="h-full select-none transition-transform motion-reduce:transition-none"
|
class="h-full select-none transition-transform motion-reduce:transition-none"
|
||||||
style:transform={imageTransform}
|
style:transform={imageTransform}
|
||||||
|
onload={() => assetViewerManager.emit('ViewerOpenTransitionReady')}
|
||||||
|
onerror={() => assetViewerManager.emit('ViewerOpenTransitionReady')}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
};
|
};
|
||||||
@@ -20,7 +18,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</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')])}
|
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:then [data, { default: PhotoSphereViewer }]}
|
{:then [data, { default: PhotoSphereViewer }]}
|
||||||
|
|||||||
@@ -211,6 +211,7 @@
|
|||||||
zoomSpeed: 0.5,
|
zoomSpeed: 0.5,
|
||||||
fisheye: false,
|
fisheye: false,
|
||||||
});
|
});
|
||||||
|
viewer.addEventListener('ready', () => assetViewerManager.emit('ViewerOpenTransitionReady'), { once: true });
|
||||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||||
// zoomLevel is 0-100
|
// zoomLevel is 0-100
|
||||||
@@ -255,7 +256,12 @@
|
|||||||
<AssetViewerEvents {onZoom} />
|
<AssetViewerEvents {onZoom} />
|
||||||
|
|
||||||
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
|
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
|
||||||
<div class="h-full w-full mb-0" bind:this={container}></div>
|
<div
|
||||||
|
id="sphere"
|
||||||
|
class="h-dvh w-dvw mb-0"
|
||||||
|
bind:this={container}
|
||||||
|
style:view-transition-name={assetViewerManager.transitionName}
|
||||||
|
></div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Reset the default tooltip styling */
|
/* Reset the default tooltip styling */
|
||||||
|
|||||||
@@ -28,14 +28,16 @@
|
|||||||
cursor: AssetCursor;
|
cursor: AssetCursor;
|
||||||
element?: HTMLDivElement;
|
element?: HTMLDivElement;
|
||||||
sharedLink?: SharedLinkResponseDto;
|
sharedLink?: SharedLinkResponseDto;
|
||||||
onReady?: () => void;
|
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
let { cursor, element = $bindable(), sharedLink, onError, onSwipe }: Props = $props();
|
||||||
|
|
||||||
const { slideshowState, slideshowLook } = slideshowStore;
|
const { slideshowState, slideshowLook } = slideshowStore;
|
||||||
|
const objectFit = $derived(
|
||||||
|
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain',
|
||||||
|
);
|
||||||
const asset = $derived(cursor.current);
|
const asset = $derived(cursor.current);
|
||||||
|
|
||||||
let visibleImageReady: boolean = $state(false);
|
let visibleImageReady: boolean = $state(false);
|
||||||
@@ -227,15 +229,15 @@
|
|||||||
{asset}
|
{asset}
|
||||||
{sharedLink}
|
{sharedLink}
|
||||||
{container}
|
{container}
|
||||||
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
{objectFit}
|
||||||
{onUrlChange}
|
{onUrlChange}
|
||||||
onImageReady={() => {
|
onImageReady={() => {
|
||||||
visibleImageReady = true;
|
visibleImageReady = true;
|
||||||
onReady?.();
|
assetViewerManager.emit('ViewerOpenTransitionReady');
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
onError?.();
|
onError?.();
|
||||||
onReady?.();
|
assetViewerManager.emit('ViewerOpenTransitionReady');
|
||||||
}}
|
}}
|
||||||
bind:imgRef={assetViewerManager.imgRef}
|
bind:imgRef={assetViewerManager.imgRef}
|
||||||
bind:ref={adaptiveImage}
|
bind:ref={adaptiveImage}
|
||||||
|
|||||||
@@ -58,7 +58,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// reactive on `assetFileUrl` changes
|
|
||||||
if (assetFileUrl) {
|
if (assetFileUrl) {
|
||||||
hasFocused = false;
|
hasFocused = false;
|
||||||
videoPlayer?.load();
|
videoPlayer?.load();
|
||||||
@@ -139,6 +138,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<video
|
<video
|
||||||
|
style:view-transition-name={assetViewerManager.transitionName}
|
||||||
bind:this={videoPlayer}
|
bind:this={videoPlayer}
|
||||||
loop={$loopVideoPreference && loopVideo}
|
loop={$loopVideoPreference && loopVideo}
|
||||||
autoplay={$autoPlayVideo}
|
autoplay={$autoPlayVideo}
|
||||||
@@ -147,6 +147,7 @@
|
|||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
class="h-full object-contain"
|
class="h-full object-contain"
|
||||||
{...useSwipe(onSwipe)}
|
{...useSwipe(onSwipe)}
|
||||||
|
onloadedmetadata={() => assetViewerManager.emit('ViewerOpenTransitionReady')}
|
||||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||||
onended={onVideoEnded}
|
onended={onVideoEnded}
|
||||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
}
|
}
|
||||||
@@ -19,7 +17,7 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</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}
|
{#await modules}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
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 UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
|
||||||
import type { HeaderButtonActionItem } from '$lib/types';
|
import type { HeaderButtonActionItem } from '$lib/types';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
|
import { isAssetViewerRoute } from '$lib/utils/navigation';
|
||||||
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
|
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';
|
import { t } from 'svelte-i18n';
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
{#if !hideNavbar}
|
{#if !hideNavbar}
|
||||||
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
|
<NavigationBar hidden={isAssetViewerRoute(page)} onUploadClick={() => openFileUploadDialog()} />
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
@@ -64,7 +66,7 @@
|
|||||||
<UserSidebar />
|
<UserSidebar />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<main class="relative">
|
<main class="relative w-full">
|
||||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { assetViewerFadeDuration } from '$lib/constants';
|
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { Size } from '$lib/utils/container-utils';
|
||||||
import { getAssetMediaUrl } from '$lib/utils';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
|
||||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: TimelineAsset;
|
asset: AssetResponseDto;
|
||||||
|
transitionName?: string;
|
||||||
onImageLoad: () => void;
|
onImageLoad: () => void;
|
||||||
|
onError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { asset, onImageLoad }: Props = $props();
|
const { asset, transitionName, onImageLoad, onError }: Props = $props();
|
||||||
|
|
||||||
let assetFileUrl: string = $state('');
|
let containerWidth = $state(0);
|
||||||
let imageLoaded: boolean = $state(false);
|
let containerHeight = $state(0);
|
||||||
let loader = $state<HTMLImageElement>();
|
|
||||||
|
|
||||||
const onLoadCallback = () => {
|
const container: Size = $derived({ width: containerWidth, height: containerHeight });
|
||||||
imageLoaded = true;
|
|
||||||
assetFileUrl = imageLoaderUrl;
|
|
||||||
onImageLoad();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (loader?.complete) {
|
|
||||||
onLoadCallback();
|
|
||||||
}
|
|
||||||
loader?.addEventListener('load', onLoadCallback);
|
|
||||||
return () => {
|
|
||||||
loader?.removeEventListener('load', onLoadCallback);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !imageLoaded}
|
<div
|
||||||
<!-- svelte-ignore a11y_missing_attribute -->
|
class="relative h-full w-full overflow-hidden"
|
||||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
bind:clientWidth={containerWidth}
|
||||||
{/if}
|
bind:clientHeight={containerHeight}
|
||||||
|
>
|
||||||
{#if !imageLoaded}
|
{#if containerWidth > 0 && containerHeight > 0}
|
||||||
<DelayedLoadingSpinner />
|
<AdaptiveImage {asset} {container} {transitionName} showLetterboxes={false} onImageReady={onImageLoad} {onError} />
|
||||||
{:else if imageLoaded}
|
{:else}
|
||||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
<DelayedLoadingSpinner />
|
||||||
<img
|
{/if}
|
||||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
</div>
|
||||||
src={assetFileUrl}
|
|
||||||
alt={$getAltText(asset)}
|
|
||||||
draggable="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -22,10 +22,16 @@
|
|||||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
|
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
|
||||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||||
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||||
|
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
|
||||||
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||||
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
@@ -50,6 +56,7 @@
|
|||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { Attachment } from 'svelte/attachments';
|
import type { Attachment } from 'svelte/attachments';
|
||||||
import { Tween } from 'svelte/motion';
|
import { Tween } from 'svelte/motion';
|
||||||
@@ -62,6 +69,7 @@
|
|||||||
let paused = $state(false);
|
let paused = $state(false);
|
||||||
let current = $state<MemoryAsset | undefined>(undefined);
|
let current = $state<MemoryAsset | undefined>(undefined);
|
||||||
const currentAssetId = $derived(current?.asset.id);
|
const currentAssetId = $derived(current?.asset.id);
|
||||||
|
const currentAssetDto = $derived(current ? current.memory.assets[current.assetIndex] : undefined);
|
||||||
const currentMemoryAssetFull = $derived.by(async () =>
|
const currentMemoryAssetFull = $derived.by(async () =>
|
||||||
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
|
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
|
||||||
);
|
);
|
||||||
@@ -74,6 +82,14 @@
|
|||||||
|
|
||||||
let isSaved = $derived(current?.memory.isSaved);
|
let isSaved = $derived(current?.memory.isSaved);
|
||||||
let viewerHeight = $state(0);
|
let viewerHeight = $state(0);
|
||||||
|
let transition = $state({
|
||||||
|
name: undefined as string | undefined,
|
||||||
|
previousPanel: undefined as string | undefined,
|
||||||
|
nextPanel: undefined as string | undefined,
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
const showTransitionOverlays = $derived(transition.active || transition.name === 'hero');
|
||||||
|
const showNavButtonOverlay = $derived(transition.name === 'hero');
|
||||||
|
|
||||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||||
// need to include padding in the viewport for gallery
|
// need to include padding in the viewport for gallery
|
||||||
@@ -82,18 +98,6 @@
|
|||||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||||
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||||
|
|
||||||
const handleNavigate = async (asset?: { id: string }) => {
|
|
||||||
if (assetViewerManager.isViewing) {
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await goto(asHref(asset));
|
|
||||||
};
|
|
||||||
|
|
||||||
const setProgressDuration = (asset: TimelineAsset) => {
|
const setProgressDuration = (asset: TimelineAsset) => {
|
||||||
if (asset.isVideo) {
|
if (asset.isVideo) {
|
||||||
const timeParts = asset.duration!.split(':').map(Number);
|
const timeParts = asset.duration!.split(':').map(Number);
|
||||||
@@ -109,11 +113,177 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextAsset = () => handleNavigate(current?.next?.asset);
|
const scrollToTop = () => {
|
||||||
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
if (window.scrollY === 0) {
|
||||||
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
return Promise.resolve();
|
||||||
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
}
|
||||||
const handleEscape = async () => goto(Route.photos());
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(resolve, 500);
|
||||||
|
window.addEventListener(
|
||||||
|
'scrollend',
|
||||||
|
() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const withMemoryTransition = async (
|
||||||
|
asset: { id: string } | undefined,
|
||||||
|
config: Omit<Parameters<typeof viewTransitionManager.startTransition>[0], 'onFinished'> & {
|
||||||
|
onFinished?: () => void;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if ($isViewing || !asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await scrollToTop();
|
||||||
|
|
||||||
|
transition.active = true;
|
||||||
|
viewTransitionManager
|
||||||
|
.startTransition({
|
||||||
|
...config,
|
||||||
|
onFinished: () => {
|
||||||
|
transition.previousPanel = undefined;
|
||||||
|
transition.nextPanel = undefined;
|
||||||
|
transition.name = undefined;
|
||||||
|
transition.active = false;
|
||||||
|
config.onFinished?.();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => console.error('[Memory] transition failed:', error));
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateWithTransition = (asset?: { id: string }) =>
|
||||||
|
withMemoryTransition(asset, {
|
||||||
|
types: ['memory-nav'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
transition.name = 'memory-fade-out';
|
||||||
|
},
|
||||||
|
performUpdate: async () => {
|
||||||
|
await goto(asHref(asset!));
|
||||||
|
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||||
|
},
|
||||||
|
prepareNewSnapshot: () => {
|
||||||
|
transition.name = 'memory-fade-in';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNextAsset = () => {
|
||||||
|
const next = current?.next;
|
||||||
|
if (next && next.memory.id !== current?.memory.id) {
|
||||||
|
void navigateToMemory('next', next.asset);
|
||||||
|
} else {
|
||||||
|
void navigateWithTransition(next?.asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handlePreviousAsset = () => {
|
||||||
|
const previous = current?.previous;
|
||||||
|
if (previous && previous.memory.id !== current?.memory.id) {
|
||||||
|
void navigateToMemory('previous', previous.asset);
|
||||||
|
} else {
|
||||||
|
void navigateWithTransition(previous?.asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const navigateToMemory = (direction: 'next' | 'previous', asset?: { id: string }) => {
|
||||||
|
const isNext = direction === 'next';
|
||||||
|
const useHeroMorph = !mediaQueryManager.reducedMotion;
|
||||||
|
|
||||||
|
return withMemoryTransition(asset, {
|
||||||
|
types: ['memory'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
if (useHeroMorph) {
|
||||||
|
if (isNext) {
|
||||||
|
transition.nextPanel = 'hero';
|
||||||
|
transition.previousPanel = 'memory-departing';
|
||||||
|
} else {
|
||||||
|
transition.previousPanel = 'hero';
|
||||||
|
transition.nextPanel = 'memory-departing';
|
||||||
|
}
|
||||||
|
transition.name = 'hero-out';
|
||||||
|
} else {
|
||||||
|
transition.name = 'memory-fade-out';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
performUpdate: async () => {
|
||||||
|
transition.nextPanel = undefined;
|
||||||
|
transition.previousPanel = undefined;
|
||||||
|
if (useHeroMorph) {
|
||||||
|
if (isNext) {
|
||||||
|
transition.previousPanel = 'hero-out';
|
||||||
|
} else {
|
||||||
|
transition.nextPanel = 'hero-out';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition.name = useHeroMorph ? 'hero' : 'memory-fade-in';
|
||||||
|
await goto(asHref(asset!));
|
||||||
|
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMemory = () => void navigateToMemory('next', current?.nextMemory?.assets[0]);
|
||||||
|
const handlePreviousMemory = () => void navigateToMemory('previous', current?.previousMemory?.assets[0]);
|
||||||
|
const closeMemoryViewer = () => {
|
||||||
|
if (current && current.assetIndex > 0 && !mediaQueryManager.reducedMotion) {
|
||||||
|
const firstAsset = current.memory.assets[0];
|
||||||
|
void withMemoryTransition(firstAsset, {
|
||||||
|
types: ['memory-nav', 'memory-nav-fast'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
transition.name = 'memory-fade-out';
|
||||||
|
},
|
||||||
|
performUpdate: async () => {
|
||||||
|
await goto(asHref(firstAsset));
|
||||||
|
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||||
|
},
|
||||||
|
prepareNewSnapshot: () => {
|
||||||
|
transition.name = 'memory-fade-in';
|
||||||
|
},
|
||||||
|
onFinished: () => closeToTimeline(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
closeToTimeline();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeToTimeline = () => {
|
||||||
|
const memoryId = current?.memory.id;
|
||||||
|
let cardImage: HTMLElement | null | undefined;
|
||||||
|
|
||||||
|
void viewTransitionManager.startTransition({
|
||||||
|
types: ['memory-enter'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
transition.name = 'hero';
|
||||||
|
},
|
||||||
|
performUpdate: async () => {
|
||||||
|
transition.name = undefined;
|
||||||
|
await goto(Route.photos());
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const memoryCard = memoryId
|
||||||
|
? document.querySelector<HTMLElement>(`[data-memory-id="${CSS.escape(memoryId)}"]`)
|
||||||
|
: null;
|
||||||
|
memoryCard?.scrollIntoView({ behavior: 'instant', inline: 'nearest', block: 'nearest' });
|
||||||
|
cardImage = memoryCard?.querySelector<HTMLElement>('img');
|
||||||
|
if (cardImage) {
|
||||||
|
cardImage.style.viewTransitionName = 'hero';
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
if (cardImage) {
|
||||||
|
cardImage.style.viewTransitionName = '';
|
||||||
|
cardImage = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = closeMemoryViewer;
|
||||||
const handleSelectAll = () =>
|
const handleSelectAll = () =>
|
||||||
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
||||||
|
|
||||||
@@ -157,13 +327,17 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProgress = async (progress: number) => {
|
const handleProgress = (progress: number) => {
|
||||||
if (!progressBarController) {
|
if (!progressBarController) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progress === 1 && !paused) {
|
if (progress === 1 && !paused && !transition.active) {
|
||||||
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
|
if (current?.next) {
|
||||||
|
handleNextAsset();
|
||||||
|
} else {
|
||||||
|
handlePromiseError(handleAction('handleProgressLast', 'pause'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,7 +441,18 @@
|
|||||||
playerInitialized = false;
|
playerInitialized = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetAndPlay = () => {
|
const resolveTransitionIfPending = () => {
|
||||||
|
if (viewTransitionManager.activeViewTransition) {
|
||||||
|
transition.name = 'hero';
|
||||||
|
eventManager.emit('ViewerOpenTransitionReady');
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
transition.name = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMemoryImageReady = () => {
|
||||||
|
resolveTransitionIfPending();
|
||||||
handlePromiseError(handleAction('resetAndPlay', 'reset'));
|
handlePromiseError(handleAction('resetAndPlay', 'reset'));
|
||||||
handlePromiseError(handleAction('resetAndPlay', 'play'));
|
handlePromiseError(handleAction('resetAndPlay', 'play'));
|
||||||
};
|
};
|
||||||
@@ -282,7 +467,7 @@
|
|||||||
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
|
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
|
||||||
} else if (isVideo) {
|
} else if (isVideo) {
|
||||||
// Image assets will start playing when the image is loaded. Only autostart video assets.
|
// Image assets will start playing when the image is loaded. Only autostart video assets.
|
||||||
resetAndPlay();
|
handleMemoryImageReady();
|
||||||
}
|
}
|
||||||
playerInitialized = true;
|
playerInitialized = true;
|
||||||
};
|
};
|
||||||
@@ -310,7 +495,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (progressBarController) {
|
if (progressBarController) {
|
||||||
handlePromiseError(handleProgress(progressBarController.current));
|
handleProgress(progressBarController.current);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -379,7 +564,7 @@
|
|||||||
bind:clientWidth={viewport.width}
|
bind:clientWidth={viewport.width}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
<ControlAppBar onClose={closeMemoryViewer} forceDark multiRow>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
{#if current}
|
{#if current}
|
||||||
<p class="text-lg">
|
<p class="text-lg">
|
||||||
@@ -455,7 +640,11 @@
|
|||||||
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
|
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- PREVIOUS MEMORY -->
|
<!-- PREVIOUS MEMORY -->
|
||||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
<div
|
||||||
|
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.previousMemory
|
||||||
|
? ''
|
||||||
|
: 'opacity-0!'}"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative h-full w-full rounded-2xl"
|
class="relative h-full w-full rounded-2xl"
|
||||||
@@ -468,6 +657,7 @@
|
|||||||
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||||
alt={$t('previous_memory')}
|
alt={$t('previous_memory')}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
style:view-transition-name={transition.previousPanel}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<enhanced:img
|
<enhanced:img
|
||||||
@@ -480,7 +670,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if current.previousMemory}
|
{#if current.previousMemory}
|
||||||
<div class="absolute bottom-4 end-4 text-start text-white">
|
<div
|
||||||
|
class="absolute bottom-4 end-4 text-start text-white"
|
||||||
|
style:view-transition-name={transition.active ? 'memory-overlay-prev' : undefined}
|
||||||
|
>
|
||||||
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
|
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
|
||||||
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
|
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -489,39 +682,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CURRENT MEMORY -->
|
<!-- CURRENT MEMORY -->
|
||||||
<div
|
<div class="main-view relative isolate h-full w-[70vw] rounded-2xl bg-black">
|
||||||
class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black"
|
{#key current.asset.id}
|
||||||
>
|
{#if current.asset.isVideo}
|
||||||
<div class="relative h-full w-full rounded-2xl bg-black">
|
<MemoryVideoViewer
|
||||||
{#key current.asset.id}
|
asset={current.asset}
|
||||||
{#if current.asset.isVideo}
|
bind:videoPlayer
|
||||||
<MemoryVideoViewer
|
videoViewerMuted={$videoViewerMuted}
|
||||||
asset={current.asset}
|
videoViewerVolume={$videoViewerVolume}
|
||||||
bind:videoPlayer
|
/>
|
||||||
videoViewerMuted={$videoViewerMuted}
|
{:else if currentAssetDto}
|
||||||
videoViewerVolume={$videoViewerVolume}
|
<MemoryPhotoViewer
|
||||||
/>
|
asset={currentAssetDto}
|
||||||
{:else}
|
transitionName={transition.name}
|
||||||
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
onImageLoad={handleMemoryImageReady}
|
||||||
{/if}
|
onError={resolveTransitionIfPending}
|
||||||
{/key}
|
/>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
|
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
|
||||||
class:opacity-0={galleryInView}
|
class:opacity-0={galleryInView}
|
||||||
class:opacity-100={!galleryInView}
|
class:opacity-100={!galleryInView}
|
||||||
>
|
style:view-transition-name={showTransitionOverlays ? 'memory-controls' : undefined}
|
||||||
<div class="flex items-center">
|
>
|
||||||
<IconButton
|
<div class="flex items-center">
|
||||||
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
<IconButton
|
||||||
shape="round"
|
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
||||||
variant="ghost"
|
shape="round"
|
||||||
color="secondary"
|
variant="ghost"
|
||||||
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
color="secondary"
|
||||||
onclick={() => handleSaveMemory()}
|
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
||||||
class="w-12 h-12"
|
onclick={() => handleSaveMemory()}
|
||||||
/>
|
class="w-12 h-12"
|
||||||
<!-- <IconButton
|
/>
|
||||||
|
<!-- <IconButton
|
||||||
icon={mdiShareVariantOutline}
|
icon={mdiShareVariantOutline}
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -529,42 +725,46 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
aria-label={$t('share')}
|
aria-label={$t('share')}
|
||||||
/> -->
|
/> -->
|
||||||
<ButtonContextMenu
|
<ButtonContextMenu
|
||||||
icon={mdiDotsVertical}
|
icon={mdiDotsVertical}
|
||||||
title={$t('menu')}
|
title={$t('menu')}
|
||||||
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
|
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
|
||||||
direction="left"
|
direction="left"
|
||||||
size="medium"
|
size="medium"
|
||||||
align="bottom-right"
|
align="bottom-right"
|
||||||
>
|
>
|
||||||
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
||||||
<MenuOption
|
<MenuOption
|
||||||
onClick={() => handleDeleteMemoryAsset()}
|
onClick={() => handleDeleteMemoryAsset()}
|
||||||
text={$t('remove_photo_from_memory')}
|
text={$t('remove_photo_from_memory')}
|
||||||
icon={mdiImageMinusOutline}
|
icon={mdiImageMinusOutline}
|
||||||
/>
|
/>
|
||||||
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{#await currentMemoryAssetFull then asset}
|
|
||||||
{#if asset}
|
|
||||||
<IconButton
|
|
||||||
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
|
|
||||||
icon={mdiImageSearch}
|
|
||||||
aria-label={$t('view_in_timeline')}
|
|
||||||
color="secondary"
|
|
||||||
variant="ghost"
|
|
||||||
shape="round"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- CONTROL BUTTONS -->
|
|
||||||
|
<div>
|
||||||
|
{#await currentMemoryAssetFull then asset}
|
||||||
|
{#if asset}
|
||||||
|
<IconButton
|
||||||
|
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
|
||||||
|
icon={mdiImageSearch}
|
||||||
|
aria-label={$t('view_in_timeline')}
|
||||||
|
color="secondary"
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- CONTROL BUTTONS -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 pointer-events-none"
|
||||||
|
style:view-transition-name={showNavButtonOverlay ? 'memory-nav-buttons' : undefined}
|
||||||
|
>
|
||||||
{#if current.previous}
|
{#if current.previous}
|
||||||
<div class="absolute top-1/2 start-0 ms-4 dark">
|
<div class="absolute top-1/2 inset-s-0 ms-4 dark pointer-events-auto">
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
aria-label={$t('previous_memory')}
|
aria-label={$t('previous_memory')}
|
||||||
@@ -578,7 +778,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if current.next}
|
{#if current.next}
|
||||||
<div class="absolute top-1/2 end-0 me-4 dark">
|
<div class="absolute top-1/2 inset-e-0 me-4 dark pointer-events-auto">
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
aria-label={$t('next_memory')}
|
aria-label={$t('next_memory')}
|
||||||
@@ -590,25 +790,32 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="absolute start-8 top-4 text-sm font-medium text-white">
|
<div
|
||||||
<p>
|
class="absolute start-8 top-4 text-sm font-medium text-white"
|
||||||
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
style:view-transition-name={showTransitionOverlays ? 'memory-overlay' : undefined}
|
||||||
locale: $locale,
|
>
|
||||||
})}
|
<p>
|
||||||
</p>
|
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
||||||
<p>
|
locale: $locale,
|
||||||
{#await currentMemoryAssetFull then asset}
|
})}
|
||||||
{asset?.exifInfo?.city || ''}
|
</p>
|
||||||
{asset?.exifInfo?.country || ''}
|
<p>
|
||||||
{/await}
|
{#await currentMemoryAssetFull then asset}
|
||||||
</p>
|
{asset?.exifInfo?.city || ''}
|
||||||
</div>
|
{asset?.exifInfo?.country || ''}
|
||||||
|
{/await}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- NEXT MEMORY -->
|
<!-- NEXT MEMORY -->
|
||||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
<div
|
||||||
|
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.nextMemory
|
||||||
|
? ''
|
||||||
|
: 'opacity-0!'}"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative h-full w-full rounded-2xl"
|
class="relative h-full w-full rounded-2xl"
|
||||||
@@ -621,6 +828,7 @@
|
|||||||
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||||
alt={$t('next_memory')}
|
alt={$t('next_memory')}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
style:view-transition-name={transition.nextPanel}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<enhanced:img
|
<enhanced:img
|
||||||
@@ -633,7 +841,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if current.nextMemory}
|
{#if current.nextMemory}
|
||||||
<div class="absolute bottom-4 start-4 text-start text-white">
|
<div
|
||||||
|
class="absolute bottom-4 start-4 text-start text-white"
|
||||||
|
style:view-transition-name={transition.active ? 'memory-overlay-next' : undefined}
|
||||||
|
>
|
||||||
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
|
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
|
||||||
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
|
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -677,8 +888,6 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.main-view {
|
.main-view {
|
||||||
box-shadow:
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
|
||||||
0 4px 4px 0 rgba(0, 0, 0, 0.3),
|
|
||||||
0 8px 12px 6px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import SkipLink from '$lib/elements/SkipLink.svelte';
|
import SkipLink from '$lib/elements/SkipLink.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getGlobalActions } from '$lib/services/app.service';
|
import { getGlobalActions } from '$lib/services/app.service';
|
||||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||||
@@ -27,29 +28,43 @@
|
|||||||
onUploadClick?: () => void;
|
onUploadClick?: () => void;
|
||||||
// TODO: remove once this is only used in <AppShellHeader>
|
// TODO: remove once this is only used in <AppShellHeader>
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { onUploadClick, noBorder = false }: Props = $props();
|
let { onUploadClick, noBorder = false, hidden = false }: Props = $props();
|
||||||
|
let viewTransitionName = $state<string | undefined>();
|
||||||
let shouldShowAccountInfoPanel = $state(false);
|
let shouldShowAccountInfoPanel = $state(false);
|
||||||
let shouldShowNotificationPanel = $state(false);
|
let shouldShowNotificationPanel = $state(false);
|
||||||
let innerWidth: number = $state(0);
|
let innerWidth: number = $state(0);
|
||||||
const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0);
|
const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0);
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
await notificationManager.refresh();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load notifications on mount', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { Cast } = $derived(getGlobalActions($t));
|
const { Cast } = $derived(getGlobalActions($t));
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void notificationManager.refresh().catch((error) => console.error('Failed to load notifications on mount', error));
|
||||||
|
|
||||||
|
return viewTransitionManager.on({
|
||||||
|
PrepareOldSnapshot: (types) => {
|
||||||
|
if (types.includes('viewer')) {
|
||||||
|
viewTransitionName = 'exclude';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PrepareNewSnapshot: (types) => {
|
||||||
|
viewTransitionName = types.includes('timeline') ? 'exclude' : undefined;
|
||||||
|
},
|
||||||
|
Finished: () => {
|
||||||
|
viewTransitionName = undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window bind:innerWidth />
|
<svelte:window bind:innerWidth />
|
||||||
|
|
||||||
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
|
<nav
|
||||||
|
id="dashboard-navbar"
|
||||||
|
class={['max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm', hidden && 'invisible']}
|
||||||
|
style:view-transition-name={viewTransitionName}
|
||||||
|
>
|
||||||
<SkipLink text={$t('skip_to_content')} />
|
<SkipLink text={$t('skip_to_content')} />
|
||||||
<div
|
<div
|
||||||
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
|
import { filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.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 { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
@@ -12,10 +11,11 @@
|
|||||||
let { isUploading } = uploadAssetsStore;
|
let { isUploading } = uploadAssetsStore;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
heroTransitionAssetId?: string | null;
|
||||||
|
suspendTransitions?: boolean;
|
||||||
viewerAssets: ViewerAsset[];
|
viewerAssets: ViewerAsset[];
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
manager: VirtualScrollManager;
|
|
||||||
thumbnail: Snippet<
|
thumbnail: Snippet<
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -27,9 +27,17 @@
|
|||||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
|
const {
|
||||||
|
heroTransitionAssetId,
|
||||||
|
suspendTransitions = false,
|
||||||
|
viewerAssets,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
thumbnail,
|
||||||
|
customThumbnailLayout,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
const transitionDuration = $derived(suspendTransitions && !$isUploading ? 0 : 150);
|
||||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -38,11 +46,13 @@
|
|||||||
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
|
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||||
{@const position = viewerAsset.position!}
|
{@const position = viewerAsset.position!}
|
||||||
{@const asset = viewerAsset.asset!}
|
{@const asset = viewerAsset.asset!}
|
||||||
|
{@const transitionName = heroTransitionAssetId === asset.id ? 'hero' : undefined}
|
||||||
|
|
||||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||||
<div
|
<div
|
||||||
data-asset-id={asset.id}
|
data-asset-id={asset.id}
|
||||||
class="absolute"
|
class="absolute"
|
||||||
|
style:view-transition-name={transitionName}
|
||||||
style:top={position.top + 'px'}
|
style:top={position.top + 'px'}
|
||||||
style:inset-inline-start={position.left + 'px'}
|
style:inset-inline-start={position.left + 'px'}
|
||||||
style:width={position.width + 'px'}
|
style:width={position.width + 'px'}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||||
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
|
import { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
|
||||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
|
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { assetsSnapshot, filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
|
import { assetsSnapshot, filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||||
import type { Snippet } from 'svelte';
|
import { onMount, tick, type Snippet } from 'svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
toViewerHeroAssetId?: string | null;
|
||||||
thumbnail: Snippet<
|
thumbnail: Snippet<
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -28,16 +32,16 @@
|
|||||||
singleSelect: boolean;
|
singleSelect: boolean;
|
||||||
assetInteraction: AssetMultiSelectManager;
|
assetInteraction: AssetMultiSelectManager;
|
||||||
timelineMonth: TimelineMonth;
|
timelineMonth: TimelineMonth;
|
||||||
manager: VirtualScrollManager;
|
|
||||||
onTimelineDaySelect: (timelineDay: TimelineDay, assets: TimelineAsset[]) => void;
|
onTimelineDaySelect: (timelineDay: TimelineDay, assets: TimelineAsset[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
toViewerHeroAssetId,
|
||||||
thumbnail: thumbnailWithGroup,
|
thumbnail: thumbnailWithGroup,
|
||||||
customThumbnailLayout,
|
customThumbnailLayout,
|
||||||
singleSelect,
|
singleSelect,
|
||||||
assetInteraction,
|
assetInteraction,
|
||||||
timelineMonth,
|
timelineMonth,
|
||||||
manager,
|
|
||||||
onTimelineDaySelect,
|
onTimelineDaySelect,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -55,6 +59,32 @@
|
|||||||
});
|
});
|
||||||
return getDateLocaleString(date);
|
return getDateLocaleString(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let toTimelineHeroAssetId = $state<string | null>(null);
|
||||||
|
let heroTransitionAssetId = $derived(toTimelineHeroAssetId ?? toViewerHeroAssetId ?? null);
|
||||||
|
|
||||||
|
const handleViewerCloseTransition = ({ id }: { id: string }) => {
|
||||||
|
const asset = timelineMonth.findAssetById({ id });
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void viewTransitionManager.startTransition({
|
||||||
|
types: ['timeline'],
|
||||||
|
performUpdate: async () => {
|
||||||
|
assetViewerManager.emit('ViewerCloseTransitionReady');
|
||||||
|
const event = await eventManager.untilNext('TimelineLoaded');
|
||||||
|
toTimelineHeroAssetId = event.id;
|
||||||
|
await tick();
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
toTimelineHeroAssetId = null;
|
||||||
|
focusAsset(asset.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (viewTransitionManager.isSupported()) {
|
||||||
|
onMount(() => assetViewerManager.on({ ViewerCloseTransition: handleViewerCloseTransition }));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each filterIsInOrNearViewport(timelineMonth.timelineDays) as timelineDay, groupIndex (timelineDay.day)}
|
{#each filterIsInOrNearViewport(timelineMonth.timelineDays) as timelineDay, groupIndex (timelineDay.day)}
|
||||||
@@ -99,7 +129,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssetLayout
|
<AssetLayout
|
||||||
{manager}
|
{heroTransitionAssetId}
|
||||||
|
suspendTransitions={timelineMonth.timelineManager.suspendTransitions}
|
||||||
viewerAssets={timelineDay.viewerAssets}
|
viewerAssets={timelineDay.viewerAssets}
|
||||||
height={timelineDay.height}
|
height={timelineDay.height}
|
||||||
width={timelineDay.width}
|
width={timelineDay.width}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
invisible: boolean;
|
||||||
/** Offset from the top of the timeline (e.g., for headers) */
|
/** Offset from the top of the timeline (e.g., for headers) */
|
||||||
timelineTopOffset?: number;
|
timelineTopOffset?: number;
|
||||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
invisible = false,
|
||||||
timelineTopOffset = 0,
|
timelineTopOffset = 0,
|
||||||
timelineBottomOffset = 0,
|
timelineBottomOffset = 0,
|
||||||
height = 0,
|
height = 0,
|
||||||
@@ -509,6 +511,7 @@
|
|||||||
aria-valuemin={toScrollY(0)}
|
aria-valuemin={toScrollY(0)}
|
||||||
data-id="scrubber"
|
data-id="scrubber"
|
||||||
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
||||||
|
class:invisible
|
||||||
style:padding-top={PADDING_TOP + 'px'}
|
style:padding-top={PADDING_TOP + 'px'}
|
||||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||||
style:width
|
style:width
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||||
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import { startViewerTransition } from '$lib/utils/transition-utils';
|
||||||
import type { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
|
import type { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
|
||||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
|
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
|
||||||
@@ -99,6 +101,7 @@
|
|||||||
// Overall scroll percentage through the entire timeline (0-1)
|
// Overall scroll percentage through the entire timeline (0-1)
|
||||||
let timelineScrollPercent: number = $state(0);
|
let timelineScrollPercent: number = $state(0);
|
||||||
let scrubberWidth = $state(0);
|
let scrubberWidth = $state(0);
|
||||||
|
let toViewerHeroAssetId = $state<string | null>(null);
|
||||||
|
|
||||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||||
const maxMd = $derived(mediaQueryManager.maxMd);
|
const maxMd = $derived(mediaQueryManager.maxMd);
|
||||||
@@ -207,7 +210,7 @@
|
|||||||
timelineManager.viewportWidth = rect.width;
|
timelineManager.viewportWidth = rect.width;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const scrollTarget = assetViewerManager.gridScrollTarget?.at;
|
const scrollTarget = getScrollTarget();
|
||||||
let scrolled = false;
|
let scrolled = false;
|
||||||
if (scrollTarget) {
|
if (scrollTarget) {
|
||||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||||
@@ -219,7 +222,7 @@
|
|||||||
await tick();
|
await tick();
|
||||||
focusAsset(scrollTarget);
|
focusAsset(scrollTarget);
|
||||||
}
|
}
|
||||||
invisible = false;
|
invisible = isAssetViewerRoute(page) ? true : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// note: only modified once in afterNavigate()
|
// note: only modified once in afterNavigate()
|
||||||
@@ -237,10 +240,13 @@
|
|||||||
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
|
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getScrollTarget = () => {
|
||||||
|
return assetViewerManager.gridScrollTarget?.at ?? page.params.assetId ?? null;
|
||||||
|
};
|
||||||
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
|
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
|
||||||
// after successful navigation.
|
// after successful navigation.
|
||||||
afterNavigate(({ complete }) => {
|
afterNavigate(({ complete }) => {
|
||||||
void complete.finally(() => {
|
void complete.finally(async () => {
|
||||||
const isAssetViewerPage = isAssetViewerRoute(page);
|
const isAssetViewerPage = isAssetViewerRoute(page);
|
||||||
|
|
||||||
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
||||||
@@ -251,6 +257,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
void scrollAfterNavigate();
|
void scrollAfterNavigate();
|
||||||
|
if (!isAssetViewerPage) {
|
||||||
|
const scrollTarget = getScrollTarget();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
eventManager.emit('TimelineLoaded', { id: scrollTarget });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +270,7 @@
|
|||||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!enableRouting) {
|
if (!enableRouting && !isAssetViewerRoute(page)) {
|
||||||
invisible = false;
|
invisible = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -545,7 +557,7 @@
|
|||||||
assetInteraction.selectAll = timelineManager.assetCount === assetInteraction.assets.length;
|
assetInteraction.selectAll = timelineManager.assetCount === assetInteraction.assets.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _onClick = (
|
const defaultThumbnailClick = (
|
||||||
timelineManager: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
assets: TimelineAsset[],
|
assets: TimelineAsset[],
|
||||||
groupTitle: string,
|
groupTitle: string,
|
||||||
@@ -557,6 +569,25 @@
|
|||||||
}
|
}
|
||||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleThumbnailClick = (asset: TimelineAsset, timelineDay: TimelineDay) => {
|
||||||
|
if (typeof onThumbnailClick === 'function' || isSelectionMode || assetInteraction.selectionActive) {
|
||||||
|
if (typeof onThumbnailClick === 'function') {
|
||||||
|
onThumbnailClick(asset, timelineManager, timelineDay, defaultThumbnailClick);
|
||||||
|
} else {
|
||||||
|
defaultThumbnailClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openViewer = () => void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||||
|
startViewerTransition(
|
||||||
|
asset.id,
|
||||||
|
openViewer,
|
||||||
|
(id) => (toViewerHeroAssetId = id),
|
||||||
|
() => (toViewerHeroAssetId = null),
|
||||||
|
);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||||
@@ -587,6 +618,7 @@
|
|||||||
{#if timelineManager.months.length > 0}
|
{#if timelineManager.months.length > 0}
|
||||||
<Scrubber
|
<Scrubber
|
||||||
{timelineManager}
|
{timelineManager}
|
||||||
|
{invisible}
|
||||||
height={timelineManager.viewportHeight}
|
height={timelineManager.viewportHeight}
|
||||||
timelineTopOffset={timelineManager.topSectionHeight}
|
timelineTopOffset={timelineManager.topSectionHeight}
|
||||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||||
@@ -618,6 +650,7 @@
|
|||||||
id="asset-grid"
|
id="asset-grid"
|
||||||
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||||
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||||
|
data-initialized={timelineManager.isInitialized || undefined}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
bind:clientHeight={timelineManager.viewportHeight}
|
bind:clientHeight={timelineManager.viewportHeight}
|
||||||
bind:clientWidth={timelineManager.viewportWidth}
|
bind:clientWidth={timelineManager.viewportWidth}
|
||||||
@@ -666,11 +699,11 @@
|
|||||||
style:width="100%"
|
style:width="100%"
|
||||||
>
|
>
|
||||||
<Month
|
<Month
|
||||||
|
{toViewerHeroAssetId}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
{customThumbnailLayout}
|
{customThumbnailLayout}
|
||||||
{singleSelect}
|
{singleSelect}
|
||||||
{timelineMonth}
|
{timelineMonth}
|
||||||
manager={timelineManager}
|
|
||||||
onTimelineDaySelect={handleGroupSelect}
|
onTimelineDaySelect={handleGroupSelect}
|
||||||
>
|
>
|
||||||
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
|
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
|
||||||
@@ -684,13 +717,7 @@
|
|||||||
{asset}
|
{asset}
|
||||||
{albumUsers}
|
{albumUsers}
|
||||||
{groupIndex}
|
{groupIndex}
|
||||||
onClick={(asset) => {
|
onClick={(asset) => handleThumbnailClick(asset, timelineDay)}
|
||||||
if (typeof onThumbnailClick === 'function') {
|
|
||||||
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
|
|
||||||
} else {
|
|
||||||
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||||
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
|
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
@@ -97,6 +98,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = async (asset: { id: string }) => {
|
const handleClose = async (asset: { id: string }) => {
|
||||||
|
if (viewTransitionManager.isSupported()) {
|
||||||
|
const transitionReady = assetViewerManager.untilNext('ViewerCloseTransitionReady');
|
||||||
|
assetViewerManager.emit('ViewerCloseTransition', { id: asset.id });
|
||||||
|
await transitionReady;
|
||||||
|
}
|
||||||
|
|
||||||
invisible = true;
|
invisible = true;
|
||||||
assetViewerManager.gridScrollTarget = { at: asset.id };
|
assetViewerManager.gridScrollTarget = { at: asset.id };
|
||||||
await navigate({
|
await navigate({
|
||||||
|
|||||||
327
web/src/lib/managers/ViewTransitionManager.svelte.spec.ts
Normal file
327
web/src/lib/managers/ViewTransitionManager.svelte.spec.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { ViewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
|
|
||||||
|
function mockViewTransition({
|
||||||
|
updateCallbackDone = Promise.resolve(),
|
||||||
|
finished = Promise.resolve(),
|
||||||
|
ready = Promise.resolve(),
|
||||||
|
skipTransition = vi.fn(),
|
||||||
|
}: {
|
||||||
|
updateCallbackDone?: Promise<void>;
|
||||||
|
finished?: Promise<void>;
|
||||||
|
ready?: Promise<void>;
|
||||||
|
skipTransition?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {}) {
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
void updateFn();
|
||||||
|
return { updateCallbackDone, finished, ready, skipTransition };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ViewTransitionManager', () => {
|
||||||
|
let manager: ViewTransitionManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new ViewTransitionManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (document as Partial<typeof document> & { startViewTransition?: unknown }).startViewTransition;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when View Transition API is not supported', () => {
|
||||||
|
it('should still call performUpdate', async () => {
|
||||||
|
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate });
|
||||||
|
|
||||||
|
expect(performUpdate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onFinished after performUpdate', async () => {
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
const performUpdate = vi.fn().mockImplementation(() => {
|
||||||
|
callOrder.push('performUpdate');
|
||||||
|
});
|
||||||
|
const onFinished = vi.fn().mockImplementation(() => {
|
||||||
|
callOrder.push('onFinished');
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate, onFinished });
|
||||||
|
|
||||||
|
expect(onFinished).toHaveBeenCalledOnce();
|
||||||
|
expect(callOrder).toEqual(['performUpdate', 'onFinished']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call prepareOldSnapshot or prepareNewSnapshot', async () => {
|
||||||
|
const prepareOldSnapshot = vi.fn();
|
||||||
|
const prepareNewSnapshot = vi.fn();
|
||||||
|
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate, prepareOldSnapshot, prepareNewSnapshot });
|
||||||
|
|
||||||
|
expect(prepareOldSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(prepareNewSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when a transition is already active', () => {
|
||||||
|
it('should skip the first transition and run the second', async () => {
|
||||||
|
let resolveFirstUpdate!: () => void;
|
||||||
|
const firstUpdateCallbackDone = new Promise<void>((resolve) => {
|
||||||
|
resolveFirstUpdate = resolve;
|
||||||
|
});
|
||||||
|
const firstFinished = new Promise<void>(() => {});
|
||||||
|
const firstSkipTransition = vi.fn();
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
callCount++;
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
void updateFn();
|
||||||
|
if (callCount === 1) {
|
||||||
|
return {
|
||||||
|
updateCallbackDone: firstUpdateCallbackDone,
|
||||||
|
finished: firstFinished,
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
skipTransition: firstSkipTransition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
updateCallbackDone: Promise.resolve(),
|
||||||
|
finished: Promise.resolve(),
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
skipTransition: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const firstPromise = manager.startTransition({
|
||||||
|
performUpdate: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
|
// While first is active, start a second — should skip the first and proceed
|
||||||
|
await manager.startTransition({ performUpdate: secondPerformUpdate });
|
||||||
|
expect(firstSkipTransition).toHaveBeenCalledOnce();
|
||||||
|
expect(secondPerformUpdate).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
resolveFirstUpdate();
|
||||||
|
await firstPromise;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('skipTransitions', () => {
|
||||||
|
it('should return false when no transition is active', () => {
|
||||||
|
expect(manager.skipTransitions()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call skipTransition on the active transition and return true', async () => {
|
||||||
|
let resolveFinished!: () => void;
|
||||||
|
const finished = new Promise<void>((resolve) => {
|
||||||
|
resolveFinished = resolve;
|
||||||
|
});
|
||||||
|
let resolveUpdate!: () => void;
|
||||||
|
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||||
|
resolveUpdate = resolve;
|
||||||
|
});
|
||||||
|
const skipTransition = vi.fn();
|
||||||
|
|
||||||
|
mockViewTransition({ updateCallbackDone, finished, skipTransition });
|
||||||
|
|
||||||
|
const promise = manager.startTransition({ performUpdate: async () => {} });
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
|
const skipped = manager.skipTransitions();
|
||||||
|
expect(skipped).toBe(true);
|
||||||
|
expect(skipTransition).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
resolveUpdate();
|
||||||
|
resolveFinished();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow a new transition after skipping', async () => {
|
||||||
|
let resolveFinished!: () => void;
|
||||||
|
const finished = new Promise<void>((resolve) => {
|
||||||
|
resolveFinished = resolve;
|
||||||
|
});
|
||||||
|
let resolveUpdate!: () => void;
|
||||||
|
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||||
|
resolveUpdate = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockViewTransition({ updateCallbackDone, finished });
|
||||||
|
|
||||||
|
const promise = manager.startTransition({ performUpdate: async () => {} });
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
|
manager.skipTransitions();
|
||||||
|
resolveUpdate();
|
||||||
|
resolveFinished();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
const secondUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
mockViewTransition({ updateCallbackDone: Promise.resolve(), finished: Promise.resolve() });
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate: secondUpdate });
|
||||||
|
expect(secondUpdate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should propagate error from performUpdate when API is not supported', async () => {
|
||||||
|
const error = new Error('update failed');
|
||||||
|
const performUpdate = vi.fn().mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(manager.startTransition({ performUpdate })).rejects.toThrow('update failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up activeViewTransition when performUpdate throws (API supported)', async () => {
|
||||||
|
const error = new Error('update failed');
|
||||||
|
let resolveFinished!: () => void;
|
||||||
|
const finished = new Promise<void>((resolve) => {
|
||||||
|
resolveFinished = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
const updateCallbackDone = updateFn();
|
||||||
|
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.startTransition({ performUpdate: () => Promise.reject(error) })).rejects.toThrow(
|
||||||
|
'update failed',
|
||||||
|
);
|
||||||
|
|
||||||
|
resolveFinished();
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
|
const secondUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
mockViewTransition();
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate: secondUpdate });
|
||||||
|
expect(secondUpdate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallback path', () => {
|
||||||
|
it('should fall back to function argument when object argument throws', async () => {
|
||||||
|
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const prepareNewSnapshot = vi.fn();
|
||||||
|
const finished = Promise.resolve();
|
||||||
|
const updateCallbackDone = Promise.resolve();
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1 && typeof arg !== 'function') {
|
||||||
|
throw new TypeError('object form not supported');
|
||||||
|
}
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
void updateFn();
|
||||||
|
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate, prepareNewSnapshot, types: ['test'] });
|
||||||
|
|
||||||
|
expect(performUpdate).toHaveBeenCalledOnce();
|
||||||
|
expect(prepareNewSnapshot).toHaveBeenCalledOnce();
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
expect(document.startViewTransition).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('abort signal', () => {
|
||||||
|
it('should pass an AbortSignal to performUpdate', async () => {
|
||||||
|
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
mockViewTransition();
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate });
|
||||||
|
|
||||||
|
expect(performUpdate).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should abort the signal when transition.ready rejects', async () => {
|
||||||
|
let capturedSignal: AbortSignal | undefined;
|
||||||
|
let resolveUpdate!: () => void;
|
||||||
|
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||||
|
resolveUpdate = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const readyError = new Error('Transition was aborted because of timeout in DOM update');
|
||||||
|
|
||||||
|
mockViewTransition({
|
||||||
|
updateCallbackDone,
|
||||||
|
finished: Promise.reject(readyError),
|
||||||
|
ready: Promise.reject(readyError),
|
||||||
|
});
|
||||||
|
|
||||||
|
const performUpdate = vi.fn().mockImplementation((signal: AbortSignal) => {
|
||||||
|
capturedSignal = signal;
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = manager.startTransition({ performUpdate });
|
||||||
|
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
|
expect(capturedSignal?.aborted).toBe(true);
|
||||||
|
|
||||||
|
resolveUpdate();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not abort the signal when transition completes normally', async () => {
|
||||||
|
let capturedSignal: AbortSignal | undefined;
|
||||||
|
|
||||||
|
mockViewTransition();
|
||||||
|
|
||||||
|
await manager.startTransition({
|
||||||
|
performUpdate: (signal) => {
|
||||||
|
capturedSignal = signal;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedSignal?.aborted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass a non-aborted signal in the unsupported fallback path', async () => {
|
||||||
|
let capturedSignal: AbortSignal | undefined;
|
||||||
|
|
||||||
|
await manager.startTransition({
|
||||||
|
performUpdate: (signal) => {
|
||||||
|
capturedSignal = signal;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||||
|
expect(capturedSignal?.aborted).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSupported', () => {
|
||||||
|
it('should return false when startViewTransition is not in document', () => {
|
||||||
|
expect(manager.isSupported()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when startViewTransition is in document', () => {
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn();
|
||||||
|
|
||||||
|
expect(manager.isSupported()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
web/src/lib/managers/ViewTransitionManager.svelte.ts
Normal file
102
web/src/lib/managers/ViewTransitionManager.svelte.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
type TransitionEvents = {
|
||||||
|
PrepareOldSnapshot: [string[]];
|
||||||
|
PrepareNewSnapshot: [string[]];
|
||||||
|
Finished: [string[]];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TransitionRequest {
|
||||||
|
types?: string[];
|
||||||
|
prepareOldSnapshot?: () => void;
|
||||||
|
performUpdate: (signal: AbortSignal) => Promise<void>;
|
||||||
|
prepareNewSnapshot?: () => void;
|
||||||
|
onFinished?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewTransitionManager extends BaseEventManager<TransitionEvents> {
|
||||||
|
#activeViewTransition: ViewTransition | null = null;
|
||||||
|
#activeOnFinished: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
|
isSupported() {
|
||||||
|
return 'startViewTransition' in document;
|
||||||
|
}
|
||||||
|
|
||||||
|
skipTransitions() {
|
||||||
|
const skipped = !!this.#activeViewTransition;
|
||||||
|
this.#activeViewTransition?.skipTransition();
|
||||||
|
this.#activeViewTransition = null;
|
||||||
|
const onFinished = this.#activeOnFinished;
|
||||||
|
this.#activeOnFinished = undefined;
|
||||||
|
onFinished?.();
|
||||||
|
return skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startTransition({
|
||||||
|
types,
|
||||||
|
prepareOldSnapshot,
|
||||||
|
performUpdate,
|
||||||
|
prepareNewSnapshot,
|
||||||
|
onFinished,
|
||||||
|
}: TransitionRequest) {
|
||||||
|
if (this.#activeViewTransition) {
|
||||||
|
this.skipTransitions();
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedTypes = types ?? [];
|
||||||
|
|
||||||
|
if (!this.isSupported()) {
|
||||||
|
await performUpdate(AbortSignal.timeout(10_000));
|
||||||
|
onFinished?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('PrepareOldSnapshot', resolvedTypes);
|
||||||
|
prepareOldSnapshot?.();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const update = async () => {
|
||||||
|
await performUpdate(abortController.signal);
|
||||||
|
this.emit('PrepareNewSnapshot', resolvedTypes);
|
||||||
|
prepareNewSnapshot?.();
|
||||||
|
await tick();
|
||||||
|
};
|
||||||
|
|
||||||
|
let transition: ViewTransition;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
transition = document.startViewTransition({ update, types });
|
||||||
|
} catch {
|
||||||
|
// Fallback: browsers supporting VT Level 1 but not Level 2 (object form with types) will throw
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
transition = document.startViewTransition(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#activeViewTransition = transition;
|
||||||
|
this.#activeOnFinished = onFinished;
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
void transition.ready.catch((error: unknown) => {
|
||||||
|
abortController.abort(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
void transition.finished
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.#activeViewTransition === transition) {
|
||||||
|
this.#activeViewTransition = null;
|
||||||
|
this.#activeOnFinished = undefined;
|
||||||
|
this.emit('Finished', resolvedTypes);
|
||||||
|
onFinished?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
await transition.updateCallbackDone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewTransitionManager = new ViewTransitionManager();
|
||||||
@@ -23,12 +23,17 @@ export type Events = {
|
|||||||
Zoom: [];
|
Zoom: [];
|
||||||
ZoomChange: [ZoomImageWheelState];
|
ZoomChange: [ZoomImageWheelState];
|
||||||
Copy: [];
|
Copy: [];
|
||||||
|
ViewerOpenTransitionReady: [];
|
||||||
|
ViewerOpenTransition: [];
|
||||||
|
ViewerCloseTransition: [{ id: string }];
|
||||||
|
ViewerCloseTransitionReady: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
class AssetViewerManager extends BaseEventManager<Events> {
|
class AssetViewerManager extends BaseEventManager<Events> {
|
||||||
#zoomState = $state(createDefaultZoomState());
|
#zoomState = $state(createDefaultZoomState());
|
||||||
#animationFrameId: number | null = null;
|
#animationFrameId: number | null = null;
|
||||||
|
|
||||||
|
transitionName = $state<string | undefined>();
|
||||||
imgRef = $state<HTMLImageElement | undefined>();
|
imgRef = $state<HTMLImageElement | undefined>();
|
||||||
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
|
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
|
||||||
#isImageLoading = $derived.by(() => {
|
#isImageLoading = $derived.by(() => {
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export type Events = {
|
|||||||
ReleaseEvent: [ReleaseEvent];
|
ReleaseEvent: [ReleaseEvent];
|
||||||
|
|
||||||
WebsocketConnect: [];
|
WebsocketConnect: [];
|
||||||
|
|
||||||
|
TimelineLoaded: [{ id: string | null }];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const eventManager = new BaseEventManager<Events>();
|
export const eventManager = new BaseEventManager<Events>();
|
||||||
|
|||||||
@@ -43,6 +43,50 @@ export class BaseEventManager<Events extends EventsBase> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private once<T extends keyof Events>(event: T, callback: EventCallback<Events, T>) {
|
||||||
|
const unsubscribe = this.#onEvent(event, (...args: Events[T]) => {
|
||||||
|
unsubscribe();
|
||||||
|
return callback(...args);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
untilNext<T extends keyof Events>(
|
||||||
|
event: T,
|
||||||
|
{ timeoutMs = 10_000, signal }: { timeoutMs?: number; signal?: AbortSignal } = {},
|
||||||
|
): Promise<Events[T] extends [] ? void : Events[T][0]> {
|
||||||
|
type Result = Events[T] extends [] ? void : Events[T][0];
|
||||||
|
return new Promise<Result>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const settle = () => {
|
||||||
|
if (settled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
unsubscribe();
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const unsubscribe = this.once(event, (...args: Events[T]) => {
|
||||||
|
if (settle()) {
|
||||||
|
resolve(args[0] as Result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settle()) {
|
||||||
|
reject(new Error(`untilNext('${String(event)}') timed out after ${timeoutMs}ms`));
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
const onAbort = () => {
|
||||||
|
if (settle()) {
|
||||||
|
resolve(undefined as Result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
||||||
const listeners = this.getListeners(event);
|
const listeners = this.getListeners(event);
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
|
|||||||
104
web/src/lib/utils/transition-utils.ts
Normal file
104
web/src/lib/utils/transition-utils.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
function startHeroTransition(
|
||||||
|
type: string,
|
||||||
|
heroAssetId: string,
|
||||||
|
openViewer: () => void,
|
||||||
|
activateHeroAsset: (assetId: string) => void,
|
||||||
|
deactivateHeroAsset: () => void,
|
||||||
|
) {
|
||||||
|
void viewTransitionManager.startTransition({
|
||||||
|
types: [type],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
activateHeroAsset(heroAssetId);
|
||||||
|
},
|
||||||
|
performUpdate: async (signal) => {
|
||||||
|
deactivateHeroAsset();
|
||||||
|
const ready = assetViewerManager.untilNext('ViewerOpenTransitionReady', { signal });
|
||||||
|
openViewer();
|
||||||
|
await ready;
|
||||||
|
assetViewerManager.emit('ViewerOpenTransition');
|
||||||
|
await tick();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startViewerTransition(
|
||||||
|
heroAssetId: string,
|
||||||
|
openViewer: () => void,
|
||||||
|
activateHeroAsset: (assetId: string) => void,
|
||||||
|
deactivateHeroAsset: () => void,
|
||||||
|
) {
|
||||||
|
startHeroTransition('viewer', heroAssetId, openViewer, activateHeroAsset, deactivateHeroAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startMemoryTransition(
|
||||||
|
heroAssetId: string,
|
||||||
|
openViewer: () => void,
|
||||||
|
activateHeroAsset: (assetId: string) => void,
|
||||||
|
deactivateHeroAsset: () => void,
|
||||||
|
) {
|
||||||
|
startHeroTransition('memory-enter', heroAssetId, openViewer, activateHeroAsset, deactivateHeroAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeOverlay: HTMLElement | undefined;
|
||||||
|
|
||||||
|
export function removeCrossfadeOverlay() {
|
||||||
|
if (activeOverlay) {
|
||||||
|
activeOverlay.remove();
|
||||||
|
activeOverlay = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function crossfadeViewerContent(updateFn: () => void | Promise<void>, duration = 200) {
|
||||||
|
const viewerContent = document.querySelector<HTMLElement>('[data-viewer-content]');
|
||||||
|
if (!viewerContent) {
|
||||||
|
await updateFn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCrossfadeOverlay();
|
||||||
|
|
||||||
|
const clone = viewerContent.cloneNode(true) as HTMLElement;
|
||||||
|
Object.assign(clone.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '0',
|
||||||
|
zIndex: '1',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
delete clone.dataset.viewerContent;
|
||||||
|
if (!viewerContent.parentElement) {
|
||||||
|
await updateFn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewerContent.parentElement.append(clone);
|
||||||
|
activeOverlay = clone;
|
||||||
|
|
||||||
|
const ready = eventManager.untilNext('ViewerOpenTransitionReady');
|
||||||
|
await updateFn();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ready;
|
||||||
|
} catch {
|
||||||
|
clone.remove();
|
||||||
|
if (activeOverlay === clone) {
|
||||||
|
activeOverlay = undefined;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fadeOut = clone.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||||
|
duration,
|
||||||
|
easing: 'cubic-bezier(0.4, 0, 1, 1)',
|
||||||
|
fill: 'forwards',
|
||||||
|
});
|
||||||
|
|
||||||
|
void fadeOut.finished.then(() => {
|
||||||
|
clone.remove();
|
||||||
|
if (activeOverlay === clone) {
|
||||||
|
activeOverlay = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class:display-none={assetViewerManager.isViewing}>
|
<div class:invisible={assetViewerManager.isViewing}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
<UploadCover />
|
<UploadCover />
|
||||||
@@ -31,7 +31,4 @@
|
|||||||
:root {
|
:root {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
.display-none {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
@@ -36,8 +37,9 @@
|
|||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { startMemoryTransition } from '$lib/utils/transition-utils';
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel, type CarouselImageItem } from '@immich/ui';
|
||||||
import { mdiDotsVertical } from '@mdi/js';
|
import { mdiDotsVertical } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
@@ -90,6 +92,17 @@
|
|||||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let memoryTransitionId = $state<string | null>(null);
|
||||||
|
|
||||||
|
const handleMemoryCardClick = (item: CarouselImageItem) => {
|
||||||
|
startMemoryTransition(
|
||||||
|
item.id ?? item.href,
|
||||||
|
() => void goto(item.href),
|
||||||
|
(id) => (memoryTransitionId = id),
|
||||||
|
() => (memoryTransitionId = null),
|
||||||
|
);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
|
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
|
||||||
@@ -103,7 +116,33 @@
|
|||||||
withStacked
|
withStacked
|
||||||
>
|
>
|
||||||
{#if authManager.preferences.memories.enabled}
|
{#if authManager.preferences.memories.enabled}
|
||||||
<ImageCarousel {items} />
|
{#snippet memoryCard(item: CarouselImageItem)}
|
||||||
|
<a
|
||||||
|
class="relative me-2 inline-block aspect-3/4 h-54 rounded-xl last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video"
|
||||||
|
href={item.href}
|
||||||
|
data-memory-id={item.id}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMemoryCardClick(item);
|
||||||
|
}}
|
||||||
|
style:box-shadow="rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="h-full w-full rounded-xl object-cover"
|
||||||
|
src={item.src}
|
||||||
|
alt={item.alt ?? item.title}
|
||||||
|
draggable="false"
|
||||||
|
style:view-transition-name={memoryTransitionId === (item.id ?? item.href) ? 'hero' : undefined}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-s-0 top-0 h-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
|
||||||
|
></div>
|
||||||
|
<p class="absolute inset-s-4 bottom-2 text-lg text-white max-md:text-sm">
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
<ImageCarousel {items} child={memoryCard} />
|
||||||
{/if}
|
{/if}
|
||||||
{#snippet empty()}
|
{#snippet empty()}
|
||||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />
|
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />
|
||||||
|
|||||||
Reference in New Issue
Block a user