Compare commits

..

1 Commits

Author SHA1 Message Date
mertalev 02addf7491 bump player 2026-03-03 20:04:55 -05:00
82 changed files with 1134 additions and 2590 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"scripts": { "scripts": {
"format": "prettier --cache --check .", "format": "prettier --check .",
"format:fix": "prettier --cache --write --list-different ." "format:fix": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.7.4" "prettier": "^3.7.4"
+1 -8
View File
@@ -5,13 +5,6 @@
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"dart-code.flutter", "dart-code.flutter",
"dart-code.dart-code", "dart-code.dart-code",
"dcmdev.dcm-vscode-extension", "dcmdev.dcm-vscode-extension"
"bradlc.vscode-tailwindcss",
"ms-playwright.playwright",
"vitest.explorer",
"editorconfig.editorconfig",
"foxundermoon.shell-format",
"timonwong.shellcheck",
"bluebrown.yamlfmt"
] ]
} }
+1 -33
View File
@@ -15,7 +15,6 @@
}, },
"[javascript]": { "[javascript]": {
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit", "source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit" "source.removeUnusedImports": "explicit"
}, },
@@ -35,7 +34,6 @@
}, },
"[svelte]": { "[svelte]": {
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit", "source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit" "source.removeUnusedImports": "explicit"
}, },
@@ -45,7 +43,6 @@
}, },
"[typescript]": { "[typescript]": {
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit", "source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit" "source.removeUnusedImports": "explicit"
}, },
@@ -54,43 +51,14 @@
"editor.tabSize": 2 "editor.tabSize": 2
}, },
"cSpell.words": ["immich"], "cSpell.words": ["immich"],
"css.lint.unknownAtRules": "ignore",
"editor.bracketPairColorization.enabled": true,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.guides.bracketPairs": "active",
"eslint.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "svelte"], "eslint.validate": ["javascript", "typescript", "svelte"],
"eslint.workingDirectories": [
{ "directory": "cli", "changeProcessCWD": true },
{ "directory": "e2e", "changeProcessCWD": true },
{ "directory": "server", "changeProcessCWD": true },
{ "directory": "web", "changeProcessCWD": true }
],
"files.watcherExclude": {
"**/.jj/**": true,
"**/.git/**": true,
"**/node_modules/**": true,
"**/build/**": true,
"**/dist/**": true,
"**/.svelte-kit/**": true
},
"explorer.fileNesting.enabled": true, "explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": { "explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart", "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts", "*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs" "package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
}, },
"search.exclude": {
"**/node_modules": true,
"**/build": true,
"**/dist": true,
"**/.svelte-kit": true,
"**/open-api/typescript-sdk/src": true
},
"svelte.enable-ts-plugin": true, "svelte.enable-ts-plugin": true,
"tailwindCSS.experimental.configFile": { "typescript.preferences.importModuleSpecifier": "non-relative"
"web/src/app.css": "web/src/**"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"vitest.maximumConfigs": 10
} }
-2
View File
@@ -15,8 +15,6 @@ Please try to keep pull requests as focused as possible. A PR should do exactly
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on! If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR.
## Use of generative AI ## Use of generative AI
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request. We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
+4 -4
View File
@@ -21,7 +21,7 @@
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.14", "@types/node": "^24.10.14",
"@vitest/coverage-v8": "^4.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"commander": "^12.0.0", "commander": "^12.0.0",
@@ -37,7 +37,7 @@
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
"vite": "^7.0.0", "vite": "^7.0.0",
"vite-tsconfig-paths": "^6.0.0", "vite-tsconfig-paths": "^6.0.0",
"vitest": "^4.0.0", "vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0", "vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
@@ -49,8 +49,8 @@
"prepack": "pnpm run build", "prepack": "pnpm run build",
"test": "vitest", "test": "vitest",
"test:cov": "vitest --coverage", "test:cov": "vitest --coverage",
"format": "prettier --cache --check .", "format": "prettier --check .",
"format:fix": "prettier --cache --write --list-different .", "format:fix": "prettier --write .",
"check": "tsc --noEmit" "check": "tsc --noEmit"
}, },
"repository": { "repository": {
+36 -45
View File
@@ -1,6 +1,6 @@
import fs from 'node:fs'; import * as fs from 'node:fs';
import os from 'node:os'; import * as os from 'node:os';
import path from 'node:path'; import * as path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises'; import { setTimeout as sleep } from 'node:timers/promises';
import { describe, expect, it, MockedFunction, vi } from 'vitest'; import { describe, expect, it, MockedFunction, vi } from 'vitest';
@@ -58,7 +58,7 @@ describe('uploadFiles', () => {
}); });
it('returns new assets when upload file is successful', async () => { it('returns new assets when upload file is successful', async () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () { fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
return { return {
status: 200, status: 200,
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }), body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
@@ -75,7 +75,7 @@ describe('uploadFiles', () => {
it('returns new assets when upload file retry is successful', async () => { it('returns new assets when upload file retry is successful', async () => {
let counter = 0; let counter = 0;
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () { fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
counter++; counter++;
if (counter < retry) { if (counter < retry) {
throw new Error('Network error'); throw new Error('Network error');
@@ -96,7 +96,7 @@ describe('uploadFiles', () => {
}); });
it('returns new assets when upload file retry is failed', async () => { it('returns new assets when upload file retry is failed', async () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () { fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
throw new Error('Network error'); throw new Error('Network error');
}); });
@@ -236,19 +236,16 @@ describe('startWatch', () => {
await sleep(100); // to debounce the watcher from considering the test file as a existing file await sleep(100); // to debounce the watcher from considering the test file as a existing file
await fs.promises.writeFile(testFilePath, 'testjpg'); await fs.promises.writeFile(testFilePath, 'testjpg');
await vi.waitFor( await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
() => expect(checkBulkUpload).toHaveBeenCalledWith({
expect(checkBulkUpload).toHaveBeenCalledWith({ assetBulkUploadCheckDto: {
assetBulkUploadCheckDto: { assets: [
assets: [ expect.objectContaining({
expect.objectContaining({ id: testFilePath,
id: testFilePath, }),
}), ],
], },
}, });
}),
{ timeout: 5000 },
);
}); });
it('should filter out unsupported files', async () => { it('should filter out unsupported files', async () => {
@@ -260,19 +257,16 @@ describe('startWatch', () => {
await fs.promises.writeFile(testFilePath, 'testjpg'); await fs.promises.writeFile(testFilePath, 'testjpg');
await fs.promises.writeFile(unsupportedFilePath, 'testtxt'); await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
await vi.waitFor( await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
() => expect(checkBulkUpload).toHaveBeenCalledWith({
expect(checkBulkUpload).toHaveBeenCalledWith({ assetBulkUploadCheckDto: {
assetBulkUploadCheckDto: { assets: expect.arrayContaining([
assets: expect.arrayContaining([ expect.objectContaining({
expect.objectContaining({ id: testFilePath,
id: testFilePath, }),
}), ]),
]), },
}, });
}),
{ timeout: 5000 },
);
expect(checkBulkUpload).not.toHaveBeenCalledWith({ expect(checkBulkUpload).not.toHaveBeenCalledWith({
assetBulkUploadCheckDto: { assetBulkUploadCheckDto: {
@@ -297,19 +291,16 @@ describe('startWatch', () => {
await fs.promises.writeFile(testFilePath, 'testjpg'); await fs.promises.writeFile(testFilePath, 'testjpg');
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg'); await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
await vi.waitFor( await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
() => expect(checkBulkUpload).toHaveBeenCalledWith({
expect(checkBulkUpload).toHaveBeenCalledWith({ assetBulkUploadCheckDto: {
assetBulkUploadCheckDto: { assets: expect.arrayContaining([
assets: expect.arrayContaining([ expect.objectContaining({
expect.objectContaining({ id: testFilePath,
id: testFilePath, }),
}), ]),
]), },
}, });
}),
{ timeout: 5000 },
);
expect(checkBulkUpload).not.toHaveBeenCalledWith({ expect(checkBulkUpload).not.toHaveBeenCalledWith({
assetBulkUploadCheckDto: { assetBulkUploadCheckDto: {
+2 -6
View File
@@ -1,4 +1,4 @@
import { defineConfig, UserConfig } from 'vite'; import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths'; import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ export default defineConfig({
@@ -17,8 +17,4 @@ export default defineConfig({
noExternal: /^(?!node:).*$/, noExternal: /^(?!node:).*$/,
}, },
plugins: [tsconfigPaths()], plugins: [tsconfigPaths()],
test: { });
name: 'cli:unit',
globals: true,
},
} as UserConfig);
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
},
});
+2 -2
View File
@@ -4,8 +4,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"docusaurus": "docusaurus", "docusaurus": "docusaurus",
"format": "prettier --cache --check .", "format": "prettier --check .",
"format:fix": "prettier --cache --write --list-different .", "format:fix": "prettier --write .",
"start": "docusaurus start --port 3005", "start": "docusaurus start --port 3005",
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0", "copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"build": "pnpm run copy:openapi && docusaurus build", "build": "pnpm run copy:openapi && docusaurus build",
+4 -5
View File
@@ -14,8 +14,8 @@
"start:web": "pnpm exec playwright test --ui --project=web", "start:web": "pnpm exec playwright test --ui --project=web",
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance", "start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
"start:web:ui": "pnpm exec playwright test --ui --project=ui", "start:web:ui": "pnpm exec playwright test --ui --project=ui",
"format": "prettier --cache --check .", "format": "prettier --check .",
"format:fix": "prettier --cache --write --list-different .", "format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0", "lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "pnpm run lint --fix", "lint:fix": "pnpm run lint --fix",
"check": "tsc --noEmit" "check": "tsc --noEmit"
@@ -27,7 +27,7 @@
"@eslint/js": "^10.0.0", "@eslint/js": "^10.0.0",
"@faker-js/faker": "^10.1.0", "@faker-js/faker": "^10.1.0",
"@immich/cli": "workspace:*", "@immich/cli": "workspace:*",
"@immich/e2e-auth-server": "workspace:*", "@immich/e2e-auth-server": "workspace:*",
"@immich/sdk": "workspace:*", "@immich/sdk": "workspace:*",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
@@ -54,8 +54,7 @@
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
"utimes": "^5.2.1", "utimes": "^5.2.1",
"vite-tsconfig-paths": "^6.1.1", "vitest": "^3.0.0"
"vitest": "^4.0.0"
}, },
"volta": { "volta": {
"node": "24.13.1" "node": "24.13.1"
+19 -27
View File
@@ -1,7 +1,10 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test'; import { Page, expect, test } from '@playwright/test';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => { test.describe('Photo Viewer', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetMediaResponseDto; let asset: AssetMediaResponseDto;
@@ -23,42 +26,31 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => { test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); const box = await imageLocator(page).boundingBox();
const original = page.getByTestId('original').filter({ visible: true }); expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await expect(thumbnail).toHaveAttribute('src', /thumbnail/); await page.mouse.move(x + width / 2, y + height / 2);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1); await page.mouse.wheel(0, -1);
await expect(original).toBeInViewport(); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
await expect(original).toHaveAttribute('src', /original/);
}); });
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => { test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`); await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); const box = await imageLocator(page).boundingBox();
const original = page.getByTestId('original').filter({ visible: true }); expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await expect(thumbnail).toHaveAttribute('src', /thumbnail/); await page.mouse.move(x + width / 2, y + height / 2);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1); await page.mouse.wheel(0, -1);
await expect(original).toHaveAttribute('src', /fullsize/); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
}); });
test('reloads photo when checksum changes', async ({ page }) => { test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true }); const initialSrc = await imageLocator(page).getAttribute('src');
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const initialSrc = await thumbnail.getAttribute('src');
await utils.replaceAsset(admin.accessToken, asset.id); await utils.replaceAsset(admin.accessToken, asset.id);
await expect(preview).not.toHaveAttribute('src', initialSrc!); await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
}); });
}); });
+2 -2
View File
@@ -99,13 +99,13 @@ export const setupTimelineMockApiRoutes = async (
}); });
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => { await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail|fullsize)/; const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern); const match = request.url().match(pattern);
if (!match?.groups) { if (!match?.groups) {
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`); throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
} }
if (match.groups.size === 'preview' || match.groups.size === 'fullsize') { if (match.groups.size === 'preview') {
if (!route.request().serviceWorker()) { if (!route.request().serviceWorker()) {
return route.continue(); return route.continue();
} }
@@ -73,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
await page.goto(`/photos/${fixture.primaryAsset.id}`); await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer'); await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]'); const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
await expect(viewerBrokenAsset).toBeVisible(); await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
@@ -6,7 +6,6 @@ import {
generateTimelineData, generateTimelineData,
TimelineAssetConfig, TimelineAssetConfig,
TimelineData, TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline'; } from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network'; import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network'; import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -54,7 +53,7 @@ test.describe('search gallery-viewer', () => {
assets: { assets: {
total: searchAssets.length, total: searchAssets.length,
count: searchAssets.length, count: searchAssets.length,
items: searchAssets.map((asset) => toAssetResponseDto(asset)), items: searchAssets,
facets: [], facets: [],
nextPage: null, nextPage: null,
}, },
+6 -4
View File
@@ -163,11 +163,13 @@ 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) {
const previewUrl = `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true`;
await page await page
.getByTestId('preview') .locator(
.and(page.locator(`[src="${previewUrl}"]`)) `img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
.or(page.locator(`video[poster="${previewUrl}"]`)) )
.or(
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
)
.waitFor(); .waitFor();
}, },
async expectActiveAssetToBe(page: Page, assetId: string) { async expectActiveAssetToBe(page: Page, assetId: string) {
+1 -1
View File
@@ -17,6 +17,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"baseUrl": "./" "baseUrl": "./"
}, },
"include": ["src/**/*.ts", "vitest*.config.ts"], "include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"] "exclude": ["dist", "node_modules"]
} }
+5 -5
View File
@@ -1,4 +1,3 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true'; const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
@@ -15,14 +14,15 @@ if (!skipDockerSetup) {
export default defineConfig({ export default defineConfig({
test: { test: {
name: 'e2e:server',
retry: process.env.CI ? 4 : 0, retry: process.env.CI ? 4 : 0,
include: ['src/specs/server/**/*.e2e-spec.ts'], include: ['src/specs/server/**/*.e2e-spec.ts'],
globalSetup, globalSetup,
testTimeout: 15_000, testTimeout: 15_000,
pool: 'threads', pool: 'threads',
maxWorkers: 1, poolOptions: {
isolate: false, threads: {
singleThread: true,
},
},
}, },
plugins: [tsconfigPaths()],
}); });
+5 -5
View File
@@ -1,4 +1,3 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true'; const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
@@ -15,14 +14,15 @@ if (!skipDockerSetup) {
export default defineConfig({ export default defineConfig({
test: { test: {
name: 'e2e:maintenance',
retry: process.env.CI ? 4 : 0, retry: process.env.CI ? 4 : 0,
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'], include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
globalSetup, globalSetup,
testTimeout: 15_000, testTimeout: 15_000,
pool: 'threads', pool: 'threads',
maxWorkers: 1, poolOptions: {
isolate: false, threads: {
singleThread: true,
},
},
}, },
plugins: [tsconfigPaths()],
}); });
+2 -2
View File
@@ -3,8 +3,8 @@
"version": "2.5.6", "version": "2.5.6",
"private": true, "private": true,
"scripts": { "scripts": {
"format": "prettier --cache --check .", "format": "prettier --check .",
"format:fix": "prettier --cache --write --list-different ." "format:fix": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.7.4", "prettier": "^3.7.4",
@@ -3,21 +3,30 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SearchResult { class SearchResult {
final List<BaseAsset> assets; final List<BaseAsset> assets;
final double scrollOffset;
final int? nextPage; final int? nextPage;
const SearchResult({required this.assets, this.nextPage}); const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
return SearchResult(
assets: assets ?? this.assets,
nextPage: nextPage ?? this.nextPage,
scrollOffset: scrollOffset ?? this.scrollOffset,
);
}
@override @override
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)'; String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
@override @override
bool operator ==(covariant SearchResult other) { bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals; final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage; return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
} }
@override @override
int get hashCode => assets.hashCode ^ nextPage.hashCode; int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
} }
@@ -78,9 +78,6 @@ class TimelineFactory {
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) => TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type)); TimelineService(_timelineRepository.fromAssets(assets, type));
TimelineService fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type));
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) => TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
@@ -115,7 +112,7 @@ class TimelineService {
if (totalAssets == 0) { if (totalAssets == 0) {
_bufferOffset = 0; _bufferOffset = 0;
_buffer = []; _buffer.clear();
} else { } else {
final int offset; final int offset;
final int count; final int count;
+82 -27
View File
@@ -33,27 +33,12 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
); );
} }
class SnapScrollController extends ScrollController {
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
@override
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) =>
SnapScrollPosition(physics: physics, context: context, oldPosition: oldPosition);
}
class SnapScrollPosition extends ScrollPositionWithSingleContext {
double snapOffset;
SnapScrollPosition({required super.physics, required super.context, super.oldPosition, this.snapOffset = 0.0});
@override
bool get shouldIgnorePointer => false;
}
class SnapScrollPhysics extends ScrollPhysics { class SnapScrollPhysics extends ScrollPhysics {
static const _minFlingVelocity = 700.0; static const _minFlingVelocity = 700.0;
static const minSnapDistance = 30.0; static const minSnapDistance = 30.0;
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
const SnapScrollPhysics({super.parent}); const SnapScrollPhysics({super.parent});
@override @override
@@ -81,21 +66,91 @@ class SnapScrollPhysics extends ScrollPhysics {
} }
} }
return ScrollSpringSimulation(spring, position.pixels, target(position, velocity, snapOffset), velocity); return ScrollSpringSimulation(
_spring,
position.pixels,
target(position, velocity, snapOffset),
velocity,
tolerance: toleranceFor(position),
);
} }
@override
SpringDescription get spring => SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
@override
bool get allowImplicitScrolling => false;
@override
bool get allowUserScrolling => false;
static double target(ScrollMetrics position, double velocity, double snapOffset) { static double target(ScrollMetrics position, double velocity, double snapOffset) {
if (velocity > _minFlingVelocity) return snapOffset; if (velocity > _minFlingVelocity) return snapOffset;
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset; if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
return position.pixels < minSnapDistance ? 0.0 : snapOffset; return position.pixels < minSnapDistance ? 0.0 : snapOffset;
} }
} }
class SnapScrollPosition extends ScrollPositionWithSingleContext {
double snapOffset;
SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition});
}
class ProxyScrollController extends ScrollController {
final ScrollController scrollController;
ProxyScrollController({required this.scrollController});
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
@override
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
return ProxyScrollPosition(
scrollController: scrollController,
physics: physics,
context: context,
oldPosition: oldPosition,
);
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
}
class ProxyScrollPosition extends SnapScrollPosition {
final ScrollController scrollController;
ProxyScrollPosition({
required this.scrollController,
required super.physics,
required super.context,
super.oldPosition,
});
@override
double setPixels(double newPixels) {
final overscroll = super.setPixels(newPixels);
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
scrollController.position.forcePixels(pixels);
}
return overscroll;
}
@override
void forcePixels(double value) {
super.forcePixels(value);
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
scrollController.position.forcePixels(pixels);
}
}
@override
double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
? scrollController.position.maxScrollExtent
: super.maxScrollExtent;
@override
double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
? scrollController.position.minScrollExtent
: super.minScrollExtent;
@override
double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension
? scrollController.position.viewportDimension
: super.viewportDimension;
}
@@ -276,19 +276,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
origin: origin, origin: origin,
); );
TimelineQuery fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin origin) =>
(
bucketSource: () async* {
yield _generateBuckets(getAssets().length);
yield* assetCount.map(_generateBuckets);
},
assetSource: (offset, count) {
final assets = getAssets();
return Future.value(assets.skip(offset).take(count).toList(growable: false));
},
origin: origin,
);
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) { TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
// Sort assets by date descending and group by day // Sort assets by date descending and group by day
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
@@ -80,28 +80,51 @@ class DriftSearchPage extends HookConsumerWidget {
final ratingCurrentFilterWidget = useState<Widget?>(null); final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null); final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
final userPreferences = ref.watch(userMetadataPreferencesProvider); final userPreferences = ref.watch(userMetadataPreferencesProvider);
searchFilter(SearchFilter filter) { SnackBar searchInfoSnackBar(String message) {
return SnackBar(
content: Text(message, style: context.textTheme.labelLarge),
showCloseIcon: true,
behavior: SnackBarBehavior.fixed,
closeIconColor: context.colorScheme.onSurface,
);
}
searchFilter(SearchFilter filter) async {
if (filter.isEmpty) {
return;
}
if (preFilter == null && filter == previousFilter.value) { if (preFilter == null && filter == previousFilter.value) {
return; return;
} }
ref.read(paginatedSearchProvider.notifier).clear(); isSearching.value = true;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter);
if (filter.isEmpty) { if (!hasResult) {
previousFilter.value = null; context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context)));
return;
} }
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter; previousFilter.value = filter;
isSearching.value = false;
} }
search() => searchFilter(filter.value); search() => searchFilter(filter.value);
loadMoreSearchResults() { loadMoreSearchResult() async {
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value)); isSearching.value = true;
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context)));
}
isSearching.value = false;
} }
searchPreFilter() { searchPreFilter() {
@@ -719,10 +742,10 @@ class DriftSearchPage extends HookConsumerWidget {
), ),
), ),
), ),
if (filter.value.isEmpty) if (isSearching.value)
const _SearchSuggestions() const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
else else
_SearchResultGrid(onScrollEnd: loadMoreSearchResults), _SearchResultGrid(onScrollEnd: loadMoreSearchResult),
], ],
), ),
); );
@@ -734,85 +757,45 @@ class _SearchResultGrid extends ConsumerWidget {
const _SearchResultGrid({required this.onScrollEnd}); const _SearchResultGrid({required this.onScrollEnd});
bool _onScrollUpdateNotification(ScrollNotification notification) {
final metrics = notification.metrics;
if (metrics.axis != Axis.vertical) return false;
final isBottomSheet = notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final remaining = metrics.maxScrollExtent - metrics.pixels;
if (remaining < metrics.viewportDimension && !isBottomSheet) {
onScrollEnd();
}
return false;
}
Widget? _bottomWidget(BuildContext context, WidgetRef ref) {
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
if (isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
);
}
final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null));
if (hasMore) return null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text(
'search_no_more_result'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
),
),
);
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty)); final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets));
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
if (!hasAssets && !isLoading) { if (assets.isEmpty) {
return const _SearchNoResults(); return const _SearchEmptyContent();
} }
return NotificationListener<ScrollUpdateNotification>( return NotificationListener<ScrollEndNotification>(
onNotification: _onScrollUpdateNotification, onNotification: (notification) {
final isBottomSheetNotification =
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical;
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) {
onScrollEnd();
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent);
}
return true;
},
child: SliverFillRemaining( child: SliverFillRemaining(
child: ProviderScope( child: ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
final notifier = ref.read(paginatedSearchProvider.notifier); final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search);
final service = ref ref.onDispose(timelineService.dispose);
.watch(timelineFactoryProvider) return timelineService;
.fromAssetStream(
() => ref.read(paginatedSearchProvider).assets,
notifier.assetCount,
TimelineOrigin.search,
);
ref.onDispose(service.dispose);
return service;
}), }),
], ],
child: Timeline( child: Timeline(
key: ValueKey(assets.length),
groupBy: GroupAssetsBy.none, groupBy: GroupAssetsBy.none,
appBar: null, appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false, snapToMonth: false,
loadingWidget: const SizedBox.shrink(), initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)),
bottomSliverWidget: _bottomWidget(context, ref),
), ),
), ),
), ),
@@ -820,35 +803,8 @@ class _SearchResultGrid extends ConsumerWidget {
} }
} }
class _SearchNoResults extends StatelessWidget { class _SearchEmptyContent extends StatelessWidget {
const _SearchNoResults(); const _SearchEmptyContent();
@override
Widget build(BuildContext context) {
return SliverFillRemaining(
hasScrollBody: false,
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search_off_rounded, size: 72, color: context.colorScheme.onSurfaceVariant),
const SizedBox(height: 24),
Text(
'search_no_result'.t(context: context),
textAlign: TextAlign.center,
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
],
),
),
);
}
}
class _SearchSuggestions extends StatelessWidget {
const _SearchSuggestions();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1,7 +1,5 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart'; import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
@@ -23,52 +21,40 @@ class SearchFilterProvider extends Notifier<SearchFilter?> {
} }
} }
class SearchState { final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
final List<BaseAsset> assets;
final int? nextPage;
final bool isLoading;
const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false});
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
); );
class PaginatedSearchNotifier extends StateNotifier<SearchState> { class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
final SearchService _searchService; final SearchService _searchService;
final _assetCountController = StreamController<int>.broadcast();
PaginatedSearchNotifier(this._searchService) : super(const SearchState()); PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1));
Stream<int> get assetCount => _assetCountController.stream; Future<bool> search(SearchFilter filter) async {
if (state.nextPage == null) {
Future<void> search(SearchFilter filter) async { return false;
if (state.nextPage == null || state.isLoading) return; }
state = SearchState(assets: state.assets, nextPage: state.nextPage, isLoading: true);
final result = await _searchService.search(filter, state.nextPage!); final result = await _searchService.search(filter, state.nextPage!);
if (result == null) { if (result == null) {
state = SearchState(assets: state.assets, nextPage: state.nextPage); return false;
return;
} }
final assets = [...state.assets, ...result.assets]; state = SearchResult(
state = SearchState(assets: assets, nextPage: result.nextPage); assets: [...state.assets, ...result.assets],
nextPage: result.nextPage,
scrollOffset: state.scrollOffset,
);
_assetCountController.add(assets.length); return true;
} }
void clear() { void setScrollOffset(double offset) {
state = const SearchState(); state = state.copyWith(scrollOffset: offset);
_assetCountController.add(0);
} }
@override clear() {
void dispose() { state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0);
_assetCountController.close();
super.dispose();
} }
} }
@@ -27,21 +27,18 @@ class AssetDetails extends ConsumerWidget {
color: context.colorScheme.surface, color: context.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
), ),
child: SafeArea( child: Column(
top: false, crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, const DragHandle(),
children: [ DateTimeDetails(asset: asset, exifInfo: exifInfo),
const DragHandle(), PeopleDetails(asset: asset),
DateTimeDetails(asset: asset, exifInfo: exifInfo), LocationDetails(asset: asset, exifInfo: exifInfo),
PeopleDetails(asset: asset), TechnicalDetails(asset: asset, exifInfo: exifInfo),
LocationDetails(asset: asset, exifInfo: exifInfo), RatingDetails(exifInfo: exifInfo),
TechnicalDetails(asset: asset, exifInfo: exifInfo), AppearsInDetails(asset: asset),
RatingDetails(exifInfo: exifInfo), SizedBox(height: context.padding.bottom + 48),
AppearsInDetails(asset: asset), ],
SizedBox(height: context.padding.bottom + 48),
],
),
), ),
); );
} }
@@ -50,7 +50,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
bool _showingDetails = false; bool _showingDetails = false;
bool _isZoomed = false; bool _isZoomed = false;
final _scrollController = SnapScrollController(); final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
double _snapOffset = 0.0; double _snapOffset = 0.0;
DragStartDetails? _dragStart; DragStartDetails? _dragStart;
@@ -62,17 +63,17 @@ class _AssetPageState extends ConsumerState<AssetPage> {
super.initState(); super.initState();
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) return; if (!mounted || !_proxyScrollController.hasClients) return;
_scrollController.snapPosition.snapOffset = _snapOffset; _proxyScrollController.snapPosition.snapOffset = _snapOffset;
if (_showingDetails && _snapOffset > 0) { if (_showingDetails && _snapOffset > 0) {
_scrollController.jumpTo(_snapOffset); _proxyScrollController.jumpTo(_snapOffset);
} }
}); });
} }
@override @override
void dispose() { void dispose() {
_scrollController.dispose(); _proxyScrollController.dispose();
_scaleBoundarySub?.cancel(); _scaleBoundarySub?.cancel();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
super.dispose(); super.dispose();
@@ -87,20 +88,21 @@ class _AssetPageState extends ConsumerState<AssetPage> {
} }
void _showDetails() { void _showDetails() {
if (!_scrollController.hasClients || _snapOffset <= 0) return; if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
_viewer.setShowingDetails(true); _viewer.setShowingDetails(true);
_scrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); _proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
} }
bool _willClose(double scrollVelocity) => bool _willClose(double scrollVelocity) {
_scrollController.hasClients && if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false;
_snapOffset > 0 &&
_scrollController.position.pixels < _snapOffset && final position = _proxyScrollController.position;
SnapScrollPhysics.target(_scrollController.position, scrollVelocity, _snapOffset) < return _proxyScrollController.position.pixels < _snapOffset &&
SnapScrollPhysics.minSnapDistance; SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
}
void _syncShowingDetails() { void _syncShowingDetails() {
final offset = _scrollController.offset; final offset = _proxyScrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance) { if (offset > SnapScrollPhysics.minSnapDistance) {
_viewer.setShowingDetails(true); _viewer.setShowingDetails(true);
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) { } else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
@@ -122,8 +124,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
} }
void _startProxyDrag() { void _startProxyDrag() {
if (_scrollController.hasClients && _dragStart != null) { if (_proxyScrollController.hasClients && _dragStart != null) {
_drag = _scrollController.position.drag(_dragStart!, () => _drag = null); _drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null);
} }
} }
@@ -388,15 +390,22 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_snapOffset = detailsOffset - snapTarget; _snapOffset = detailsOffset - snapTarget;
if (_scrollController.hasClients) { if (_proxyScrollController.hasClients) {
_scrollController.snapPosition.snapOffset = _snapOffset; _proxyScrollController.snapPosition.snapOffset = _snapOffset;
} }
return Stack( return Stack(
children: [ children: [
Offstage(
child: SingleChildScrollView(
controller: _proxyScrollController,
physics: const SnapScrollPhysics(),
child: const SizedBox.shrink(),
),
),
SingleChildScrollView( SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
physics: const SnapScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
child: Stack( child: Stack(
children: [ children: [
SizedBox( SizedBox(
@@ -5,7 +5,6 @@ const Size kTimelineFixedTileExtent = Size.square(256);
const double kTimelineSpacing = 2.0; const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3; const int kTimelineColumnCount = 3;
const double kScrubberThumbHeight = 48.0;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300); const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800); const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
@@ -530,14 +530,12 @@ class _CircularThumb extends StatelessWidget {
elevation: 4.0, elevation: 4.0,
color: backgroundColor, color: backgroundColor,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(kScrubberThumbHeight), topLeft: Radius.circular(48.0),
bottomLeft: Radius.circular(kScrubberThumbHeight), bottomLeft: Radius.circular(48.0),
topRight: Radius.circular(4.0), topRight: Radius.circular(4.0),
bottomRight: Radius.circular(4.0), bottomRight: Radius.circular(4.0),
), ),
child: Container( child: Container(constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0))),
constraints: BoxConstraints.tight(const Size(kScrubberThumbHeight * 0.6, kScrubberThumbHeight)),
),
), ),
); );
} }
@@ -17,7 +17,6 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
@@ -35,7 +34,6 @@ class Timeline extends StatelessWidget {
super.key, super.key,
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.showStorageIndicator = false, this.showStorageIndicator = false,
this.withStack = false, this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
@@ -43,14 +41,13 @@ class Timeline extends StatelessWidget {
this.groupBy, this.groupBy,
this.withScrubber = true, this.withScrubber = true,
this.snapToMonth = true, this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false, this.readOnly = false,
this.persistentBottomBar = false, this.persistentBottomBar = false,
this.loadingWidget,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final bool showStorageIndicator; final bool showStorageIndicator;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
@@ -58,9 +55,9 @@ class Timeline extends StatelessWidget {
final GroupAssetsBy? groupBy; final GroupAssetsBy? groupBy;
final bool withScrubber; final bool withScrubber;
final bool snapToMonth; final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly; final bool readOnly;
final bool persistentBottomBar; final bool persistentBottomBar;
final Widget? loadingWidget;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -85,14 +82,13 @@ class Timeline extends StatelessWidget {
child: _SliverTimeline( child: _SliverTimeline(
topSliverWidget: topSliverWidget, topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight, topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar, appBar: appBar,
bottomSheet: bottomSheet, bottomSheet: bottomSheet,
withScrubber: withScrubber, withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar, persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth, snapToMonth: snapToMonth,
initialScrollOffset: initialScrollOffset,
maxWidth: constraints.maxWidth, maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
), ),
), ),
), ),
@@ -115,26 +111,24 @@ class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({ const _SliverTimeline({
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.appBar, this.appBar,
this.bottomSheet, this.bottomSheet,
this.withScrubber = true, this.withScrubber = true,
this.persistentBottomBar = false, this.persistentBottomBar = false,
this.snapToMonth = true, this.snapToMonth = true,
this.initialScrollOffset,
this.maxWidth, this.maxWidth,
this.loadingWidget,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withScrubber; final bool withScrubber;
final bool persistentBottomBar; final bool persistentBottomBar;
final bool snapToMonth; final bool snapToMonth;
final double? initialScrollOffset;
final double? maxWidth; final double? maxWidth;
final Widget? loadingWidget;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
@@ -158,7 +152,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = ScrollController(onAttach: _restoreAssetPosition); _scrollController = ScrollController(
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
onAttach: _restoreAssetPosition,
);
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow); final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
@@ -376,7 +373,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
} }
}, },
child: asyncSegments.widgetWhen( child: asyncSegments.widgetWhen(
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
onData: (segments) { onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
@@ -384,9 +380,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
: 0; : 0;
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10; final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0;
const bottomSheetOpenModifier = 120.0; const bottomSheetOpenModifier = 120.0;
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); final bottomPadding =
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight; context.padding.bottom +
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final grid = CustomScrollView( final grid = CustomScrollView(
primary: true, primary: true,
@@ -409,8 +408,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false, addRepaintBoundaries: false,
), ),
), ),
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!, SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
], ],
); );
@@ -421,7 +419,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
layoutSegments: segments, layoutSegments: segments,
timelineHeight: maxHeight, timelineHeight: maxHeight,
topPadding: topPadding, topPadding: topPadding,
bottomPadding: scrubberBottomPadding, bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null, hasAppBar: widget.appBar != null,
child: grid, child: grid,
+6 -6
View File
@@ -1217,10 +1217,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1241,8 +1241,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: e132bc3 ref: "0cc59ab"
resolved-ref: e132bc3ecc6a6d8fc2089d96f849c8a13129500e resolved-ref: "0cc59abad8216d4bb26d959252c6aca1a8d05ef9"
url: "https://github.com/immich-app/native_video_player" url: "https://github.com/immich-app/native_video_player"
source: git source: git
version: "1.3.1" version: "1.3.1"
@@ -1910,10 +1910,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.7"
thumbhash: thumbhash:
dependency: "direct main" dependency: "direct main"
description: description:
+1 -1
View File
@@ -57,7 +57,7 @@ dependencies:
native_video_player: native_video_player:
git: git:
url: https://github.com/immich-app/native_video_player url: https://github.com/immich-app/native_video_player
ref: 'e132bc3' ref: '0cc59ab'
network_info_plus: ^6.1.3 network_info_plus: ^6.1.3
octo_image: ^2.1.0 octo_image: ^2.1.0
openapi: openapi:
+125 -286
View File
@@ -66,8 +66,8 @@ importers:
specifier: ^24.10.14 specifier: ^24.10.14
version: 24.11.0 version: 24.11.0
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^4.0.0 specifier: ^3.0.0
version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
byte-size: byte-size:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.0.1 version: 9.0.1
@@ -114,11 +114,11 @@ importers:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vitest: vitest:
specifier: ^4.0.0 specifier: ^3.0.0
version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest-fetch-mock: vitest-fetch-mock:
specifier: ^0.4.0 specifier: ^0.4.0
version: 0.4.5(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
yaml: yaml:
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.8.2 version: 2.8.2
@@ -285,12 +285,9 @@ importers:
utimes: utimes:
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.1(encoding@0.1.13) version: 5.2.1(encoding@0.1.13)
vite-tsconfig-paths:
specifier: ^6.1.1
version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vitest: vitest:
specifier: ^4.0.0 specifier: ^3.0.0
version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
e2e-auth-server: e2e-auth-server:
devDependencies: devDependencies:
@@ -504,7 +501,7 @@ importers:
version: 0.40.3 version: 0.40.3
multer: multer:
specifier: ^2.0.2 specifier: ^2.0.2
version: 2.1.1 version: 2.1.0
nest-commander: nest-commander:
specifier: ^3.16.0 specifier: ^3.16.0
version: 3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3) version: 3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3)
@@ -887,7 +884,7 @@ importers:
version: 6.9.1 version: 6.9.1
'@testing-library/svelte': '@testing-library/svelte':
specifier: ^5.2.8 specifier: ^5.2.8
version: 5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/user-event': '@testing-library/user-event':
specifier: ^14.5.2 specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1) version: 14.6.1(@testing-library/dom@10.4.1)
@@ -910,8 +907,8 @@ importers:
specifier: ^1.5.5 specifier: ^1.5.5
version: 1.5.6 version: 1.5.6
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^4.0.0 specifier: ^3.0.0
version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
dotenv: dotenv:
specifier: ^17.0.0 specifier: ^17.0.0
version: 17.3.1 version: 17.3.1
@@ -973,8 +970,8 @@ importers:
specifier: ^7.1.2 specifier: ^7.1.2
version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: vitest:
specifier: ^4.0.0 specifier: ^3.0.0
version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
packages: packages:
@@ -5198,21 +5195,9 @@ packages:
'@vitest/browser': '@vitest/browser':
optional: true optional: true
'@vitest/coverage-v8@4.0.14':
resolution: {integrity: sha512-EYHLqN/BY6b47qHH7gtMxAg++saoGmsjWmAq9MlXxAz4M0NcHh9iOyKhBZyU4yxZqOd8Xnqp80/5saeitz4Cng==}
peerDependencies:
'@vitest/browser': 4.0.14
vitest: 4.0.14
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@3.2.4': '@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/expect@4.0.14':
resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==}
'@vitest/mocker@3.2.4': '@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies: peerDependencies:
@@ -5224,47 +5209,21 @@ packages:
vite: vite:
optional: true optional: true
'@vitest/mocker@4.0.14':
resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.4': '@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/pretty-format@4.0.14':
resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==}
'@vitest/runner@3.2.4': '@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/runner@4.0.14':
resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==}
'@vitest/snapshot@3.2.4': '@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/snapshot@4.0.14':
resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==}
'@vitest/spy@3.2.4': '@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/spy@4.0.14':
resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==}
'@vitest/utils@3.2.4': '@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
'@vitest/utils@4.0.14':
resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==}
'@webassemblyjs/ast@1.14.1': '@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -5874,10 +5833,6 @@ packages:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'} engines: {node: '>=18'}
chai@6.2.1:
resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==}
engines: {node: '>=18'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -5908,8 +5863,8 @@ packages:
chardet@2.1.1: chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
check-error@2.1.3: check-error@2.1.1:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
cheerio-select@2.1.0: cheerio-select@2.1.0:
@@ -7220,8 +7175,8 @@ packages:
resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==} resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
expect-type@1.2.2: expect-type@1.3.0:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
exponential-backoff@3.1.3: exponential-backoff@3.1.3:
@@ -8650,9 +8605,6 @@ packages:
magicast@0.3.5: magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
magicast@0.5.1:
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
make-dir@3.1.0: make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -9118,8 +9070,8 @@ packages:
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
engines: {node: '>= 10.16.0'} engines: {node: '>= 10.16.0'}
multer@2.1.1: multer@2.1.0:
resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} resolution: {integrity: sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==}
engines: {node: '>= 10.16.0'} engines: {node: '>= 10.16.0'}
multicast-dns@7.2.5: multicast-dns@7.2.5:
@@ -11343,8 +11295,8 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
test-exclude@7.0.2: test-exclude@7.0.1:
resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'} engines: {node: '>=18'}
testcontainers@11.12.0: testcontainers@11.12.0:
@@ -11427,10 +11379,6 @@ packages:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
tinyrainbow@3.0.3:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4: tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -11915,40 +11863,6 @@ packages:
jsdom: jsdom:
optional: true optional: true
vitest@4.0.14:
resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.0.14
'@vitest/browser-preview': 4.0.14
'@vitest/browser-webdriverio': 4.0.14
'@vitest/ui': 4.0.14
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-jsonrpc@8.2.0: vscode-jsonrpc@8.2.0:
resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -16599,14 +16513,14 @@ snapshots:
dependencies: dependencies:
svelte: 5.53.5 svelte: 5.53.5
'@testing-library/svelte@5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': '@testing-library/svelte@5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@testing-library/dom': 10.4.1 '@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.53.5) '@testing-library/svelte-core': 1.0.0(svelte@5.53.5)
svelte: 5.53.5 svelte: 5.53.5
optionalDependencies: optionalDependencies:
vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies: dependencies:
@@ -17313,43 +17227,28 @@ snapshots:
magic-string: 0.30.21 magic-string: 0.30.21
magicast: 0.3.5 magicast: 0.3.5
std-env: 3.10.0 std-env: 3.10.0
test-exclude: 7.0.2 test-exclude: 7.0.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/coverage-v8@4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2 '@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.14
ast-v8-to-istanbul: 0.3.8 ast-v8-to-istanbul: 0.3.8
debug: 4.4.3
istanbul-lib-coverage: 3.2.2 istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1 istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6 istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0 istanbul-reports: 3.2.0
magicast: 0.5.1 magic-string: 0.30.21
obug: 2.1.1 magicast: 0.3.5
std-env: 3.10.0 std-env: 3.10.0
tinyrainbow: 3.0.3 test-exclude: 7.0.1
vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) tinyrainbow: 2.0.0
transitivePeerDependencies: vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- supports-color
'@vitest/coverage-v8@4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.14
ast-v8-to-istanbul: 0.3.8
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magicast: 0.5.1
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -17361,15 +17260,6 @@ snapshots:
chai: 5.3.3 chai: 5.3.3
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
'@vitest/expect@4.0.14':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.0.14
'@vitest/utils': 4.0.14
chai: 6.2.1
tinyrainbow: 3.0.3
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@vitest/spy': 3.2.4 '@vitest/spy': 3.2.4
@@ -17378,17 +17268,9 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@4.0.14(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@vitest/spy': 4.0.14 '@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@4.0.14(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.14
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: optionalDependencies:
@@ -17398,50 +17280,28 @@ snapshots:
dependencies: dependencies:
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
'@vitest/pretty-format@4.0.14':
dependencies:
tinyrainbow: 3.0.3
'@vitest/runner@3.2.4': '@vitest/runner@3.2.4':
dependencies: dependencies:
'@vitest/utils': 3.2.4 '@vitest/utils': 3.2.4
pathe: 2.0.3 pathe: 2.0.3
strip-literal: 3.1.0 strip-literal: 3.1.0
'@vitest/runner@4.0.14':
dependencies:
'@vitest/utils': 4.0.14
pathe: 2.0.3
'@vitest/snapshot@3.2.4': '@vitest/snapshot@3.2.4':
dependencies: dependencies:
'@vitest/pretty-format': 3.2.4 '@vitest/pretty-format': 3.2.4
magic-string: 0.30.21 magic-string: 0.30.21
pathe: 2.0.3 pathe: 2.0.3
'@vitest/snapshot@4.0.14':
dependencies:
'@vitest/pretty-format': 4.0.14
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.4': '@vitest/spy@3.2.4':
dependencies: dependencies:
tinyspy: 4.0.4 tinyspy: 4.0.4
'@vitest/spy@4.0.14': {}
'@vitest/utils@3.2.4': '@vitest/utils@3.2.4':
dependencies: dependencies:
'@vitest/pretty-format': 3.2.4 '@vitest/pretty-format': 3.2.4
loupe: 3.2.1 loupe: 3.2.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
'@vitest/utils@4.0.14':
dependencies:
'@vitest/pretty-format': 4.0.14
tinyrainbow: 3.0.3
'@webassemblyjs/ast@1.14.1': '@webassemblyjs/ast@1.14.1':
dependencies: dependencies:
'@webassemblyjs/helper-numbers': 1.13.2 '@webassemblyjs/helper-numbers': 1.13.2
@@ -18140,13 +18000,11 @@ snapshots:
chai@5.3.3: chai@5.3.3:
dependencies: dependencies:
assertion-error: 2.0.1 assertion-error: 2.0.1
check-error: 2.1.3 check-error: 2.1.1
deep-eql: 5.0.2 deep-eql: 5.0.2
loupe: 3.2.1 loupe: 3.2.1
pathval: 2.0.1 pathval: 2.0.1
chai@6.2.1: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -18168,7 +18026,7 @@ snapshots:
chardet@2.1.1: {} chardet@2.1.1: {}
check-error@2.1.3: {} check-error@2.1.1: {}
cheerio-select@2.1.0: cheerio-select@2.1.0:
dependencies: dependencies:
@@ -19638,7 +19496,7 @@ snapshots:
optionalDependencies: optionalDependencies:
exiftool-vendored.exe: 13.51.0 exiftool-vendored.exe: 13.51.0
expect-type@1.2.2: {} expect-type@1.3.0: {}
exponential-backoff@3.1.3: {} exponential-backoff@3.1.3: {}
@@ -21264,12 +21122,6 @@ snapshots:
'@babel/types': 7.28.5 '@babel/types': 7.28.5
source-map-js: 1.2.1 source-map-js: 1.2.1
magicast@0.5.1:
dependencies:
'@babel/parser': 7.28.5
'@babel/types': 7.28.5
source-map-js: 1.2.1
make-dir@3.1.0: make-dir@3.1.0:
dependencies: dependencies:
semver: 6.3.1 semver: 6.3.1
@@ -22057,7 +21909,7 @@ snapshots:
type-is: 1.6.18 type-is: 1.6.18
xtend: 4.0.2 xtend: 4.0.2
multer@2.1.1: multer@2.1.0:
dependencies: dependencies:
append-field: 1.0.0 append-field: 1.0.0
busboy: 1.6.0 busboy: 1.6.0
@@ -24715,11 +24567,11 @@ snapshots:
commander: 2.20.3 commander: 2.20.3
source-map-support: 0.5.21 source-map-support: 0.5.21
test-exclude@7.0.2: test-exclude@7.0.1:
dependencies: dependencies:
'@istanbuljs/schema': 0.1.3 '@istanbuljs/schema': 0.1.3
glob: 10.5.0 glob: 10.5.0
minimatch: 10.2.4 minimatch: 9.0.6
testcontainers@11.12.0: testcontainers@11.12.0:
dependencies: dependencies:
@@ -24807,8 +24659,6 @@ snapshots:
tinyrainbow@2.0.0: {} tinyrainbow@2.0.0: {}
tinyrainbow@3.0.3: {}
tinyspy@4.0.4: {} tinyspy@4.0.4: {}
tldts-core@6.1.86: tldts-core@6.1.86:
@@ -25233,6 +25083,27 @@ snapshots:
- tsx - tsx
- yaml - yaml
vite-node@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -25283,9 +25154,53 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest-fetch-mock@0.4.5(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies: dependencies:
vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-node: 3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.11.0
happy-dom: 20.7.0
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies: dependencies:
@@ -25299,7 +25214,7 @@ snapshots:
'@vitest/utils': 3.2.4 '@vitest/utils': 3.2.4
chai: 5.3.3 chai: 5.3.3
debug: 4.4.3 debug: 4.4.3
expect-type: 1.2.2 expect-type: 1.3.0
magic-string: 0.30.21 magic-string: 0.30.21
pathe: 2.0.3 pathe: 2.0.3
picomatch: 4.0.3 picomatch: 4.0.3
@@ -25331,110 +25246,33 @@ snapshots:
- tsx - tsx
- yaml - yaml
vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies: dependencies:
'@vitest/expect': 4.0.14 '@types/chai': 5.2.3
'@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/expect': 3.2.4
'@vitest/pretty-format': 4.0.14 '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/runner': 4.0.14 '@vitest/pretty-format': 3.2.4
'@vitest/snapshot': 4.0.14 '@vitest/runner': 3.2.4
'@vitest/spy': 4.0.14 '@vitest/snapshot': 3.2.4
'@vitest/utils': 4.0.14 '@vitest/spy': 3.2.4
es-module-lexer: 1.7.0 '@vitest/utils': 3.2.4
expect-type: 1.2.2 chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21 magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3 pathe: 2.0.3
picomatch: 4.0.3 picomatch: 4.0.3
std-env: 3.10.0 std-env: 3.10.0
tinybench: 2.9.0 tinybench: 2.9.0
tinyexec: 0.3.2 tinyexec: 0.3.2
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinyrainbow: 3.0.3 tinypool: 1.1.1
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) tinyrainbow: 2.0.0
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.11.0
happy-dom: 20.7.0
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.14
'@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.14
'@vitest/runner': 4.0.14
'@vitest/snapshot': 4.0.14
'@vitest/spy': 4.0.14
'@vitest/utils': 4.0.14
es-module-lexer: 1.7.0
expect-type: 1.2.2
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.11.0
happy-dom: 20.7.0
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.14
'@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.14
'@vitest/runner': 4.0.14
'@vitest/snapshot': 4.0.14
'@vitest/spy': 4.0.14
'@vitest/utils': 4.0.14
es-module-lexer: 1.7.0
expect-type: 1.2.2
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-node: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.0 '@types/debug': 4.1.12
'@types/node': 25.3.3 '@types/node': 25.3.3
happy-dom: 20.7.0 happy-dom: 20.7.0
jsdom: 26.1.0(canvas@2.11.2) jsdom: 26.1.0(canvas@2.11.2)
@@ -25447,6 +25285,7 @@ snapshots:
- sass-embedded - sass-embedded
- stylus - stylus
- sugarss - sugarss
- supports-color
- terser - terser
- tsx - tsx
- yaml - yaml
+2 -2
View File
@@ -7,8 +7,8 @@
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"format": "prettier --cache --check .", "format": "prettier --check .",
"format:fix": "prettier --cache --write --list-different .", "format:fix": "prettier --write .",
"start": "pnpm run start:dev", "start": "pnpm run start:dev",
"nest": "nest", "nest": "nest",
"start:dev": "nest start --watch --", "start:dev": "nest start --watch --",
@@ -42,7 +42,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadFiles } from 'src/types'; import { UploadFiles } from 'src/types';
import { ImmichFileResponse, sendFile, sendFileThrottled } from 'src/utils/file'; import { ImmichFileResponse, sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets) @ApiTags(ApiTag.Assets)
@@ -109,7 +109,7 @@ export class AssetMediaController {
@Res() res: Response, @Res() res: Response,
@Next() next: NextFunction, @Next() next: NextFunction,
) { ) {
await sendFileThrottled(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger, 10); await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
} }
@Put(':id/original') @Put(':id/original')
@@ -162,14 +162,7 @@ export class AssetMediaController {
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto); const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
if (viewThumbnailRes instanceof ImmichFileResponse) { if (viewThumbnailRes instanceof ImmichFileResponse) {
const size = dto.size ?? AssetMediaSize.THUMBNAIL; await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
let durationSeconds = 1;
if (size === AssetMediaSize.PREVIEW) {
durationSeconds = 4;
} else if (size === AssetMediaSize.FULLSIZE) {
durationSeconds = 4;
}
await sendFileThrottled(res, next, () => Promise.resolve(viewThumbnailRes), this.logger, durationSeconds);
} else { } else {
// viewThumbnailRes is a AssetMediaRedirectResponse // viewThumbnailRes is a AssetMediaRedirectResponse
// which redirects to the original asset or a specific size to make better use of caching // which redirects to the original asset or a specific size to make better use of caching
+1 -88
View File
@@ -1,41 +1,13 @@
import { HttpException, StreamableFile } from '@nestjs/common'; import { HttpException, StreamableFile } from '@nestjs/common';
import { NextFunction, Response } from 'express'; import { NextFunction, Response } from 'express';
import { createReadStream } from 'node:fs'; import { access, constants } from 'node:fs/promises';
import { access, constants, stat } from 'node:fs/promises';
import { basename, extname } from 'node:path'; import { basename, extname } from 'node:path';
import { Transform, TransformCallback } from 'node:stream';
import { setTimeout as sleep } from 'node:timers/promises';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import sharp from 'sharp';
import { CacheControl } from 'src/enum'; import { CacheControl } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { ImmichReadStream } from 'src/repositories/storage.repository'; import { ImmichReadStream } from 'src/repositories/storage.repository';
import { isConnectionAborted } from 'src/utils/misc'; import { isConnectionAborted } from 'src/utils/misc';
class ThrottleTransform extends Transform {
private bytesPerSecond: number;
private startTime: number;
private bytesSent: number;
constructor(bytesPerSecond: number) {
super();
this.bytesPerSecond = bytesPerSecond;
this.startTime = Date.now();
this.bytesSent = 0;
}
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
this.bytesSent += chunk.length;
const targetTime = (this.bytesSent / this.bytesPerSecond) * 1000;
const elapsed = Date.now() - this.startTime;
const delay = Math.max(0, targetTime - elapsed);
setTimeout(() => {
callback(null, chunk);
}, delay);
}
}
export function getFileNameWithoutExtension(path: string): string { export function getFileNameWithoutExtension(path: string): string {
return basename(path, extname(path)); return basename(path, extname(path));
} }
@@ -48,11 +20,6 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string
return getFileNameWithoutExtension(stillName) + extname(motionName); return getFileNameWithoutExtension(stillName) + extname(motionName);
} }
export async function hasAlphaChannel(input: string | Buffer): Promise<boolean> {
const metadata = await sharp(input).metadata();
return metadata.hasAlpha === true;
}
export class ImmichFileResponse { export class ImmichFileResponse {
public readonly path!: string; public readonly path!: string;
public readonly contentType!: string; public readonly contentType!: string;
@@ -118,57 +85,3 @@ export const sendFile = async (
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => { export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
return new StreamableFile(stream, { type, length }); return new StreamableFile(stream, { type, length });
}; };
const THROTTLE_TRANSFER_DURATION_SECONDS = 6;
const THROTTLE_INITIAL_DELAY_MS = 500;
export const sendFileThrottled = async (
res: Response,
next: NextFunction,
handler: () => Promise<ImmichFileResponse> | ImmichFileResponse,
logger: LoggingRepository,
durationSeconds: number = THROTTLE_TRANSFER_DURATION_SECONDS,
): Promise<void> => {
try {
const file = await handler();
const cacheControlHeader = cacheControlHeaders[file.cacheControl];
if (cacheControlHeader) {
res.set('Cache-Control', cacheControlHeader);
}
res.header('Content-Type', file.contentType);
if (file.fileName) {
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
}
const fileStat = await stat(file.path);
res.header('Content-Length', fileStat.size.toString());
await sleep(THROTTLE_INITIAL_DELAY_MS);
const bytesPerSecond = fileStat.size / durationSeconds;
const readStream = createReadStream(file.path);
const throttle = new ThrottleTransform(bytesPerSecond);
readStream.pipe(throttle).pipe(res);
readStream.on('error', (error) => {
if (!isConnectionAborted(error) && !res.headersSent) {
logger.error(`Unable to send file: ${error}`, (error as Error).stack);
res.header('Cache-Control', 'none');
next(error);
}
});
} catch (error: Error | any) {
if (isConnectionAborted(error) || res.headersSent) {
return;
}
if (error instanceof HttpException === false) {
logger.error(`Unable to send file: ${error}`, error.stack);
}
res.header('Cache-Control', 'none');
next(error);
}
};
-4
View File
@@ -3,7 +3,6 @@ import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { resolve } from 'node:path';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { AssetFace } from 'src/database'; import { AssetFace } from 'src/database';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
@@ -79,9 +78,6 @@ import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
import { automock, wait } from 'test/utils'; import { automock, wait } from 'test/utils';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
// eslint-disable-next-line unicorn/prefer-module
export const testAssetsDir = resolve(__dirname, '../../e2e/test-assets');
interface ClassConstructor<T = any> extends Function { interface ClassConstructor<T = any> extends Function {
new (...args: any[]): T; new (...args: any[]): T;
} }
@@ -2,7 +2,7 @@ import { Kysely } from 'kysely';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { ExifTestContext, testAssetsDir } from 'test/medium.factory'; import { ExifTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils'; import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>; let database: Kysely<DB>;
@@ -11,7 +11,7 @@ const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database); const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const originalPath = resolve(testAssetsDir, testAssetPath); const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset }; return { ctx, sut: ctx.sut, asset };
@@ -1,7 +1,7 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { ExifTestContext, testAssetsDir } from 'test/medium.factory'; import { ExifTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils'; import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>; let database: Kysely<DB>;
@@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database); const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const originalPath = resolve(testAssetsDir, testAssetPath); const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset }; return { ctx, sut: ctx.sut, asset };
@@ -1,7 +1,7 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { ExifTestContext, testAssetsDir } from 'test/medium.factory'; import { ExifTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils'; import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>; let database: Kysely<DB>;
@@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database); const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const originalPath = resolve(testAssetsDir, testAssetPath); const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset }; return { ctx, sut: ctx.sut, asset };
+1 -6
View File
@@ -1,15 +1,10 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import swc from 'unplugin-swc'; import swc from 'unplugin-swc';
import tsconfigPaths from 'vite-tsconfig-paths'; import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export default defineConfig({ export default defineConfig({
test: { test: {
name: 'server:medium', root: './',
root: serverRoot,
globals: true, globals: true,
include: ['test/medium/**/*.spec.ts'], include: ['test/medium/**/*.spec.ts'],
globalSetup: ['test/medium/globalSetup.ts'], globalSetup: ['test/medium/globalSetup.ts'],
+1 -6
View File
@@ -1,15 +1,10 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import swc from 'unplugin-swc'; import swc from 'unplugin-swc';
import tsconfigPaths from 'vite-tsconfig-paths'; import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export default defineConfig({ export default defineConfig({
test: { test: {
name: 'server:unit', root: './',
root: serverRoot,
globals: true, globals: true,
include: ['src/**/*.spec.ts'], include: ['src/**/*.spec.ts'],
coverage: { coverage: {
+4 -4
View File
@@ -16,8 +16,8 @@
"check:all": "pnpm run check:code && pnpm run test:cov", "check:all": "pnpm run check:code && pnpm run test:cov",
"lint": "eslint . --max-warnings 0 --concurrency 4", "lint": "eslint . --max-warnings 0 --concurrency 4",
"lint:fix": "pnpm run lint --fix", "lint:fix": "pnpm run lint --fix",
"format": "prettier --cache --check .", "format": "prettier --check .",
"format:fix": "prettier --cache --write --list-different .", "format:fix": "prettier --write .",
"test": "vitest", "test": "vitest",
"test:cov": "vitest --coverage", "test:cov": "vitest --coverage",
"test:watch": "vitest dev", "test:watch": "vitest dev",
@@ -83,7 +83,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@vitest/coverage-v8": "^4.0.0", "@vitest/coverage-v8": "^3.0.0",
"dotenv": "^17.0.0", "dotenv": "^17.0.0",
"eslint": "^10.0.0", "eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@@ -105,7 +105,7 @@
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.45.0",
"vite": "^7.1.2", "vite": "^7.1.2",
"vitest": "^4.0.0" "vitest": "^3.0.0"
}, },
"volta": { "volta": {
"node": "24.13.1" "node": "24.13.1"
+1 -1
View File
@@ -2,7 +2,7 @@ import { tick } from 'svelte';
import { vi } from 'vitest'; import { vi } from 'vitest';
export const getAnimateMock = () => export const getAnimateMock = () =>
vi.fn().mockImplementation(function () { vi.fn().mockImplementation(() => {
let onfinish: (() => void) | null = null; let onfinish: (() => void) | null = null;
void tick().then(() => onfinish?.()); void tick().then(() => onfinish?.());
@@ -1,11 +1,9 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
export const getIntersectionObserverMock = () => export const getIntersectionObserverMock = () =>
vi.fn(function () { vi.fn(() => ({
return { disconnect: vi.fn(),
disconnect: vi.fn(), observe: vi.fn(),
observe: vi.fn(), takeRecords: vi.fn(),
takeRecords: vi.fn(), unobserve: vi.fn(),
unobserve: vi.fn(), }));
};
});
@@ -1,10 +1,8 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
export const getResizeObserverMock = () => export const getResizeObserverMock = () =>
vi.fn(function () { vi.fn(() => ({
return { disconnect: vi.fn(),
disconnect: vi.fn(), observe: vi.fn(),
observe: vi.fn(), unobserve: vi.fn(),
unobserve: vi.fn(), }));
};
});
@@ -1,25 +0,0 @@
import { cancelImageUrl } from '$lib/utils/sw-messaging';
export function loadImage(src: string, onLoad: () => void, onError: () => void, onStart?: () => void) {
let destroyed = false;
const handleLoad = () => !destroyed && onLoad();
const handleError = () => !destroyed && onError();
const img = document.createElement('img');
img.addEventListener('load', handleLoad);
img.addEventListener('error', handleError);
onStart?.();
img.src = src;
return () => {
destroyed = true;
img.removeEventListener('load', handleLoad);
img.removeEventListener('error', handleError);
cancelImageUrl(src);
img.remove();
};
}
export type LoadImageFunction = typeof loadImage;
+3 -10
View File
@@ -1,12 +1,8 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core'; import { createZoomImageWheel } from '@zoom-image/core';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean; zoomTarget?: HTMLElement }) => { export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
const zoomInstance = createZoomImageWheel(node, { const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
maxZoom: 10,
initialState: assetViewerManager.zoomState,
zoomTarget: options?.zoomTarget,
});
const unsubscribes = [ const unsubscribes = [
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }), assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
@@ -24,11 +20,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
node.style.overflow = 'visible'; node.style.overflow = 'visible';
return { return {
update(newOptions?: { disabled?: boolean; zoomTarget?: HTMLElement }) { update(newOptions?: { disabled?: boolean }) {
options = newOptions; options = newOptions;
if (newOptions?.zoomTarget !== undefined) {
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
}
}, },
destroy() { destroy() {
for (const unsubscribe of unsubscribes) { for (const unsubscribe of unsubscribes) {
-212
View File
@@ -1,212 +0,0 @@
<script lang="ts">
import { thumbhash } from '$lib/actions/thumbhash';
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import ImageLayer from '$lib/components/ImageLayer.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetUrls } from '$lib/utils';
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { untrack, type Snippet } from 'svelte';
type Props = {
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
objectFit?: 'contain' | 'cover';
container: {
width: number;
height: number;
};
onUrlChange?: (url: string) => void;
onImageReady?: () => void;
onError?: () => void;
ref?: HTMLDivElement;
imgRef?: HTMLImageElement;
backdrop?: Snippet;
overlays?: Snippet;
};
let {
ref = $bindable(),
// eslint-disable-next-line no-useless-assignment
imgRef = $bindable(),
asset,
sharedLink,
objectFit = 'contain',
container,
onUrlChange,
onImageReady,
onError,
backdrop,
overlays,
}: Props = $props();
const afterThumbnail = (loader: AdaptiveImageLoader) => {
if (assetViewerManager.zoom > 1) {
loader.trigger('original');
} else {
loader.trigger('preview');
}
};
const buildQualityList = () => {
const assetUrls = getAssetUrls(asset, sharedLink);
const qualityList: QualityList = [
{
quality: 'thumbnail',
url: assetUrls.thumbnail,
checkCanceled: false,
onAfterLoad: afterThumbnail,
onAfterError: afterThumbnail,
},
{
quality: 'preview',
url: assetUrls.preview,
checkCanceled: true,
onAfterError: (loader) => loader.trigger('original'),
},
{ quality: 'original', url: assetUrls.original, checkCanceled: true },
];
return qualityList;
};
const loaderKey = $derived(`${asset.id}:${asset.thumbhash}:${sharedLink?.id}`);
const adaptiveImageLoader = $derived.by(() => {
void loaderKey;
return untrack(
() =>
new AdaptiveImageLoader(asset.id, buildQualityList(), {
onImageReady,
onError,
onUrlChange,
}),
);
});
$effect.pre(() => {
const loader = adaptiveImageLoader;
untrack(() => assetViewerManager.resetZoomState());
return () => loader.destroy();
});
const imageDimensions = $derived.by(() => {
const { width, height } = asset;
if (width && width > 0 && height && height > 0) {
return { width, height };
}
return { width: 1, height: 1 };
});
const { width, height, left, top } = $derived.by(() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
return {
width: width + 'px',
height: height + 'px',
left: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
};
});
const { status } = $derived(adaptiveImageLoader);
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
const show = $derived.by(() => {
const { quality, started, hasError, urls } = status;
return {
alphaBackground: !hasError && started,
spinner: !asset.thumbhash && !started,
brokenAsset: hasError,
thumbhash: quality.thumbnail !== 'success' && quality.preview !== 'success' && quality.original !== 'success',
thumbnail: quality.thumbnail !== 'error' && quality.preview !== 'success' && quality.original !== 'success',
preview: quality.preview !== 'error' && quality.original !== 'success',
original: quality.original !== 'error' && urls.original !== undefined,
};
});
$effect(() => {
if (assetViewerManager.zoom > 1 && status.quality.preview === 'success' && status.quality.original !== 'success') {
untrack(() => void adaptiveImageLoader.trigger('original'));
}
});
let thumbnailElement = $state<HTMLImageElement>();
let previewElement = $state<HTMLImageElement>();
let originalElement = $state<HTMLImageElement>();
$effect(() => {
const quality = status.quality;
imgRef =
(quality.original === 'success' ? originalElement : undefined) ??
(quality.preview === 'success' ? previewElement : undefined) ??
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
});
</script>
<div class="relative h-full w-full overflow-hidden" bind:this={ref}>
{@render backdrop?.()}
<div class="absolute" style:left style:top style:width style:height>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
{width}
{height}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="text-xl h-full w-full absolute" />
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{/if}
</div>
</div>
@@ -1,11 +0,0 @@
<script lang="ts">
import type { ClassValue } from 'svelte/elements';
interface Props {
class?: ClassValue;
}
let { class: className = '' }: Props = $props();
</script>
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>
@@ -1,20 +0,0 @@
<script lang="ts">
import { LoadingSpinner } from '@immich/ui';
</script>
<div class="delayed-spinner absolute flex h-full items-center justify-center">
<LoadingSpinner />
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
.delayed-spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>
-47
View File
@@ -1,47 +0,0 @@
<script lang="ts">
import Image from '$lib/components/Image.svelte';
import type { AdaptiveImageLoader, ImageQuality } from '$lib/utils/adaptive-image-loader.svelte';
import type { Snippet } from 'svelte';
type Props = {
adaptiveImageLoader: AdaptiveImageLoader;
quality: ImageQuality;
src: string | undefined;
alt?: string;
role?: string;
ref?: HTMLImageElement;
width: string;
height: string;
overlays?: Snippet;
};
let {
adaptiveImageLoader,
quality,
src,
alt = '',
role,
ref = $bindable(),
width,
height,
overlays,
}: Props = $props();
</script>
{#key adaptiveImageLoader}
<div class="absolute top-0" style:width style:height>
<Image
{src}
onStart={() => adaptiveImageLoader.onStart(quality)}
onLoad={() => adaptiveImageLoader.onLoad(quality)}
onError={() => adaptiveImageLoader.onError(quality)}
bind:ref
class="h-full w-full"
{alt}
{role}
draggable={false}
data-testid={quality}
/>
{@render overlays?.()}
</div>
{/key}
@@ -1,103 +0,0 @@
import { loadImage } from '$lib/actions/image-loader.svelte';
import { getAssetUrls } from '$lib/utils';
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
import type { AssetResponseDto } from '@immich/sdk';
type AssetCursor = {
current: AssetResponseDto;
nextAsset?: AssetResponseDto;
previousAsset?: AssetResponseDto;
};
export class PreloadManager {
private nextPreloader: AdaptiveImageLoader | undefined;
private previousPreloader: AdaptiveImageLoader | undefined;
private startPreloader(asset: AssetResponseDto | undefined): AdaptiveImageLoader | undefined {
if (!asset) {
return;
}
const urls = getAssetUrls(asset);
const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview');
const qualityList: QualityList = [
{
quality: 'thumbnail',
url: urls.thumbnail,
checkCanceled: false,
onAfterLoad: afterThumbnail,
onAfterError: afterThumbnail,
},
{
quality: 'preview',
url: urls.preview,
checkCanceled: true,
onAfterError: (loader) => loader.trigger('original'),
},
{ quality: 'original', url: urls.original, checkCanceled: true },
];
const loader = new AdaptiveImageLoader(asset.id, qualityList, undefined, loadImage);
loader.start();
return loader;
}
private destroyPreviousPreloader() {
this.previousPreloader?.destroy();
this.previousPreloader = undefined;
}
private destroyNextPreloader() {
this.nextPreloader?.destroy();
this.nextPreloader = undefined;
}
cancelBeforeNavigation(direction: 'previous' | 'next') {
switch (direction) {
case 'next': {
this.destroyPreviousPreloader();
break;
}
case 'previous': {
this.destroyNextPreloader();
break;
}
}
}
updateAfterNavigation(oldCursor: AssetCursor, newCursor: AssetCursor) {
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
if (!movedBackward) {
this.destroyPreviousPreloader();
}
if (!movedForward) {
this.destroyNextPreloader();
}
if (movedForward) {
this.nextPreloader = this.startPreloader(newCursor.nextAsset);
} else if (movedBackward) {
this.previousPreloader = this.startPreloader(newCursor.previousAsset);
} else {
this.previousPreloader = this.startPreloader(newCursor.previousAsset);
this.nextPreloader = this.startPreloader(newCursor.nextAsset);
}
}
initializePreloads(cursor: AssetCursor) {
if (cursor.nextAsset) {
this.nextPreloader = this.startPreloader(cursor.nextAsset);
}
if (cursor.previousAsset) {
this.previousPreloader = this.startPreloader(cursor.previousAsset);
}
}
destroy() {
this.destroyNextPreloader();
this.destroyPreviousPreloader();
}
}
export const preloadManager = new PreloadManager();
@@ -18,19 +18,18 @@ describe('AssetViewerNavBar component', () => {
}; };
beforeAll(() => { beforeAll(() => {
Element.prototype.animate = vi.fn().mockImplementation(function () { Element.prototype.animate = vi.fn().mockImplementation(() => ({
return { cancel: () => {},
cancel: () => {}, }));
};
});
vi.stubGlobal('ResizeObserver', getResizeObserverMock()); vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () { vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
return { return {
featureFlagsManager: { featureFlagsManager: {
init: vi.fn(), init: vi.fn(),
loadFeatureFlags: vi.fn(), loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true }, value: { trash: true, smartSearch: true },
} as never, // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
}; };
}); });
}); });
@@ -21,7 +21,7 @@
import { languageManager } from '$lib/managers/language-manager.svelte'; import { languageManager } from '$lib/managers/language-manager.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 { getAssetActions } from '$lib/services/asset.service'; import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { getSharedLink, withoutIcons } from '$lib/utils'; import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions'; import type { OnUndoDelete } from '$lib/utils/actions';
@@ -42,6 +42,7 @@
mdiDotsVertical, mdiDotsVertical,
mdiImageSearch, mdiImageSearch,
mdiPresentationPlay, mdiPresentationPlay,
mdiUpload,
mdiVideoOutline, mdiVideoOutline,
} from '@mdi/js'; } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -169,6 +170,11 @@
{#if !isLocked} {#if !isLocked}
{#if isOwner} {#if isOwner}
<ArchiveAction {asset} {onAction} {preAction} /> <ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiUpload}
onClick={() => handleReplaceAsset(asset.id)}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed} {#if !asset.isArchived && !asset.isTrashed}
<MenuOption <MenuOption
icon={mdiImageSearch} icon={mdiImageSearch}
@@ -1,20 +1,22 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap'; import { focusTrap } from '$lib/actions/focus-trap';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction, ProjectionType } from '$lib/constants'; import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte'; import { activityManager } from '$lib/managers/activity-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 { 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 { imageManager } from '$lib/managers/ImageManager.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service'; import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
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';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -93,20 +95,20 @@
stopProgress: stopSlideshowProgress, stopProgress: stopSlideshowProgress,
slideshowNavigation, slideshowNavigation,
slideshowState, slideshowState,
slideshowTransition,
slideshowRepeat, slideshowRepeat,
} = slideshowStore; } = slideshowStore;
const stackThumbnailSize = 60; const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65; const stackSelectedThumbnailSize = 65;
let stack: StackResponseDto | undefined = $state(); const asset = $derived(cursor.current);
let selectedStackAsset = $derived(stack?.assets.find(({ id }) => id === stack?.primaryAssetId));
let previewStackedAsset: AssetResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset); const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset); const previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink(); let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let fullscreenElement = $state<Element>(); let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
let stack: StackResponseDto | null = $state(null);
let playOriginalVideo = $state($alwaysLoadOriginalVideo); let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>(); let slideshowStartAssetId = $state<string>();
@@ -116,60 +118,62 @@
}; };
const refreshStack = async () => { const refreshStack = async () => {
if (authManager.isSharedLink || !withStacked) { if (authManager.isSharedLink) {
return; return;
} }
if (!cursor.current.stack) { if (asset.stack) {
stack = undefined; stack = await getStack({ id: asset.stack.id });
return;
} }
stack = await getStack({ id: cursor.current.stack.id }); if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
untrack(() => {
imageManager.preload(stack?.assets[1]);
});
}; };
const handleFavorite = async () => { const handleFavorite = async () => {
if (!album || !album.isActivityEnabled) { if (album && album.isActivityEnabled) {
return; try {
} await activityManager.toggleLike();
} catch (error) {
try { handleError(error, $t('errors.unable_to_change_favorite'));
await activityManager.toggleLike(); }
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
} }
}; };
onMount(() => { onMount(() => {
syncAssetViewerOpenClass(true); syncAssetViewerOpenClass(true);
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => { unsubscribes.push(
if (value === SlideshowState.PlaySlideshow) { slideshowState.subscribe((value) => {
slideshowHistory.reset(); if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.queue(toTimelineAsset(asset)); slideshowHistory.reset();
handlePromiseError(handlePlaySlideshow()); slideshowHistory.queue(toTimelineAsset(asset));
} else if (value === SlideshowState.StopSlideshow) { handlePromiseError(handlePlaySlideshow());
handlePromiseError(handleStopSlideshow()); } else if (value === SlideshowState.StopSlideshow) {
} handlePromiseError(handleStopSlideshow());
}); }
}),
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => { slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) { if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset(); slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset)); slideshowHistory.queue(toTimelineAsset(asset));
} }
}); }),
);
return () => {
slideshowStateUnsubscribe();
slideshowNavigationUnsubscribe();
};
}); });
onDestroy(() => { onDestroy(() => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
activityManager.reset(); activityManager.reset();
assetViewerManager.closeEditor(); assetViewerManager.closeEditor();
syncAssetViewerOpenClass(false); syncAssetViewerOpenClass(false);
preloadManager.destroy();
}); });
const closeViewer = () => { const closeViewer = () => {
@@ -185,57 +189,52 @@
assetViewerManager.closeEditor(); assetViewerManager.closeEditor();
}; };
const getNavigationTarget = () => { const tracker = new InvocationTracker();
if ($slideshowState === SlideshowState.PlaySlideshow) {
return $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
} else { if (!order) {
return 'skip'; if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
} else {
return;
}
} }
};
const completeNavigation = async (target: 'previous' | 'next') => { e?.stopPropagation();
preloadManager.cancelBeforeNavigation(target); imageManager.cancel(asset);
let hasNext: boolean; if (tracker.isActive()) {
return;
}
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { void tracker.invoke(async () => {
hasNext = target === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); let hasNext: boolean;
if (!hasNext) {
const asset = await onRandom?.(); if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
if (asset) { hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
slideshowHistory.queue(asset); if (!hasNext) {
hasNext = true; 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) {
if (hasNext) {
$restartSlideshowProgress = true;
} else if ($slideshowRepeat && slideshowStartAssetId) {
// Loop back to starting asset
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
} }
} }
} else { }, $t('error_while_navigating'));
hasNext =
target === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
}
if ($slideshowState !== SlideshowState.PlaySlideshow) {
return;
}
if (hasNext) {
$restartSlideshowProgress = true;
} else if ($slideshowRepeat && slideshowStartAssetId) {
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
};
const tracker = new InvocationTracker();
const navigateAsset = (target: 'previous' | 'next' | 'skip') => {
if (target === 'skip' || tracker.isActive()) {
return;
}
void tracker.invoke(
() => completeNavigation(target),
(error: unknown) => handleError(error, $t('error_while_navigating')),
() => eventManager.emit('ViewerFinishNavigate'),
);
}; };
/** /**
@@ -278,10 +277,12 @@
} }
}; };
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? asset : undefined;
};
const handlePreAction = (action: Action) => { const handlePreAction = (action: Action) => {
preAction?.(action); preAction?.(action);
}; };
const handleAction = async (action: Action) => { const handleAction = async (action: Action) => {
switch (action.type) { switch (action.type) {
case AssetAction.DELETE: case AssetAction.DELETE:
@@ -290,7 +291,7 @@
break; break;
} }
case AssetAction.REMOVE_ASSET_FROM_STACK: { case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack ?? undefined; stack = action.stack;
if (stack) { if (stack) {
cursor.current = stack.assets[0]; cursor.current = stack.assets[0];
} }
@@ -347,41 +348,39 @@
const refresh = async () => { const refresh = async () => {
await refreshStack(); await refreshStack();
ocrManager.clear(); ocrManager.clear();
if (sharedLink) { if (!sharedLink) {
return; if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
} }
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
}; };
$effect(() => { $effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
cursor.current; asset;
untrack(() => handlePromiseError(refresh())); untrack(() => handlePromiseError(refresh()));
imageManager.preload(cursor.nextAsset);
imageManager.preload(cursor.previousAsset);
}); });
let lastCursor = $state<AssetCursor>(); const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
if (oldAssetId !== asset.id) {
$effect(() => {
if (cursor.current.id === lastCursor?.current.id) {
return; return;
} }
if (lastCursor) {
selectedStackAsset = undefined; await new Promise((promise) => setTimeout(promise, 500));
previewStackedAsset = undefined; await goto(Route.viewAsset({ id: newAssetId }));
preloadManager.updateAfterNavigation(lastCursor, cursor); };
const onAssetUpdate = (update: AssetResponseDto) => {
if (asset.id === update.id) {
cursor = { ...cursor, current: update };
} }
if (!lastCursor) { };
preloadManager.initializePreloads(cursor);
}
lastCursor = cursor;
});
const viewerKind = $derived.by(() => { const viewerKind = $derived.by(() => {
if (previewStackedAsset) { if (previewStackedAsset) {
return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
} }
if (asset.type === AssetTypeEnum.Video) { if (asset.type === AssetTypeEnum.Video) {
return 'VideoViewer'; return 'VideoViewer';
@@ -425,6 +424,7 @@
</script> </script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} /> <CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<svelte:document bind:fullscreenElement /> <svelte:document bind:fullscreenElement />
@@ -467,36 +467,44 @@
</div> </div>
{/if} {/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"> <div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} /> <PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div> </div>
{/if} {/if}
<!-- Asset Viewer --> <!-- Asset Viewer -->
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full"> <div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackVideoViewer'} {#if viewerKind === 'StackPhotoViewer'}
<VideoViewer <PhotoViewer
cursor={{ ...cursor, current: previewStackedAsset! }} cursor={{ ...cursor, current: previewStackedAsset! }}
assetId={previewStackedAsset!.id} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
asset={previewStackedAsset!}
cacheKey={previewStackedAsset!.thumbhash} cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType} projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true} loopVideo={true}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer} onClose={closeViewer}
onVideoEnded={() => navigateAsset(getNavigationTarget())} onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted} onVideoStarted={handleVideoStarted}
{playOriginalVideo} {playOriginalVideo}
/> />
{:else if viewerKind === 'LiveVideoViewer'} {:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer <VideoViewer
{cursor} {asset}
assetId={asset.livePhotoVideoId!} assetId={asset.livePhotoVideoId!}
{sharedLink}
cacheKey={asset.thumbhash} cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType} projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)} onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo} {playOriginalVideo}
/> />
@@ -506,20 +514,22 @@
<CropArea {asset} /> <CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'} {:else if viewerKind === 'PhotoViewer'}
<PhotoViewer <PhotoViewer
cursor={{ ...cursor, current: asset }} {cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink} {sharedLink}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')} haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/> />
{:else if viewerKind === 'VideoViewer'} {:else if viewerKind === 'VideoViewer'}
<VideoViewer <VideoViewer
{cursor} {asset}
{sharedLink}
cacheKey={asset.thumbhash} cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType} projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer} onClose={closeViewer}
onVideoEnded={() => navigateAsset(getNavigationTarget())} onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted} onVideoStarted={handleVideoStarted}
{playOriginalVideo} {playOriginalVideo}
/> />
@@ -544,7 +554,7 @@
{/if} {/if}
</div> </div>
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"> <div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} /> <NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div> </div>
@@ -572,14 +582,10 @@
{#if stack && withStacked && !assetViewerManager.isShowEditor} {#if stack && withStacked && !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"> <div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div <div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
role="presentation"
class="relative inline-flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
onmouseleave={() => (previewStackedAsset = undefined)}
>
{#each stackedAssets as stackedAsset (stackedAsset.id)} {#each stackedAssets as stackedAsset (stackedAsset.id)}
<div <div
class={['inline-block px-1 relative transition-all pb-2']} class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'} style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
> >
<Thumbnail <Thumbnail
@@ -588,19 +594,21 @@
dimmed={stackedAsset.id !== asset.id} dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)} asset={toTimelineAsset(stackedAsset)}
onClick={() => { onClick={() => {
selectedStackAsset = stackedAsset; cursor.current = stackedAsset;
previewStackedAsset = undefined; previewStackedAsset = undefined;
}} }}
onMouseEvent={({ isMouseOver }) => isMouseOver && (previewStackedAsset = stackedAsset)} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
readonly readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize} thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false} showStackedIcon={false}
disableLinkMouseOver disableLinkMouseOver
/> />
<div class="w-full flex place-items-center place-content-center"> {#if stackedAsset.id === asset.id}
<div class={['w-2 h-2 rounded-full flex mt-0.5', { 'bg-white': stackedAsset.id === asset.id }]}></div> <div class="w-full flex place-items-center place-content-center">
</div> <div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
</div>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -3,7 +3,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui'; import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -81,20 +81,15 @@
await getPeople(); await getPeople();
}); });
const imageContentMetrics = $derived.by(() => {
const natural = getNaturalSize(htmlElement);
const container = { width: containerWidth, height: containerHeight };
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
return {
contentWidth,
contentHeight,
offsetX: (containerWidth - contentWidth) / 2,
offsetY: (containerHeight - contentHeight) / 2,
};
});
$effect(() => { $effect(() => {
const { offsetX, offsetY } = imageContentMetrics; const metrics = getContentMetrics(htmlElement);
const imageBoundingBox = {
top: metrics.offsetY,
left: metrics.offsetX,
width: metrics.contentWidth,
height: metrics.contentHeight,
};
if (!canvas) { if (!canvas) {
return; return;
@@ -110,8 +105,8 @@
} }
faceRect.set({ faceRect.set({
top: offsetY + 200, top: imageBoundingBox.top + 200,
left: offsetX + 200, left: imageBoundingBox.left + 200,
}); });
faceRect.setCoords(); faceRect.setCoords();
@@ -219,13 +214,13 @@
} }
const { left, top, width, height } = faceRect.getBoundingRect(); const { left, top, width, height } = faceRect.getBoundingRect();
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; const metrics = getContentMetrics(htmlElement);
const natural = getNaturalSize(htmlElement); const natural = getNaturalSize(htmlElement);
const scaleX = natural.width / contentWidth; const scaleX = natural.width / metrics.contentWidth;
const scaleY = natural.height / contentHeight; const scaleY = natural.height / metrics.contentHeight;
const imageX = (left - offsetX) * scaleX; const imageX = (left - metrics.offsetX) * scaleX;
const imageY = (top - offsetY) * scaleY; const imageY = (top - metrics.offsetY) * scaleY;
return { return {
imageWidth: natural.width, imageWidth: natural.width,
@@ -19,7 +19,7 @@
<div class="absolute left-0 top-0"> <div class="absolute left-0 top-0">
<div <div
class="absolute flex items-center justify-center text-transparent text-sm border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text transition-colors hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3" class="absolute flex items-center justify-center text-transparent text-sm border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text transition-all hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3"
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;" style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;"
> >
{ocrBox.text} {ocrBox.text}
@@ -1,57 +1,66 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import { thumbhash } from '$lib/actions/thumbhash';
import { zoomImageAction } from '$lib/actions/zoom-image'; import { zoomImageAction } from '$lib/actions/zoom-image';
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte'; import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils'; import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils'; import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils'; import { getBoundingBox } from '$lib/utils/people-utils';
import { type SharedLinkResponseDto } from '@immich/sdk'; import { getAltText } from '$lib/utils/thumbnail-util';
import { toastManager } from '@immich/ui'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, untrack } from 'svelte'; import { onDestroy, untrack } from 'svelte';
import { fromAction } from 'svelte/attachments'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { AssetCursor } from './asset-viewer.svelte'; import type { AssetCursor } from './asset-viewer.svelte';
interface Props { interface Props {
cursor: AssetCursor; cursor: AssetCursor;
element?: HTMLDivElement; element?: HTMLDivElement | undefined;
sharedLink?: SharedLinkResponseDto; haveFadeTransition?: boolean;
onReady?: () => void; sharedLink?: SharedLinkResponseDto | undefined;
onError?: () => void; onPreviousAsset?: (() => void) | null;
onSwipe?: (direction: 'left' | 'right') => void; onNextAsset?: (() => void) | null;
} }
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props(); let {
cursor,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore; const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current); const asset = $derived(cursor.current);
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
let visibleImageReady: boolean = $state(false); let visibleImageReady: boolean = $state(false);
let previousAssetId: string | undefined; let loader = $state<HTMLImageElement>();
$effect.pre(() => { $effect.pre(() => {
const id = asset.id; void asset.id;
if (id === previousAssetId) {
return;
}
previousAssetId = id;
untrack(() => { untrack(() => {
assetViewerManager.resetZoomState(); assetViewerManager.resetZoomState();
visibleImageReady = false;
$boundingBoxesArray = []; $boundingBoxesArray = [];
}); });
}); });
@@ -60,30 +69,25 @@
$boundingBoxesArray = []; $boundingBoxesArray = [];
}); });
let containerWidth = $state(0);
let containerHeight = $state(0);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
const overlayMetrics = $derived.by((): ContentMetrics => { const overlayMetrics = $derived.by((): ContentMetrics => {
if (!assetViewerManager.imgRef || !visibleImageReady) { if (!assetViewerManager.imgRef || !visibleImageReady) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 }; return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
} }
const natural = getNaturalSize(assetViewerManager.imgRef); const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container); const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
return { return {
contentWidth: scaled.width, contentWidth: contentWidth * currentZoom,
contentHeight: scaled.height, contentHeight: contentHeight * currentZoom,
offsetX: 0, offsetX: offsetX * currentZoom + currentPositionX,
offsetY: 0, offsetY: offsetY * currentZoom + currentPositionY,
}; };
}); });
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []); let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
let isOcrActive = $derived(ocrManager.showOverlay);
const onCopy = async () => { const onCopy = async () => {
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) { if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
@@ -120,15 +124,29 @@
handlePromiseError(onCopy()); handlePromiseError(onCopy());
}; };
let currentPreviewUrl = $state<string>(); const onSwipe = (event: SwipeCustomEvent) => {
if (assetViewerManager.zoom > 1) {
return;
}
const onUrlChange = (url: string) => { if (ocrManager.showOverlay) {
currentPreviewUrl = url; return;
}
if (onNextAsset && event.detail.direction === 'left') {
onNextAsset();
}
if (onPreviousAsset && event.detail.direction === 'right') {
onPreviousAsset();
}
}; };
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
$effect(() => { $effect(() => {
if (currentPreviewUrl) { if (imageLoaderUrl) {
void cast(currentPreviewUrl); void cast(imageLoaderUrl);
} }
}); });
@@ -146,30 +164,36 @@
} }
}; };
const blurredSlideshow = $derived( const onload = () => {
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash, imageLoaded = true;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
imageError = imageLoaded = true;
};
onDestroy(() => imageManager.cancel(asset, targetImageSize));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
); );
let adaptiveImage = $state<HTMLDivElement | undefined>(); let containerWidth = $state(0);
let containerHeight = $state(0);
const faceToNameMap = $derived.by(() => { let lastUrl: string | undefined;
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>();
for (const person of asset.people ?? []) {
for (const face of person.faces ?? []) {
map.set(face, person.name);
}
}
return map;
});
const faces = $derived(Array.from(faceToNameMap.keys()));
let swipeFeedbackReset = $state<(() => void) | undefined>();
$effect(() => { $effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions if (lastUrl && lastUrl !== imageLoaderUrl) {
asset.id; untrack(() => {
untrack(() => swipeFeedbackReset?.()); imageLoaded = false;
originalImageLoaded = false;
imageError = false;
visibleImageReady = false;
});
}
lastUrl = imageLoaderUrl;
}); });
</script> </script>
@@ -183,110 +207,74 @@
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
]} ]}
/> />
{#if imageError}
<SwipeFeedback <div id="broken-asset" class="h-full w-full">
bind:element <BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
<div
bind:this={element}
class="relative h-full w-full select-none" class="relative h-full w-full select-none"
bind:clientWidth={containerWidth} bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight} bind:clientHeight={containerHeight}
disabled={!onSwipe || ocrManager.showOverlay || assetViewerManager.zoom > 1}
disableSwipeLeft={!cursor.nextAsset}
disableSwipeRight={!cursor.previousAsset}
bind:reset={swipeFeedbackReset}
onSwipe={onSwipe ?? (() => {})}
{@attach fromAction(zoomImageAction, () => ({ disabled: isFaceEditMode.value, zoomTarget: adaptiveImage }))}
> >
<AdaptiveImage {#if !imageLoaded}
{asset} <div id="spinner" class="flex h-full items-center justify-center">
{sharedLink} <LoadingSpinner />
{container} </div>
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'} {:else if !imageError}
{onUrlChange} <div
onImageReady={() => { use:zoomImageAction={{ disabled: isOcrActive }}
visibleImageReady = true; {...useSwipe(onSwipe)}
onReady?.(); class="h-full w-full"
}} transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
onError={() => { >
onError?.(); {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
onReady?.(); <img
}} src={imageLoaderUrl}
bind:imgRef={assetViewerManager.imgRef} alt=""
bind:ref={adaptiveImage} class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
> draggable="false"
{#snippet backdrop()} />
{#if blurredSlideshow}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw"
></canvas>
{/if} {/if}
{/snippet} <img
{#snippet overlays()} bind:this={assetViewerManager.imgRef}
{#if !isFaceEditMode.value && !ocrManager.showOverlay} src={imageLoaderUrl}
{#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)} onload={() => (visibleImageReady = true)}
<button alt={$getAltText(toTimelineAsset(asset))}
type="button" class="h-full w-full {$slideshowState === SlideshowState.None
class="absolute pointer-events-auto outline-none" ? 'object-contain'
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;" : slideshowLookCssMapping[$slideshowLook]}"
aria-label="{$t('person')}: {faceToNameMap.get(faces[index]) || $t('unknown')}" draggable="false"
onmouseenter={() => ($boundingBoxesArray = [faces[index]])} />
onmouseleave={() => ($boundingBoxesArray = [])} {#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox (boundingbox.id)}
onfocus={() => ($boundingBoxesArray = [faces[index]])}
onblur={() => ($boundingBoxesArray = [])}
></button>
{/each}
{/if}
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
<div <div
class="absolute border-solid border-white border-3 rounded-lg pointer-events-none" class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;" style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div> ></div>
{#if faceToNameMap.get($boundingBoxesArray[index])}
<div
aria-hidden="true"
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
boundingbox.width}px; transform: translateX(-100%);"
>
{faceToNameMap.get($boundingBoxesArray[index])}
</div>
{/if}
{/each} {/each}
{#each ocrBoxes as ocrBox (ocrBox.id)} {#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} /> <OcrBoundingBox {ocrBox} />
{/each} {/each}
{/snippet} </div>
</AdaptiveImage>
{#if isFaceEditMode.value && assetViewerManager.imgRef} {#if isFaceEditMode.value}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} /> <FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{/if} {/if}
</div>
{#snippet leftPreview()} <style>
{#if cursor.previousAsset} @keyframes delayedVisibility {
<AdaptiveImage to {
asset={cursor.previousAsset} visibility: visible;
{sharedLink} }
{container} }
imageClass="object-contain" #broken-asset,
slideshowState={$slideshowState} #spinner {
slideshowLook={$slideshowLook} visibility: hidden;
/> animation: 0s linear 0.4s forwards delayedVisibility;
{/if} }
{/snippet} </style>
{#snippet rightPreview()}
{#if cursor.nextAsset}
<AdaptiveImage
asset={cursor.nextAsset}
{sharedLink}
{container}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
</SwipeFeedback>
@@ -1,393 +0,0 @@
<script lang="ts">
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { Snippet } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
type SwipeProps = {
disabled?: boolean;
disableSwipeLeft?: boolean;
disableSwipeRight?: boolean;
onSwipeEnd?: (offsetX: number) => void;
onSwipeMove?: (offsetX: number) => void;
onSwipe: (direction: 'left' | 'right') => void;
element?: HTMLDivElement;
clientWidth?: number;
clientHeight?: number;
reset?: () => void;
children: Snippet;
leftPreview?: Snippet;
rightPreview?: Snippet;
} & HTMLAttributes<HTMLDivElement>;
let {
disabled = false,
disableSwipeLeft = false,
disableSwipeRight = false,
onSwipeEnd,
onSwipeMove,
onSwipe,
element = $bindable(),
clientWidth = $bindable(),
clientHeight = $bindable(),
// eslint-disable-next-line no-useless-assignment
reset = $bindable(),
children,
leftPreview,
rightPreview,
...restProps
}: SwipeProps = $props();
interface SwipeAnimations {
currentImageAnimation: Animation;
previewAnimation: Animation | null;
abortController: AbortController;
}
const ANIMATION_DURATION_MS = 300;
// Tolerance to avoid edge cases where animation is nearly complete but not exactly at end
const ANIMATION_COMPLETION_TOLERANCE_MS = 5;
const SWIPE_THRESHOLD = 45;
// Minimum velocity to trigger swipe (tuned for natural flick gesture)
const MIN_SWIPE_VELOCITY = 0.11; // pixels per millisecond
// Require 25% drag progress if velocity is too low (prevents accidental swipes)
const MIN_PROGRESS_THRESHOLD = 0.25;
const ENABLE_SCALE_ANIMATION = false;
let contentElement: HTMLElement | undefined = $state();
let leftPreviewContainer: HTMLDivElement | undefined = $state();
let rightPreviewContainer: HTMLDivElement | undefined = $state();
let isDragging = $state(false);
let startX = $state(0);
let currentOffsetX = $state(0);
let dragStartTime: number | null = $state(null);
let leftAnimations: SwipeAnimations | null = $state(null);
let rightAnimations: SwipeAnimations | null = $state(null);
let isSwipeInProgress = $state(false);
const cursorStyle = $derived(disabled ? '' : isDragging ? 'grabbing' : 'grab');
const isValidPointerEvent = (event: PointerEvent) =>
event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0);
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
if (!contentElement) {
return null;
}
const createAnimationKeyframes = (direction: 'left' | 'right', isPreview: boolean) => {
const scale = (s: number) => (ENABLE_SCALE_ANIMATION ? ` scale(${s})` : '');
const sign = direction === 'left' ? -1 : 1;
if (isPreview) {
return [
{ transform: `translateX(${sign * -100}vw)${scale(0)}`, opacity: '0', offset: 0 },
{ transform: `translateX(${sign * -80}vw)${scale(0.2)}`, opacity: '0', offset: 0.2 },
{ transform: `translateX(${sign * -50}vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
{ transform: `translateX(${sign * -20}vw)${scale(0.8)}`, opacity: '1', offset: 0.8 },
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 1 },
];
}
return [
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
{ transform: `translateX(${sign * 100}vw)${scale(0)}`, opacity: '0', offset: 1 },
];
};
contentElement.style.transformOrigin = 'center';
const currentImageAnimation = contentElement.animate(createAnimationKeyframes(direction, false), {
duration: ANIMATION_DURATION_MS,
easing: 'linear',
fill: 'both',
});
// Preview slides in from opposite side of swipe direction
const previewContainer = direction === 'left' ? rightPreviewContainer : leftPreviewContainer;
let previewAnimation: Animation | null = null;
if (previewContainer) {
previewContainer.style.transformOrigin = 'center';
previewAnimation = previewContainer.animate(createAnimationKeyframes(direction, true), {
duration: ANIMATION_DURATION_MS,
easing: 'linear',
fill: 'both',
});
}
currentImageAnimation.pause();
previewAnimation?.pause();
const abortController = new AbortController();
return { currentImageAnimation, previewAnimation, abortController };
};
const setAnimationTime = (animations: SwipeAnimations, time: number) => {
animations.currentImageAnimation.currentTime = time;
if (animations.previewAnimation) {
animations.previewAnimation.currentTime = time;
}
};
const playAnimation = (animations: SwipeAnimations, playbackRate: number) => {
animations.currentImageAnimation.playbackRate = playbackRate;
if (animations.previewAnimation) {
animations.previewAnimation.playbackRate = playbackRate;
}
animations.currentImageAnimation.play();
animations.previewAnimation?.play();
};
const cancelAnimations = (animations: SwipeAnimations | null) => {
if (!animations) {
return;
}
animations.abortController.abort();
animations.currentImageAnimation.cancel();
animations.previewAnimation?.cancel();
};
const handlePointerDown = (event: PointerEvent) => {
if (disabled || !contentElement || !isValidPointerEvent(event) || !element || isSwipeInProgress) {
return;
}
startDrag(event);
event.preventDefault();
};
const startDrag = (event: PointerEvent) => {
if (!element) {
return;
}
isDragging = true;
startX = event.clientX;
currentOffsetX = 0;
element.setPointerCapture(event.pointerId);
dragStartTime = Date.now();
};
const handlePointerMove = (event: PointerEvent) => {
if (disabled || !contentElement || !isDragging || isSwipeInProgress) {
return;
}
const rawOffsetX = event.clientX - startX;
const direction = rawOffsetX < 0 ? 'left' : 'right';
if ((direction === 'left' && disableSwipeLeft) || (direction === 'right' && disableSwipeRight)) {
currentOffsetX = 0;
cancelAnimations(leftAnimations);
cancelAnimations(rightAnimations);
leftAnimations = null;
rightAnimations = null;
return;
}
currentOffsetX = rawOffsetX;
const animationTime = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1) * ANIMATION_DURATION_MS;
if (direction === 'left') {
if (!leftAnimations) {
leftAnimations = createSwipeAnimations('left');
}
if (leftAnimations) {
setAnimationTime(leftAnimations, animationTime);
}
if (rightAnimations) {
cancelAnimations(rightAnimations);
rightAnimations = null;
}
} else {
if (!rightAnimations) {
rightAnimations = createSwipeAnimations('right');
}
if (rightAnimations) {
setAnimationTime(rightAnimations, animationTime);
}
if (leftAnimations) {
cancelAnimations(leftAnimations);
leftAnimations = null;
}
}
onSwipeMove?.(currentOffsetX);
event.preventDefault(); // Prevent scrolling during drag
};
const handlePointerUp = (event: PointerEvent) => {
if (!isDragging || !isValidPointerEvent(event) || !contentElement || !element) {
return;
}
isDragging = false;
if (element.hasPointerCapture(event.pointerId)) {
element.releasePointerCapture(event.pointerId);
}
const dragDuration = dragStartTime ? Date.now() - dragStartTime : 0;
const velocity = dragDuration > 0 ? Math.abs(currentOffsetX) / dragDuration : 0;
const progress = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1);
if (
Math.abs(currentOffsetX) < SWIPE_THRESHOLD ||
(velocity < MIN_SWIPE_VELOCITY && progress <= MIN_PROGRESS_THRESHOLD)
) {
resetPosition();
return;
}
isSwipeInProgress = true;
onSwipeEnd?.(currentOffsetX);
completeTransition(currentOffsetX > 0 ? 'right' : 'left');
};
const resetPosition = () => {
if (!contentElement) {
return;
}
const direction = currentOffsetX < 0 ? 'left' : 'right';
const animations = direction === 'left' ? leftAnimations : rightAnimations;
if (!animations) {
currentOffsetX = 0;
return;
}
playAnimation(animations, -1);
const handleFinish = () => {
cancelAnimations(animations);
if (direction === 'left') {
leftAnimations = null;
} else {
rightAnimations = null;
}
};
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
signal: animations.abortController.signal,
});
currentOffsetX = 0;
};
const completeTransition = (direction: 'left' | 'right') => {
if (!contentElement) {
return;
}
const animations = direction === 'left' ? leftAnimations : rightAnimations;
if (!animations) {
return;
}
const currentTime = Number(animations.currentImageAnimation.currentTime) || 0;
if (currentTime >= ANIMATION_DURATION_MS - ANIMATION_COMPLETION_TOLERANCE_MS) {
onSwipe(direction);
return;
}
playAnimation(animations, 1);
const handleFinish = () => {
if (contentElement) {
onSwipe(direction);
}
};
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
signal: animations.abortController.signal,
});
};
const resetPreviewContainers = () => {
cancelAnimations(leftAnimations);
cancelAnimations(rightAnimations);
leftAnimations = null;
rightAnimations = null;
if (contentElement) {
contentElement.style.transform = '';
contentElement.style.transition = '';
contentElement.style.opacity = '';
}
currentOffsetX = 0;
};
const finishSwipeInProgress = () => {
isSwipeInProgress = false;
};
const resetSwipeFeedback = () => {
resetPreviewContainers();
finishSwipeInProgress();
};
// eslint-disable-next-line no-useless-assignment
reset = resetSwipeFeedback;
onMount(() =>
eventManager.on({
ViewerFinishNavigate: finishSwipeInProgress,
ResetSwipeFeedback: resetSwipeFeedback,
}),
);
onDestroy(() => {
resetSwipeFeedback();
if (element) {
element.style.cursor = '';
}
if (contentElement) {
contentElement.style.cursor = '';
}
});
</script>
<!-- Listen on window to catch pointer release outside element (due to setPointerCapture) -->
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
<div
{...restProps}
bind:this={element}
bind:clientWidth
bind:clientHeight
style:cursor={cursorStyle}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
role="presentation"
>
{#if leftPreview}
<!-- Swiping right reveals left preview -->
<div
bind:this={leftPreviewContainer}
class="absolute inset-0"
style:pointer-events="none"
style:display={rightAnimations ? 'block' : 'none'}
>
{@render leftPreview()}
</div>
{/if}
{#if rightPreview}
<!-- Swiping left reveals right preview -->
<div
bind:this={rightPreviewContainer}
class="absolute inset-0"
style:pointer-events="none"
style:display={leftAnimations ? 'block' : 'none'}
>
{@render rightPreview()}
</div>
{/if}
<div bind:this={contentElement} class="h-full w-full" style:cursor={cursorStyle}>
{@render children()}
</div>
</div>
@@ -1,8 +1,5 @@
<script lang="ts"> <script lang="ts">
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte'; import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants'; import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
@@ -13,83 +10,56 @@
videoViewerMuted, videoViewerMuted,
videoViewerVolume, videoViewerVolume,
} from '$lib/stores/preferences.store'; } from '$lib/stores/preferences.store';
import { slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils'; import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { scaleToFit } from '$lib/utils/container-utils'; import { AssetMediaSize } from '@immich/sdk';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui'; import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
interface Props { interface Props {
cursor: AssetCursor; assetId: string;
assetId?: string;
sharedLink?: SharedLinkResponseDto;
loopVideo: boolean; loopVideo: boolean;
cacheKey: string | null; cacheKey: string | null;
playOriginalVideo: boolean; playOriginalVideo: boolean;
onSwipe: (direction: 'left' | 'right') => void; onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void; onVideoEnded?: () => void;
onVideoStarted?: () => void; onVideoStarted?: () => void;
onClose?: () => void; onClose?: () => void;
} }
let { let {
cursor,
assetId, assetId,
sharedLink,
loopVideo, loopVideo,
cacheKey, cacheKey,
playOriginalVideo, playOriginalVideo,
onSwipe, onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {}, onVideoEnded = () => {},
onVideoStarted = () => {}, onVideoStarted = () => {},
onClose = () => {}, onClose = () => {},
}: Props = $props(); }: Props = $props();
const asset = $derived(cursor.current);
const previousAsset = $derived(cursor.previousAsset);
const nextAsset = $derived(cursor.nextAsset);
const effectiveAssetId = $derived(assetId ?? asset.id);
const { slideshowState, slideshowLook } = slideshowStore;
let videoPlayer: HTMLVideoElement | undefined = $state(); let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true); let isLoading = $state(true);
let assetFileUrl = $derived( let assetFileUrl = $derived(
playOriginalVideo playOriginalVideo
? getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Original, cacheKey }) ? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: effectiveAssetId, cacheKey }), : getAssetPlaybackUrl({ id: assetId, cacheKey }),
); );
let previousAssetFileUrl = $state<string | undefined>();
let isScrubbing = $state(false); let isScrubbing = $state(false);
let showVideo = $state(false); let showVideo = $state(false);
let containerWidth = $state(document.documentElement.clientWidth);
let containerHeight = $state(document.documentElement.clientHeight);
const assetDimensions = $derived(
(asset.width ?? 0) > 0 && (asset.height ?? 0) > 0 ? { width: asset.width!, height: asset.height! } : null,
);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
let dimensions = $derived(assetDimensions ?? { width: 1, height: 1 });
const scaledDimensions = $derived(scaleToFit(dimensions, container));
onMount(() => { onMount(() => {
// Show video after mount to ensure fading in. // Show video after mount to ensure fading in.
showVideo = true; showVideo = true;
}); });
$effect(() => { $effect(() => {
if (assetFileUrl && assetFileUrl !== previousAssetFileUrl) { // reactive on `assetFileUrl` changes
previousAssetFileUrl = assetFileUrl; if (assetFileUrl) {
untrack(() => { videoPlayer?.load();
isLoading = true;
videoPlayer?.load();
});
} }
}); });
@@ -99,13 +69,6 @@
} }
}); });
const handleLoadedMetadata = () => {
dimensions = {
width: videoPlayer?.videoWidth ?? 1,
height: videoPlayer?.videoHeight ?? 1,
};
};
const handleCanPlay = async (video: HTMLVideoElement) => { const handleCanPlay = async (video: HTMLVideoElement) => {
try { try {
if (!video.paused && !isScrubbing) { if (!video.paused && !isScrubbing) {
@@ -137,116 +100,76 @@
} }
}; };
const onSwipe = (event: SwipeCustomEvent) => {
if (event.detail.direction === 'left') {
onNextAsset();
}
if (event.detail.direction === 'right') {
onPreviousAsset();
}
};
let containerWidth = $state(0);
let containerHeight = $state(0);
$effect(() => { $effect(() => {
if (isFaceEditMode.value) { if (isFaceEditMode.value) {
videoPlayer?.pause(); videoPlayer?.pause();
} }
}); });
const calculateSize = () => {
const { width, height } = scaledDimensions;
const size = {
width: width + 'px',
height: height + 'px',
};
return size;
};
const box = $derived(calculateSize());
</script> </script>
<SwipeFeedback {#if showVideo}
class="flex select-none h-full w-full place-content-center place-items-center" <div
bind:clientWidth={containerWidth} transition:fade={{ duration: assetViewerFadeDuration }}
bind:clientHeight={containerHeight} class="flex h-full select-none place-content-center place-items-center"
{onSwipe} bind:clientWidth={containerWidth}
> bind:clientHeight={containerHeight}
{#if showVideo} >
<div {#if castManager.isCasting}
in:fade={{ duration: assetViewerFadeDuration }} <div class="place-content-center h-full place-items-center">
class="flex h-full w-full place-content-center place-items-center" <VideoRemoteViewer
> poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{#if castManager.isCasting} {onVideoStarted}
<div class="place-content-center h-full place-items-center"> {onVideoEnded}
<VideoRemoteViewer {assetFileUrl}
poster={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })} />
{onVideoStarted} </div>
{onVideoEnded} {:else}
{assetFileUrl} <video
/> bind:this={videoPlayer}
</div> loop={$loopVideoPreference && loopVideo}
{:else} autoplay={$autoPlayVideo}
<div class="relative"> playsinline
<video controls
style:height={box.height} disablePictureInPicture
style:width={box.width} class="h-full object-contain"
bind:this={videoPlayer} {...useSwipe(onSwipe)}
loop={$loopVideoPreference && loopVideo} oncanplay={(e) => handleCanPlay(e.currentTarget)}
autoplay={$autoPlayVideo} onended={onVideoEnded}
playsinline onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
controls onseeking={() => (isScrubbing = true)}
disablePictureInPicture onseeked={() => (isScrubbing = false)}
onloadedmetadata={() => handleLoadedMetadata()} onplaying={(e) => {
oncanplay={(e) => handleCanPlay(e.currentTarget)} e.currentTarget.focus();
onended={onVideoEnded} }}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)} onclose={() => onClose()}
onseeking={() => (isScrubbing = true)} muted={$videoViewerMuted}
onseeked={() => (isScrubbing = false)} bind:volume={$videoViewerVolume}
onplaying={(e) => { poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
e.currentTarget.focus(); src={assetFileUrl}
}} >
onclose={() => onClose()} </video>
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
{#if isLoading} {#if isLoading}
<div class="absolute inset-0 flex place-content-center place-items-center"> <div class="absolute flex place-content-center place-items-center">
<LoadingSpinner /> <LoadingSpinner />
</div>
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} assetId={effectiveAssetId} />
{/if}
</div> </div>
{/if} {/if}
</div>
{/if}
{#snippet leftPreview()}
{#if previousAsset}
<AdaptiveImage
asset={previousAsset}
{sharedLink}
container={{ width: containerWidth, height: containerHeight }}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
{#snippet rightPreview()} {#if isFaceEditMode.value}
{#if nextAsset} <FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
<AdaptiveImage {/if}
asset={nextAsset}
{sharedLink}
container={{ width: containerWidth, height: containerHeight }}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if} {/if}
{/snippet} </div>
</SwipeFeedback> {/if}
<style>
video:focus {
outline: none;
}
</style>
@@ -1,50 +1,50 @@
<script lang="ts"> <script lang="ts">
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte'; import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte'; import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants'; import { ProjectionType } from '$lib/constants';
import type { SharedLinkResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
interface Props { interface Props {
cursor: AssetCursor; asset: AssetResponseDto;
assetId?: string; assetId?: string;
sharedLink?: SharedLinkResponseDto;
projectionType: string | null | undefined; projectionType: string | null | undefined;
cacheKey: string | null; cacheKey: string | null;
loopVideo: boolean; loopVideo: boolean;
playOriginalVideo: boolean; playOriginalVideo: boolean;
onClose?: () => void; onClose?: () => void;
onSwipe: (direction: 'left' | 'right') => void; onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void; onVideoEnded?: () => void;
onVideoStarted?: () => void; onVideoStarted?: () => void;
} }
let { let {
cursor, asset,
assetId, assetId,
sharedLink,
projectionType, projectionType,
cacheKey, cacheKey,
loopVideo, loopVideo,
playOriginalVideo, playOriginalVideo,
onSwipe, onPreviousAsset,
onClose, onClose,
onNextAsset,
onVideoEnded, onVideoEnded,
onVideoStarted, onVideoStarted,
}: Props = $props(); }: Props = $props();
const effectiveAssetId = $derived(assetId ?? asset.id);
</script> </script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR} {#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer asset={cursor.current} /> <VideoPanoramaViewer {asset} />
{:else} {:else}
<VideoNativeViewer <VideoNativeViewer
{loopVideo} {loopVideo}
{cacheKey} {cacheKey}
{cursor} assetId={effectiveAssetId}
{assetId}
{sharedLink}
{playOriginalVideo} {playOriginalVideo}
{onSwipe} {onPreviousAsset}
{onNextAsset}
{onVideoEnded} {onVideoEnded}
{onVideoStarted} {onVideoStarted}
{onClose} {onClose}
@@ -8,18 +8,16 @@ vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', { Object.defineProperty(globalThis, 'matchMedia', {
writable: true, writable: true,
enumerable: true, enumerable: true,
value: vi.fn().mockImplementation(function (query) { value: vi.fn().mockImplementation((query) => ({
return { matches: false,
matches: false, media: query,
media: query, onchange: null,
onchange: null, addListener: vi.fn(), // deprecated
addListener: vi.fn(), // deprecated removeListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated addEventListener: vi.fn(),
addEventListener: vi.fn(), removeEventListener: vi.fn(),
removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
dispatchEvent: vi.fn(), })),
};
}),
}); });
}); });
@@ -4,7 +4,7 @@
import { getAssetMediaUrl } from '$lib/utils'; import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize } from '@immich/sdk'; import { AssetMediaSize } from '@immich/sdk';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte'; import { LoadingSpinner } from '@immich/ui';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -44,7 +44,9 @@
{/if} {/if}
{#if !imageLoaded} {#if !imageLoaded}
<DelayedLoadingSpinner /> <div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if imageLoaded} {:else if imageLoaded}
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full"> <div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
<img <img
@@ -55,3 +57,15 @@
/> />
</div> </div>
{/if} {/if}
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>
@@ -10,7 +10,7 @@
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { sharedLinkLogin, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { sharedLinkLogin, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { Button, Logo, PasswordInput } from '@immich/ui'; import { Button, Logo, PasswordInput } from '@immich/ui';
import { onDestroy, tick } from 'svelte'; import { tick } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
@@ -60,10 +60,6 @@
event.preventDefault(); event.preventDefault();
await handlePasswordSubmit(); await handlePasswordSubmit();
}; };
onDestroy(() => {
setSharedLink(undefined);
});
</script> </script>
<svelte:head> <svelte:head>
@@ -180,17 +180,6 @@
onSelect(selectedOption); onSelect(selectedOption);
}; };
// TODO: move this combobox component into @immich/ui
// Bits UI dialogs use `contain: layout` so fixed descendants are positioned in dialog space
const getModalBounds = () => {
const modalRoot = input?.closest('[data-dialog-content]');
if (!modalRoot || !getComputedStyle(modalRoot).contain.includes('layout')) {
return;
}
return modalRoot.getBoundingClientRect();
};
const calculatePosition = (boundary: DOMRect | undefined) => { const calculatePosition = (boundary: DOMRect | undefined) => {
const visualViewport = window.visualViewport; const visualViewport = window.visualViewport;
@@ -198,35 +187,29 @@
return; return;
} }
const modalBounds = getModalBounds(); const left = boundary.left + (visualViewport?.offsetLeft || 0);
const offsetTop = modalBounds?.top || 0; const offsetTop = visualViewport?.offsetTop || 0;
const offsetLeft = modalBounds?.left || 0;
const rootHeight = modalBounds?.height || window.innerHeight;
const top = boundary.top - offsetTop;
const bottom = boundary.bottom - offsetTop;
const left = boundary.left - offsetLeft;
if (dropdownDirection === 'top') { if (dropdownDirection === 'top') {
return { return {
bottom: `${rootHeight - top}px`, bottom: `${window.innerHeight - boundary.top - offsetTop}px`,
left: `${left}px`, left: `${left}px`,
width: `${boundary.width}px`, width: `${boundary.width}px`,
maxHeight: maxHeight(top - dropdownOffset), maxHeight: maxHeight(boundary.top - dropdownOffset),
}; };
} }
const viewportHeight = visualViewport?.height || rootHeight; const viewportHeight = visualViewport?.height || 0;
const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom; const availableHeight = viewportHeight - boundary.bottom;
return { return {
top: `${bottom}px`, top: `${boundary.bottom + offsetTop}px`,
left: `${left}px`, left: `${left}px`,
width: `${boundary.width}px`, width: `${boundary.width}px`,
maxHeight: maxHeight(availableHeight - dropdownOffset), maxHeight: maxHeight(availableHeight - dropdownOffset),
}; };
}; };
const maxHeight = (size: number) => `min(${Math.max(size, 0)}px,18rem)`; const maxHeight = (size: number) => `min(${size}px,18rem)`;
const onPositionChange = () => { const onPositionChange = () => {
if (!isOpen) { if (!isOpen) {
+5 -3
View File
@@ -35,6 +35,7 @@
let { isUploading } = uploadAssetsStore; let { isUploading } = uploadAssetsStore;
let hoveredDayGroup = $state<string | null>(null); let hoveredDayGroup = $state<string | null>(null);
const isMouseOverGroup = $derived(hoveredDayGroup !== null);
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150); const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => { const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
@@ -67,7 +68,7 @@
onmouseenter={() => (hoveredDayGroup = dayGroup.groupTitle)} onmouseenter={() => (hoveredDayGroup = dayGroup.groupTitle)}
onmouseleave={() => (hoveredDayGroup = null)} onmouseleave={() => (hoveredDayGroup = null)}
> >
<!-- Day title --> <!-- Month title -->
<div <div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm" class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'} style:width={dayGroup.width + 'px'}
@@ -75,14 +76,15 @@
{#if !singleSelect} {#if !singleSelect}
<div <div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0" class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={hoveredDayGroup === dayGroup.groupTitle || assetInteraction.selectedGroup.has(dayGroup.groupTitle)} class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
> >
{#if isDayGroupSelected} {#if isDayGroupSelected}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" /> <Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else} {:else}
<Icon icon={mdiCircleOutline} size="24" class="text-light-500" /> <Icon icon={mdiCircleOutline} size="24" color="#757575" />
{/if} {/if}
</div> </div>
{/if} {/if}
+99
View File
@@ -0,0 +1,99 @@
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { AssetMediaSize } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/sw-messaging', () => ({
cancelImageUrl: vi.fn(),
}));
vi.mock('$lib/utils', () => ({
getAssetMediaUrl: vi.fn(),
}));
describe('ImageManager', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('preload', () => {
it('creates an Image with the correct URL', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.preload(asset);
expect(getAssetMediaUrl).toHaveBeenCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
});
it('does nothing for undefined asset', () => {
imageManager.preload(undefined);
expect(getAssetMediaUrl).not.toHaveBeenCalled();
});
it('does nothing when getAssetMediaUrl returns falsy', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('');
const asset = assetFactory.build();
imageManager.preload(asset);
expect(getAssetMediaUrl).toHaveBeenCalled();
});
it('uses the specified size', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.preload(asset, AssetMediaSize.Thumbnail);
expect(getAssetMediaUrl).toHaveBeenCalledWith({
id: asset.id,
size: AssetMediaSize.Thumbnail,
cacheKey: asset.thumbhash,
});
});
});
describe('cancel', () => {
it('calls cancelImageUrl with the correct URL', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.cancel(asset, AssetMediaSize.Preview);
expect(cancelImageUrl).toHaveBeenCalledWith('/api/assets/123/media');
});
it('does nothing for undefined asset', () => {
imageManager.cancel(undefined);
expect(getAssetMediaUrl).not.toHaveBeenCalled();
expect(cancelImageUrl).not.toHaveBeenCalled();
});
it('cancels all sizes when size is "all"', () => {
vi.mocked(getAssetMediaUrl).mockImplementation(({ size }) => `/api/assets/123/${size}`);
const asset = assetFactory.build();
imageManager.cancel(asset, 'all');
expect(getAssetMediaUrl).toHaveBeenCalledTimes(Object.values(AssetMediaSize).length);
for (const size of Object.values(AssetMediaSize)) {
expect(cancelImageUrl).toHaveBeenCalledWith(`/api/assets/123/${size}`);
}
});
it('does not call cancelImageUrl when URL is falsy', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('');
const asset = assetFactory.build();
imageManager.cancel(asset, AssetMediaSize.Preview);
expect(cancelImageUrl).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,37 @@
import { getAssetMediaUrl } from '$lib/utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
type AllAssetMediaSize = AssetMediaSize | 'all';
class ImageManager {
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
if (!asset) {
return;
}
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
if (!url) {
return;
}
const img = new Image();
img.src = url;
}
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
if (!asset) {
return;
}
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
for (const size of sizes) {
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
if (url) {
cancelImageUrl(url);
}
}
}
}
export const imageManager = new ImageManager();
+1 -5
View File
@@ -21,9 +21,6 @@ import type {
export type Events = { export type Events = {
AppInit: []; AppInit: [];
ResetSwipeFeedback: [];
ViewerFinishNavigate: [];
AuthLogin: [LoginResponseDto]; AuthLogin: [LoginResponseDto];
AuthLogout: []; AuthLogout: [];
AuthUserLoaded: [UserAdminResponseDto]; AuthUserLoaded: [UserAdminResponseDto];
@@ -36,6 +33,7 @@ export type Events = {
ApiKeyDelete: [ApiKeyResponseDto]; ApiKeyDelete: [ApiKeyResponseDto];
AssetUpdate: [AssetResponseDto]; AssetUpdate: [AssetResponseDto];
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
AssetsArchive: [string[]]; AssetsArchive: [string[]];
AssetsDelete: [string[]]; AssetsDelete: [string[]];
AssetEditsApplied: [string]; AssetEditsApplied: [string];
@@ -89,8 +87,6 @@ export type Events = {
WorkflowDelete: [WorkflowResponseDto]; WorkflowDelete: [WorkflowResponseDto];
ReleaseEvent: [ReleaseEvent]; ReleaseEvent: [ReleaseEvent];
WebsocketConnect: [];
}; };
export const eventManager = new BaseEventManager<Events>(); export const eventManager = new BaseEventManager<Events>();
+1 -49
View File
@@ -1,34 +1,9 @@
import { getAssetActions, handleDownloadAsset } from '$lib/services/asset.service'; import { getAssetActions } from '$lib/services/asset.service';
import { user as userStore } from '$lib/stores/user.store'; import { user as userStore } from '$lib/stores/user.store';
import { setSharedLink } from '$lib/utils'; import { setSharedLink } from '$lib/utils';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfo } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { assetFactory } from '@test-data/factories/asset-factory'; import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
import { userAdminFactory } from '@test-data/factories/user-factory'; import { userAdminFactory } from '@test-data/factories/user-factory';
import { vitest } from 'vitest';
vitest.mock('@immich/ui', () => ({
toastManager: {
success: vitest.fn(),
},
}));
vitest.mock('$lib/utils/i18n', () => ({
getFormatter: vitest.fn(),
getPreferredLocale: vitest.fn(),
}));
vitest.mock('@immich/sdk');
vitest.mock('$lib/utils', async () => {
const originalModule = await vitest.importActual('$lib/utils');
return {
...originalModule,
sleep: vitest.fn(),
};
});
describe('AssetService', () => { describe('AssetService', () => {
describe('getAssetActions', () => { describe('getAssetActions', () => {
@@ -59,27 +34,4 @@ describe('AssetService', () => {
expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(true); expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(true);
}); });
}); });
describe('handleDownloadAsset', () => {
it('should use the asset originalFileName when showing toasts', async () => {
const $t = vitest.fn().mockReturnValue('formatter');
vitest.mocked(getFormatter).mockResolvedValue($t);
const asset = assetFactory.build({ originalFileName: 'asset.heic' });
await handleDownloadAsset(asset, { edited: false });
expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } });
expect(toastManager.success).toHaveBeenCalledWith('formatter');
});
it('should use the motion asset originalFileName when showing toasts', async () => {
const $t = vitest.fn().mockReturnValue('formatter');
vitest.mocked(getFormatter).mockResolvedValue($t);
const motionAsset = assetFactory.build({ originalFileName: 'asset.mov' });
vitest.mocked(getAssetInfo).mockResolvedValue(motionAsset);
const asset = assetFactory.build({ originalFileName: 'asset.heic', livePhotoVideoId: '1' });
await handleDownloadAsset(asset, { edited: false });
expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } });
expect($t).toHaveBeenNthCalledWith(2, 'downloading_asset_filename', { values: { filename: 'asset.mov' } });
expect(toastManager.success).toHaveBeenCalledWith('formatter');
});
});
}); });
+14 -1
View File
@@ -9,6 +9,7 @@ import { user as authUser, preferences } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types'; import type { AssetControlContext } from '$lib/types';
import { getSharedLink, sleep } from '$lib/utils'; import { getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils'; import { downloadUrl } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { asQueryString } from '$lib/utils/shared-links'; import { asQueryString } from '$lib/utils/shared-links';
@@ -16,6 +17,8 @@ import {
AssetJobName, AssetJobName,
AssetTypeEnum, AssetTypeEnum,
AssetVisibility, AssetVisibility,
copyAsset,
deleteAssets,
getAssetInfo, getAssetInfo,
getBaseUrl, getBaseUrl,
runAssetJobs, runAssetJobs,
@@ -294,6 +297,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
{ {
filename: asset.originalFileName, filename: asset.originalFileName,
id: asset.id, id: asset.id,
size: asset.exifInfo?.fileSizeInByte || 0,
}, },
]; ];
@@ -307,6 +311,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
assets.push({ assets.push({
filename: motionAsset.originalFileName, filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId, id: asset.livePhotoVideoId,
size: motionAsset.exifInfo?.fileSizeInByte || 0,
}); });
} }
} }
@@ -320,7 +325,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
} }
try { try {
toastManager.success($t('downloading_asset_filename', { values: { filename } })); toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } }));
downloadUrl( downloadUrl(
getBaseUrl() + getBaseUrl() +
`/assets/${id}/original` + `/assets/${id}/original` +
@@ -357,6 +362,14 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
} }
}; };
export const handleReplaceAsset = async (oldAssetId: string) => {
const [newAssetId] = await openFileUploadDialog({ multiple: false });
await copyAsset({ assetCopyDto: { sourceId: oldAssetId, targetId: newAssetId } });
await deleteAssets({ assetBulkDeleteDto: { ids: [oldAssetId], force: true } });
eventManager.emit('AssetReplace', { oldAssetId, newAssetId });
};
const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => { const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => {
const messages: Record<AssetJobName, string> = { const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshFaces]: $t('refreshing_faces'), [AssetJobName.RefreshFaces]: $t('refreshing_faces'),
+1 -4
View File
@@ -62,10 +62,7 @@ export const websocketStore = {
export const websocketEvents = createEventEmitter(websocket); export const websocketEvents = createEventEmitter(websocket);
websocket websocket
.on('connect', () => { .on('connect', () => websocketStore.connected.set(true))
eventManager.emit('WebsocketConnect');
websocketStore.connected.set(true);
})
.on('disconnect', () => websocketStore.connected.set(false)) .on('disconnect', () => websocketStore.connected.set(false))
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
.on('AppRestartV1', (mode) => websocketStore.serverRestarting.set(mode)) .on('AppRestartV1', (mode) => websocketStore.serverRestarting.set(mode))
+2 -10
View File
@@ -145,8 +145,8 @@ export const downloadRequest = <TBody = unknown>(options: DownloadRequestOptions
let _sharedLink: SharedLinkResponseDto | undefined; let _sharedLink: SharedLinkResponseDto | undefined;
export const setSharedLink = (sharedLink: typeof _sharedLink) => (_sharedLink = sharedLink); export const setSharedLink = (sharedLink: SharedLinkResponseDto) => (_sharedLink = sharedLink);
export const getSharedLink = (): typeof _sharedLink => _sharedLink; export const getSharedLink = (): SharedLinkResponseDto | undefined => _sharedLink;
const createUrl = (path: string, parameters?: Record<string, unknown>) => { const createUrl = (path: string, parameters?: Record<string, unknown>) => {
const searchParameters = new URLSearchParams(); const searchParameters = new URLSearchParams();
@@ -186,14 +186,6 @@ export const getAssetUrl = ({
return getAssetMediaUrl({ id, size, cacheKey }); return getAssetMediaUrl({ id, size, cacheKey });
}; };
export function getAssetUrls(asset: AssetResponseDto, sharedLink?: SharedLinkResponseDto) {
return {
thumbnail: getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail }),
preview: getAssetUrl({ asset, sharedLink })!,
original: getAssetUrl({ asset, sharedLink, forceOriginal: true })!,
};
}
const forceUseOriginal = (asset: AssetResponseDto) => { const forceUseOriginal = (asset: AssetResponseDto) => {
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000'); return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
}; };
@@ -1,185 +0,0 @@
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
export type ImageQuality = 'thumbnail' | 'preview' | 'original';
export type ImageStatus = 'unloaded' | 'success' | 'error';
export type ImageLoaderStatus = {
urls: Record<ImageQuality, string | undefined>;
quality: Record<ImageQuality, ImageStatus>;
started: boolean;
hasError: boolean;
};
type ImageLoaderCallbacks = {
onUrlChange?: (url: string) => void;
onImageReady?: () => void;
onError?: () => void;
};
export type QualityConfig = {
url: string;
quality: ImageQuality;
checkCanceled: boolean;
onAfterLoad?: (loader: AdaptiveImageLoader) => void;
onAfterError?: (loader: AdaptiveImageLoader) => void;
};
const MAX_TRACKED_ASSETS = 10;
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const tracker = new Map<string, 'loading' | 'canceled'>();
const updateTracker = (id: string, action: 'loading' | 'canceled') => {
tracker.delete(id);
tracker.set(id, action);
if (tracker.size > MAX_TRACKED_ASSETS) {
const firstKey = tracker.keys().next().value!;
tracker.delete(firstKey);
}
};
const isCanceled = (id: string) => 'canceled' === tracker.get(id);
const setLoading = (id: string) => updateTracker(id, 'loading');
const setCanceled = (id: string) => updateTracker(id, 'canceled');
export type QualityList = [
QualityConfig & { quality: 'thumbnail' },
QualityConfig & { quality: 'preview' },
QualityConfig & { quality: 'original' },
];
export class AdaptiveImageLoader {
private destroyFunctions: (() => void)[] = [];
private qualityConfigs: Record<ImageQuality, QualityConfig>;
private highestLoadedQualityIndex = -1;
private destroyed = false;
status = $state<ImageLoaderStatus>({
started: false,
hasError: false,
urls: { thumbnail: undefined, preview: undefined, original: undefined },
quality: { thumbnail: 'unloaded', preview: 'unloaded', original: 'unloaded' },
});
constructor(
private readonly id: string,
private readonly qualityList: QualityList,
private readonly callbacks?: ImageLoaderCallbacks,
private readonly imageLoader?: LoadImageFunction,
) {
this.qualityConfigs = {
thumbnail: qualityList[0],
preview: qualityList[1],
original: qualityList[2],
};
this.status.urls.thumbnail = qualityList[0].url;
setLoading(id);
}
start() {
if (!this.imageLoader) {
throw new Error('Start requires imageLoader to be specified');
}
this.destroyFunctions.push(
this.imageLoader(
this.qualityList[0].url,
() => this.onLoad('thumbnail'),
() => this.onError('thumbnail'),
() => this.onStart('thumbnail'),
),
);
}
onStart(quality: ImageQuality) {
const config = this.qualityConfigs[quality];
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
return;
}
this.status.started = true;
}
onLoad(quality: ImageQuality) {
const config = this.qualityConfigs[quality];
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
return;
}
if (!this.status.urls[quality]) {
return;
}
const index = this.qualityList.indexOf(config);
if (index <= this.highestLoadedQualityIndex) {
return;
}
this.highestLoadedQualityIndex = index;
this.status.quality[quality] = 'success';
this.callbacks?.onUrlChange?.(this.qualityConfigs[quality].url);
this.callbacks?.onImageReady?.();
config.onAfterLoad?.(this);
}
onError(quality: ImageQuality) {
const config = this.qualityConfigs[quality];
if (this.destroyed || (config.checkCanceled && isCanceled(this.id))) {
return;
}
this.status.hasError = true;
this.status.quality[quality] = 'error';
this.status.urls[quality] = undefined;
this.callbacks?.onError?.();
config.onAfterError?.(this);
}
trigger(quality: ImageQuality) {
if (this.destroyed) {
return false;
}
const url = this.qualityConfigs[quality].url;
if (!url) {
this.qualityConfigs[quality].onAfterError?.(this);
return false;
}
if (this.status.urls[quality]) {
return true;
}
this.status.hasError = false;
this.status.urls[quality] = url;
if (this.imageLoader) {
this.destroyFunctions.push(
this.imageLoader(
url,
() => this.onLoad(quality),
() => this.onError(quality),
() => this.onStart(quality),
),
);
}
return false;
}
destroy() {
setCanceled(this.id);
this.destroyed = true;
if (this.imageLoader) {
for (const destroy of this.destroyFunctions) {
destroy();
}
return;
}
for (const config of Object.values(this.qualityConfigs)) {
cancelImageUrl(config.url);
}
}
}
+9 -44
View File
@@ -224,54 +224,19 @@ const supportedImageMimeTypes = new Set([
'image/webp', 'image/webp',
]); ]);
async function addSupportedMimeTypes(): Promise<void> { const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755 if (isSafari) {
if (isSafari) { supportedImageMimeTypes.add('image/heic').add('image/heif');
const match = navigator.userAgent.match(/Version\/(\d+)/); }
if (!match) { function checkJxlSupport(): void {
return; const img = new Image();
} img.addEventListener('load', () => {
const majorVersion = Number.parseInt(match[1]);
const MIN_REQUIRED_VERSION = 17;
if (majorVersion >= MIN_REQUIRED_VERSION) {
supportedImageMimeTypes.add('image/jxl').add('image/heic').add('image/heif');
}
return;
}
if (globalThis.isSecureContext && typeof ImageDecoder !== 'undefined') {
const dynamicMimeTypes = [{ type: 'image/jxl' }, { type: 'image/heic', aliases: ['image/heif'] }];
for (const mime of dynamicMimeTypes) {
const isMimeTypeSupported = await ImageDecoder.isTypeSupported(mime.type);
if (isMimeTypeSupported) {
for (const mimeType of [mime.type, ...(mime.aliases || [])]) {
supportedImageMimeTypes.add(mimeType);
}
}
}
return;
}
const jxlImg = new Image();
jxlImg.addEventListener('load', () => {
supportedImageMimeTypes.add('image/jxl'); supportedImageMimeTypes.add('image/jxl');
}); });
jxlImg.src = 'data:image/jxl;base64,/woIAAAMABKIAgC4AF3lEgA='; // Small valid JPEG XL image img.src = 'data:image/jxl;base64,/woIAAAMABKIAgC4AF3lEgA='; // Small valid JPEG XL image
const heicImg = new Image();
heicImg.addEventListener('load', () => {
supportedImageMimeTypes.add('image/heic');
});
heicImg.src =
'data:image/heic;base64,AAAAGGZ0eXBoZWljAAAAAG1pZjFoZWljAAABrW1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAHBpY3QAAAAAAAAAAAAAAAAAAAAADnBpdG0AAAAAAAIAAAAQaWRhdAAAAAAAAQABAAAAOGlsb2MBAAAAREAAAgABAAAAAAAAAc0AAQAAAAAAAAAsAAIAAQAAAAAAAAABAAAAAAAAAAgAAAA4aWluZgAAAAAAAgAAABVpbmZlAgAAAQABAABodmMxAAAAABVpbmZlAgAAAAACAABncmlkAAAAANhpcHJwAAAAtmlwY28AAAB2aHZjQwEDcAAAAAAAAAAAAB7wAPz9+PgAAA8DIAABABhAAQwB//8DcAAAAwCQAAADAAADAB66AkAhAAEAKkIBAQNwAAADAJAAAAMAAAMAHqAggQWW6q6a5uBAQMCAAAADAIAAAAMAhCIAAQAGRAHBc8GJAAAAFGlzcGUAAAAAAAAAAQAAAAEAAAAUaXNwZQAAAAAAAABAAAAAQAAAABBwaXhpAAAAAAMICAgAAAAaaXBtYQAAAAAAAAACAAECgQMAAgIChAAAABppcmVmAAAAAAAAAA5kaW1nAAIAAQABAAAANG1kYXQAAAAoKAGvCchMZYA50NoPIfzz81Qfsm577GJt3lf8kLAr+NbNIoeRR7JeYA=='; // Small valid HEIC/HEIF image
} }
void addSupportedMimeTypes(); checkJxlSupport();
/** /**
* Returns true if the asset is an image supported by web browsers, false otherwise * Returns true if the asset is an image supported by web browsers, false otherwise
-13
View File
@@ -5,19 +5,6 @@ export interface ContentMetrics {
offsetY: number; offsetY: number;
} }
export const scaleToCover = (
dimensions: { width: number; height: number },
container: { width: number; height: number },
): { width: number; height: number } => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.max(scaleX, scaleY);
return {
width: dimensions.width * scale,
height: dimensions.height * scale,
};
};
export const scaleToFit = ( export const scaleToFit = (
dimensions: { width: number; height: number }, dimensions: { width: number; height: number },
container: { width: number; height: number }, container: { width: number; height: number },
+4 -7
View File
@@ -1,3 +1,5 @@
import { handleError } from '$lib/utils/handle-error';
/** /**
* Tracks the state of asynchronous invocations to handle race conditions and stale operations. * Tracks the state of asynchronous invocations to handle race conditions and stale operations.
* This class helps manage concurrent operations by tracking which invocations are active * This class helps manage concurrent operations by tracking which invocations are active
@@ -51,19 +53,14 @@ export class InvocationTracker {
return this.invocationsStarted !== this.invocationsEnded; return this.invocationsStarted !== this.invocationsEnded;
} }
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) { async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
const invocation = this.startInvocation(); const invocation = this.startInvocation();
try { try {
return await invocable(); return await invocable();
} catch (error: unknown) { } catch (error: unknown) {
if (catchCallback) { handleError(error, localizedMessage);
catchCallback(error);
} else {
console.error(error);
}
} finally { } finally {
invocation.endInvocation(); invocation.endInvocation();
finallyCallback?.();
} }
} }
} }
-54
View File
@@ -1,54 +0,0 @@
import { scaleToFit } from '$lib/utils/container-utils';
describe('scaleToFit', () => {
const tests = [
{
name: 'landscape image in square container',
dimensions: { width: 2000, height: 1000 },
container: { width: 500, height: 500 },
expected: { width: 500, height: 250 },
},
{
name: 'portrait image in square container',
dimensions: { width: 1000, height: 2000 },
container: { width: 500, height: 500 },
expected: { width: 250, height: 500 },
},
{
name: 'square image in square container',
dimensions: { width: 1000, height: 1000 },
container: { width: 500, height: 500 },
expected: { width: 500, height: 500 },
},
{
name: 'landscape image in landscape container',
dimensions: { width: 1600, height: 900 },
container: { width: 800, height: 600 },
expected: { width: 800, height: 450 },
},
{
name: 'portrait image in portrait container',
dimensions: { width: 900, height: 1600 },
container: { width: 600, height: 800 },
expected: { width: 450, height: 800 },
},
{
name: 'image matches container exactly',
dimensions: { width: 500, height: 300 },
container: { width: 500, height: 300 },
expected: { width: 500, height: 300 },
},
{
name: 'image smaller than container scales up',
dimensions: { width: 100, height: 50 },
container: { width: 400, height: 400 },
expected: { width: 400, height: 200 },
},
];
for (const { name, dimensions, container, expected } of tests) {
it(`should handle ${name}`, () => {
expect(scaleToFit(dimensions, container)).toEqual(expected);
});
}
});
+11 -15
View File
@@ -4,7 +4,6 @@
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte'; import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte'; import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte'; import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte'; import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import VersionAnnouncement from '$lib/components/VersionAnnouncement.svelte'; import VersionAnnouncement from '$lib/components/VersionAnnouncement.svelte';
@@ -20,7 +19,6 @@
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
import { maintenanceShouldRedirect } from '$lib/utils/maintenance'; import { maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { isAssetViewerRoute } from '$lib/utils/navigation'; import { isAssetViewerRoute } from '$lib/utils/navigation';
import { getServerConfig } from '@immich/sdk';
import { import {
CommandPaletteDefaultProvider, CommandPaletteDefaultProvider,
TooltipProvider, TooltipProvider,
@@ -33,7 +31,6 @@
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js'; import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import '../app.css'; import '../app.css';
interface Props { interface Props {
@@ -123,19 +120,20 @@
if (maintenanceShouldRedirect(isRestarting.isMaintenanceMode, location)) { if (maintenanceShouldRedirect(isRestarting.isMaintenanceMode, location)) {
modalManager.show(ServerRestartingModal, {}).catch((error) => console.error('Error [ServerRestartBox]:', error)); modalManager.show(ServerRestartingModal, {}).catch((error) => console.error('Error [ServerRestartBox]:', error));
// we will be disconnected momentarily
// wait for reconnect then reload
let waiting = false;
websocketStore.connected.subscribe((connected) => {
if (!connected) {
waiting = true;
} else if (connected && waiting) {
location.reload();
}
});
} }
}); });
const onWebsocketConnect = async () => {
const isRestarting = get(serverRestarting);
if (isRestarting && maintenanceShouldRedirect(isRestarting.isMaintenanceMode, location)) {
const { maintenanceMode } = await getServerConfig();
if (maintenanceMode === isRestarting.isMaintenanceMode) {
location.reload();
}
}
};
const userCommands: ActionItem[] = [ const userCommands: ActionItem[] = [
{ {
title: $t('theme'), title: $t('theme'),
@@ -184,8 +182,6 @@
const commands = $derived([...userCommands, ...adminCommands]); const commands = $derived([...userCommands, ...adminCommands]);
</script> </script>
<OnEvents {onWebsocketConnect} />
<CommandPaletteDefaultProvider name="Global" actions={commands} /> <CommandPaletteDefaultProvider name="Global" actions={commands} />
<VersionAnnouncement /> <VersionAnnouncement />
+9 -13
View File
@@ -3,23 +3,19 @@ import { init } from 'svelte-i18n';
beforeAll(async () => { beforeAll(async () => {
await init({ fallbackLocale: 'dev' }); await init({ fallbackLocale: 'dev' });
Element.prototype.animate = vi.fn().mockImplementation(function () { Element.prototype.animate = vi.fn().mockImplementation(() => ({ cancel: () => {}, finished: Promise.resolve() }));
return { cancel: () => {}, finished: Promise.resolve() };
});
}); });
Object.defineProperty(globalThis, 'matchMedia', { Object.defineProperty(globalThis, 'matchMedia', {
writable: true, writable: true,
value: vi.fn().mockImplementation(function (query) { value: vi.fn().mockImplementation((query) => ({
return { matches: false,
matches: false, media: query,
media: query, onchange: null,
onchange: null, addEventListener: vi.fn(),
addEventListener: vi.fn(), removeEventListener: vi.fn(),
removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
dispatchEvent: vi.fn(), })),
};
}),
}); });
vi.mock('$env/dynamic/public', () => { vi.mock('$env/dynamic/public', () => {
-1
View File
@@ -56,7 +56,6 @@ export default defineConfig({
entries: ['src/**/*.{svelte,ts,html}'], entries: ['src/**/*.{svelte,ts,html}'],
}, },
test: { test: {
name: 'web:unit',
include: ['src/**/*.{test,spec}.{js,ts}'], include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true, globals: true,
environment: 'happy-dom', environment: 'happy-dom',