Compare commits

...

26 Commits

Author SHA1 Message Date
midzelis 1d0760b4cd feat: swipe feedback
refactor: replace onPreviousAsset/onNextAsset with onSwipe

refactor: make InvocationTracker.invoke accept catch/finally callbacks
2026-03-06 00:06:52 +00:00
midzelis 6ea7235e3c dev: throttle images 2026-03-05 23:44:59 +00:00
midzelis 3467897113 fully rework/condense/simplify AdaptiveImage.svelte 2026-03-05 23:44:59 +00:00
midzelis 872a6ae993 feat: adaptive progressive image loading for photo viewer 2026-03-05 13:20:33 +00:00
renovate[bot] 78ba9cbc63 chore(deps): update dependency multer to v2.1.1 [security] (#26705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-05 10:59:51 +01:00
Michel Heusschen 33d75462c9 fix(web): combobox dropdown positioning in modals (#26707) 2026-03-05 10:58:26 +01:00
Mees Frensel e9451f10d6 chore(web): small cleanup of timeline month (#26708) 2026-03-05 09:53:20 +00:00
Min Idzelis 480b7e8d65 chore: configure ESLint flat config and auto-fix on save in VSCode settings (#26679) 2026-03-04 14:55:02 -05:00
Thomas 228ac63ab9 feat(mobile): keep search results visible (#26498)
Search results are replaced with a spinner when loading the next page,
which is quite jarring. Search results now remain visible when loading
the next page with a spinner at the bottom. The next page also loads
sooner, which makes it feel a lot smoother.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-04 11:27:11 -06:00
Thomas 7e9da945f6 chore(mobile): simplify asset page scroll (#26635)
In order to scroll smoothly without interfering with the gesture
detector on the photo view, we have an offstate scroll view which we
defer all drags to, and then forward scroll offsets to the real scroll
controller. This works well, but it can be simpler. Instead, we can
create a custom scroll controller on a scroll view with never scrollable
physics, and then forward drag events to that, bypassing the need for a
proxy scroll controller.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-04 10:28:55 -06:00
Thomas dd03c9c0a9 fix(mobile): add safe area for asset details (#26675) 2026-03-04 09:47:51 -06:00
Mees Frensel 16e4a2b92a fix(docs): we usually don't assign issues (#26691)
Update CONTRIBUTING.md
2026-03-04 09:43:19 -06:00
Andreas Heinz 5caa7e1902 feat(web): bounding box for faces when hovering over the face in photo view (#26667)
* feat(web): when hovering over a face already deteced, display the bounding box also shown when hovering over the person in the details-pane.

* prevent lint error

* fix unused var
2026-03-04 15:27:26 +00:00
Snowknight26 8279e1078a fix(web): download toast showing wrong filename for motion assets (#26689) 2026-03-04 16:22:48 +01:00
Timon 011ecbb43d refactor(web): remove replaceAsset action (#26444) 2026-03-04 09:05:44 -06:00
Min Idzelis 2725c96cb1 chore: add recommended VSCode workspace extensions (#26682) 2026-03-04 09:29:15 -05:00
Daniel Dietzler 3c476b1987 chore: vitest 4 for web, cli, and e2e (#26668) 2026-03-04 14:19:13 +00:00
Snowknight26 5989c9b4aa fix(web): inconsistent asset nav bar state after visiting shared link (#26674) 2026-03-04 08:25:29 -05:00
Min Idzelis 13c4260a1f fix: resolve medium test asset paths relative to file location (#26683) 2026-03-04 08:23:58 -05:00
Min Idzelis 54bc9ddd69 chore: add vitest project names and fix server config root paths (#26684)
Add `name` to all vitest configs matching CI job buckets (server:unit,
server:medium, cli:unit, web:unit, e2e:server, e2e:maintenance) so they
appear as filterable @tags in the Vitest VSCode extension.

Fix `root` in server vitest configs to use an absolute path derived from
`import.meta.url` instead of `'./'`, which resolved relative to the config
file directory (`server/test/`) rather than `server/`, causing test
discovery to fail in the Vitest VSCode extension.
2026-03-04 08:20:43 -05:00
Paul Makles f94e0fbc39 fix(maintenance mode): wait for valid server config on restart (#26456)
Signed-off-by: izzy <me@insrt.uk>
2026-03-04 11:16:21 +00:00
Nicolò Maria Semprini 5532f669eb feat: improve HEIC, HEIF and JPEG XL browser support detection (#26122)
feat: improve heic, heif and jxl browser support detection
2026-03-03 22:41:51 -05:00
Min Idzelis e4c24bdec8 chore: enable prettier caching and quiet output (#26681) 2026-03-04 03:34:48 +00:00
Savely Krasovsky 56f14162f6 chore: bump base images manually (#26670) 2026-03-04 00:54:55 +00:00
renovate[bot] 8abbbc49cf chore(deps): update dependency opentofu to v1.11.5 (#26655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 16:53:01 +00:00
Thomas 4eb08eee18 fix(mobile): video state (#26574)
Consolidate video state into a single asset-scoped provider, and reduce
dependency on global state generally. Overall this should fix a few
timing issues and race conditions with videos specifically, and make
future changes in this area easier.
2026-03-03 10:28:07 -06:00
122 changed files with 3402 additions and 2305 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"scripts": {
"format": "prettier --check .",
"format:fix": "prettier --write ."
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different ."
},
"devDependencies": {
"prettier": "^3.7.4"
+8 -1
View File
@@ -5,6 +5,13 @@
"dbaeumer.vscode-eslint",
"dart-code.flutter",
"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"
]
}
+33 -1
View File
@@ -15,6 +15,7 @@
},
"[javascript]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
@@ -34,6 +35,7 @@
},
"[svelte]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
@@ -43,6 +45,7 @@
},
"[typescript]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
@@ -51,14 +54,43 @@
"editor.tabSize": 2
},
"cSpell.words": ["immich"],
"css.lint.unknownAtRules": "ignore",
"editor.bracketPairColorization.enabled": true,
"editor.formatOnSave": true,
"editor.guides.bracketPairs": "active",
"eslint.useFlatConfig": true,
"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.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.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"
},
"search.exclude": {
"**/node_modules": true,
"**/build": true,
"**/dist": true,
"**/.svelte-kit": true,
"**/open-api/typescript-sdk/src": true
},
"svelte.enable-ts-plugin": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
"tailwindCSS.experimental.configFile": {
"web/src/app.css": "web/src/**"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"vitest.maximumConfigs": 10
}
+2
View File
@@ -15,6 +15,8 @@ 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!
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
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/mock-fs": "^4.13.1",
"@types/node": "^24.10.14",
"@vitest/coverage-v8": "^3.0.0",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
@@ -37,7 +37,7 @@
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^3.0.0",
"vitest": "^4.0.0",
"vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1"
},
@@ -49,8 +49,8 @@
"prepack": "pnpm run build",
"test": "vitest",
"test:cov": "vitest --coverage",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"check": "tsc --noEmit"
},
"repository": {
+45 -36
View File
@@ -1,6 +1,6 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { describe, expect, it, MockedFunction, vi } from 'vitest';
@@ -58,7 +58,7 @@ describe('uploadFiles', () => {
});
it('returns new assets when upload file is successful', async () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
return {
status: 200,
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 () => {
let counter = 0;
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
counter++;
if (counter < retry) {
throw new Error('Network error');
@@ -96,7 +96,7 @@ describe('uploadFiles', () => {
});
it('returns new assets when upload file retry is failed', async () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
throw new Error('Network error');
});
@@ -236,16 +236,19 @@ describe('startWatch', () => {
await sleep(100); // to debounce the watcher from considering the test file as a existing file
await fs.promises.writeFile(testFilePath, 'testjpg');
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: [
expect.objectContaining({
id: testFilePath,
}),
],
},
});
await vi.waitFor(
() =>
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: [
expect.objectContaining({
id: testFilePath,
}),
],
},
}),
{ timeout: 5000 },
);
});
it('should filter out unsupported files', async () => {
@@ -257,16 +260,19 @@ describe('startWatch', () => {
await fs.promises.writeFile(testFilePath, 'testjpg');
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
});
await vi.waitFor(
() =>
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
}),
{ timeout: 5000 },
);
expect(checkBulkUpload).not.toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
@@ -291,16 +297,19 @@ describe('startWatch', () => {
await fs.promises.writeFile(testFilePath, 'testjpg');
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
});
await vi.waitFor(
() =>
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
}),
{ timeout: 5000 },
);
expect(checkBulkUpload).not.toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
+6 -2
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig, UserConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
@@ -17,4 +17,8 @@ export default defineConfig({
noExternal: /^(?!node:).*$/,
},
plugins: [tsconfigPaths()],
});
test: {
name: 'cli:unit',
globals: true,
},
} as UserConfig);
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
[tools]
terragrunt = "0.99.4"
opentofu = "1.11.4"
opentofu = "1.11.5"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"
+2 -2
View File
@@ -4,8 +4,8 @@
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"start": "docusaurus start --port 3005",
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"build": "pnpm run copy:openapi && docusaurus build",
+5 -4
View File
@@ -14,8 +14,8 @@
"start:web": "pnpm exec playwright test --ui --project=web",
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "pnpm run lint --fix",
"check": "tsc --noEmit"
@@ -27,7 +27,7 @@
"@eslint/js": "^10.0.0",
"@faker-js/faker": "^10.1.0",
"@immich/cli": "workspace:*",
"@immich/e2e-auth-server": "workspace:*",
"@immich/e2e-auth-server": "workspace:*",
"@immich/sdk": "workspace:*",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
@@ -54,7 +54,8 @@
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"utimes": "^5.2.1",
"vitest": "^3.0.0"
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.0"
},
"volta": {
"node": "24.13.1"
+27 -19
View File
@@ -1,10 +1,7 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { Page, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
@@ -26,31 +23,42 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const original = page.getByTestId('original').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
await expect(original).toBeInViewport();
await expect(original).toHaveAttribute('src', /original/);
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const original = page.getByTestId('original').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
await expect(original).toHaveAttribute('src', /fullsize/);
});
test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src');
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
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 expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
await expect(preview).not.toHaveAttribute('src', initialSrc!);
});
});
+2 -2
View File
@@ -99,13 +99,13 @@ export const setupTimelineMockApiRoutes = async (
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail|fullsize)/;
const match = request.url().match(pattern);
if (!match?.groups) {
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
}
if (match.groups.size === 'preview') {
if (match.groups.size === 'preview' || match.groups.size === 'fullsize') {
if (!route.request().serviceWorker()) {
return route.continue();
}
@@ -73,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]');
await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
@@ -6,6 +6,7 @@ import {
generateTimelineData,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -53,7 +54,7 @@ test.describe('search gallery-viewer', () => {
assets: {
total: searchAssets.length,
count: searchAssets.length,
items: searchAssets,
items: searchAssets.map((asset) => toAssetResponseDto(asset)),
facets: [],
nextPage: null,
},
+4 -6
View File
@@ -163,13 +163,11 @@ export const assetViewerUtils = {
return page.locator('#immich-asset-viewer');
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
const previewUrl = `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true`;
await page
.locator(
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
)
.or(
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
)
.getByTestId('preview')
.and(page.locator(`[src="${previewUrl}"]`))
.or(page.locator(`video[poster="${previewUrl}"]`))
.waitFor();
},
async expectActiveAssetToBe(page: Page, assetId: string) {
+1 -1
View File
@@ -17,6 +17,6 @@
"esModuleInterop": true,
"baseUrl": "./"
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "vitest*.config.ts"],
"exclude": ["dist", "node_modules"]
}
+5 -5
View File
@@ -1,3 +1,4 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
@@ -14,15 +15,14 @@ if (!skipDockerSetup) {
export default defineConfig({
test: {
name: 'e2e:server',
retry: process.env.CI ? 4 : 0,
include: ['src/specs/server/**/*.e2e-spec.ts'],
globalSetup,
testTimeout: 15_000,
pool: 'threads',
poolOptions: {
threads: {
singleThread: true,
},
},
maxWorkers: 1,
isolate: false,
},
plugins: [tsconfigPaths()],
});
+5 -5
View File
@@ -1,3 +1,4 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
@@ -14,15 +15,14 @@ if (!skipDockerSetup) {
export default defineConfig({
test: {
name: 'e2e:maintenance',
retry: process.env.CI ? 4 : 0,
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
globalSetup,
testTimeout: 15_000,
pool: 'threads',
poolOptions: {
threads: {
singleThread: true,
},
},
maxWorkers: 1,
isolate: false,
},
plugins: [tsconfigPaths()],
});
+2 -2
View File
@@ -3,8 +3,8 @@
"version": "2.5.6",
"private": true,
"scripts": {
"format": "prettier --check .",
"format:fix": "prettier --write ."
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different ."
},
"devDependencies": {
"prettier": "^3.7.4",
+1 -1
View File
@@ -18,7 +18,7 @@ node = "24.13.1"
flutter = "3.35.7"
pnpm = "10.30.3"
terragrunt = "0.99.4"
opentofu = "1.11.4"
opentofu = "1.11.5"
java = "21.0.2"
[tools."github:CQLabs/homebrew-dcm"]
@@ -3,30 +3,21 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SearchResult {
final List<BaseAsset> assets;
final double scrollOffset;
final int? 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,
);
}
const SearchResult({required this.assets, this.nextPage});
@override
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
@override
bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
return listEquals(other.assets, assets) && other.nextPage == nextPage;
}
@override
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
int get hashCode => assets.hashCode ^ nextPage.hashCode;
}
@@ -78,6 +78,9 @@ class TimelineFactory {
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin 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(_timelineRepository.fromAssetsWithBuckets(assets, type));
@@ -112,7 +115,7 @@ class TimelineService {
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer.clear();
_buffer = [];
} else {
final int offset;
final int count;
+27 -82
View File
@@ -33,12 +33,27 @@ 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 {
static const _minFlingVelocity = 700.0;
static const minSnapDistance = 30.0;
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
const SnapScrollPhysics({super.parent});
@override
@@ -66,91 +81,21 @@ class SnapScrollPhysics extends ScrollPhysics {
}
}
return ScrollSpringSimulation(
_spring,
position.pixels,
target(position, velocity, snapOffset),
velocity,
tolerance: toleranceFor(position),
);
return ScrollSpringSimulation(spring, position.pixels, target(position, velocity, snapOffset), velocity);
}
@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) {
if (velocity > _minFlingVelocity) return snapOffset;
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 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,6 +276,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
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) {
// Sort assets by date descending and group by day
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
@@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
@@ -367,9 +366,6 @@ class GalleryViewerPage extends HookConsumerWidget {
stackIndex.value = 0;
ref.read(currentAssetProvider.notifier).set(newAsset);
if (newAsset.isVideo || newAsset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
// Wait for page change animation to finish, then precache the next image
Timer(const Duration(milliseconds: 400), () {
@@ -11,18 +11,14 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
class NativeVideoViewerPage extends HookConsumerWidget {
@@ -42,18 +38,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final videoId = asset.id.toString();
final controller = useState<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
// Used to track whether the video should play when the app
// is brought back to the foreground
final shouldPlayOnForeground = useRef(true);
// When a video is opened through the timeline, `isCurrent` will immediately be true.
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
final currentAsset = useState(ref.read(currentAssetProvider));
final isCurrent = currentAsset.value == asset;
@@ -117,127 +105,45 @@ class NativeVideoViewerPage extends HookConsumerWidget {
}
});
void checkIfBuffering() {
if (!context.mounted) {
return;
}
final videoPlayback = ref.read(videoPlaybackValueProvider);
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) {
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
state: VideoPlaybackState.buffering,
);
}
}
// Timer to mark videos as buffering if the position does not change
useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the position changes, seek to the position
// Debounce the seek to avoid seeking too often
// But also don't delay the seek too much to maintain visual feedback
final seekDebouncer = useDebouncer(
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
final playerController = controller.value;
if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return;
}
final oldSeek = oldControls?.position.inMilliseconds;
final newSeek = newControls.position.inMilliseconds;
if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek));
}
if (oldControls?.pause != newControls.pause || newControls.restarted) {
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
}
});
void onPlaybackReady() async {
final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) {
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
notifier.onNativePlaybackReady();
isVideoReady.value = true;
try {
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
if (autoPlayVideo) {
await videoController.play();
await notifier.play();
}
await videoController.setVolume(0.9);
await notifier.setVolume(1);
} catch (error) {
log.severe('Error playing video: $error');
}
}
void onPlaybackStatusChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
} else {
// Sync with the controls pause
WakelockPlus.disable();
}
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state;
if (!context.mounted) return;
ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged();
}
void onPlaybackPositionChanged() {
// When seeking, these events sometimes move the slider to an older position
if (seekDebouncer.isActive) {
return;
}
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
isBuffering.value = false;
lastVideoPosition.value = -1;
}
if (!context.mounted) return;
ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged();
}
void onPlaybackEnded() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
if (!context.mounted) return;
if (videoController.playbackInfo?.status == PlaybackStatus.stopped &&
ref.read(videoPlayerProvider(videoId).notifier).onNativePlaybackEnded();
final videoController = controller.value;
if (videoController?.playbackInfo?.status == PlaybackStatus.stopped &&
!ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo)) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
}
@@ -254,14 +160,15 @@ class NativeVideoViewerPage extends HookConsumerWidget {
if (controller.value != null || !context.mounted) {
return;
}
ref.read(videoPlayerControlsProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).reset();
final source = await videoSource;
if (source == null) {
return;
}
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
notifier.attachController(nc);
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(onPlaybackReady);
@@ -273,10 +180,9 @@ class NativeVideoViewerPage extends HookConsumerWidget {
}),
);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
unawaited(nc.setLoop(loopVideo));
await notifier.setLoop(loopVideo);
controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
ref.listen(currentAssetProvider, (_, value) {
@@ -300,10 +206,6 @@ class NativeVideoViewerPage extends HookConsumerWidget {
}
// Delay the video playback to avoid a stutter in the swipe animation
// Note, in some circumstances a longer delay is needed (eg: memories),
// the playbackDelayFactor can be used for this
// This delay seems like a hacky way to resolve underlying bugs in video
// playback, but other resolutions failed thus far
Timer(
Platform.isIOS
? Duration(milliseconds: 300 * playbackDelayFactor)
@@ -337,19 +239,18 @@ class NativeVideoViewerPage extends HookConsumerWidget {
playerController.stop().catchError((error) {
log.fine('Error stopping video: $error');
});
WakelockPlus.disable();
};
}, const []);
useOnAppLifecycleStateChange((_, state) async {
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
await controller.value?.play();
await notifier.play();
} else if (state == AppLifecycleState.paused) {
final videoPlaying = await controller.value?.isPlaying();
if (videoPlaying ?? true) {
shouldPlayOnForeground.value = true;
await controller.value?.pause();
await notifier.pause();
} else {
shouldPlayOnForeground.value = false;
}
@@ -374,39 +275,8 @@ class NativeVideoViewerPage extends HookConsumerWidget {
),
),
),
if (showControls) const Center(child: CustomVideoPlayerControls()),
if (showControls) Center(child: CustomVideoPlayerControls(videoId: videoId)),
],
);
}
Future<void> _onPauseChange(
BuildContext context,
NativeVideoPlayerController controller,
Debouncer seekDebouncer,
bool isPaused,
) async {
if (!context.mounted) {
return;
}
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
if (!context.mounted) {
return;
}
try {
if (isPaused) {
await controller.pause();
} else {
await controller.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
}
-4
View File
@@ -7,7 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart';
@@ -166,9 +165,6 @@ class MemoryPage extends HookConsumerWidget {
final asset = currentMemory.value.assets[otherIndex];
currentAsset.value = asset;
ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
}
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
@@ -7,16 +7,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
/// Expects [currentAssetNotifier] to be set before navigating to this page
/// Expects the current asset to be set via [assetViewerProvider] before navigating to this page
@RoutePage()
class DriftMemoryPage extends HookConsumerWidget {
final List<DriftMemory> memories;
@@ -26,11 +25,7 @@ class DriftMemoryPage extends HookConsumerWidget {
static void setMemory(WidgetRef ref, DriftMemory memory) {
if (memory.assets.isNotEmpty) {
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first);
if (memory.assets.first.isVideo) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
ref.read(assetViewerProvider.notifier).setAsset(memory.assets.first);
}
}
@@ -172,11 +167,7 @@ class DriftMemoryPage extends HookConsumerWidget {
final asset = currentMemory.value.assets[otherIndex];
currentAsset.value = asset;
ref.read(currentAssetNotifier.notifier).setAsset(asset);
// if (asset.isVideo || asset.isMotionPhoto) {
if (asset.isVideo) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
ref.read(assetViewerProvider.notifier).setAsset(asset);
}
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
@@ -273,7 +264,12 @@ class DriftMemoryPage extends HookConsumerWidget {
children: [
Container(
color: Colors.black,
child: DriftMemoryCard(asset: asset, title: title, showTitle: index == 0),
child: DriftMemoryCard(
asset: asset,
title: title,
showTitle: index == 0,
isCurrent: mIndex == currentMemoryIndex.value && index == currentAssetPage.value,
),
),
Positioned.fill(
child: Row(
@@ -80,51 +80,28 @@ class DriftSearchPage extends HookConsumerWidget {
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
final userPreferences = ref.watch(userMetadataPreferencesProvider);
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;
}
searchFilter(SearchFilter filter) {
if (preFilter == null && filter == previousFilter.value) {
return;
}
isSearching.value = true;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter);
ref.read(paginatedSearchProvider.notifier).clear();
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context)));
if (filter.isEmpty) {
previousFilter.value = null;
return;
}
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter;
isSearching.value = false;
}
search() => searchFilter(filter.value);
loadMoreSearchResult() async {
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;
loadMoreSearchResults() {
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value));
}
searchPreFilter() {
@@ -742,10 +719,10 @@ class DriftSearchPage extends HookConsumerWidget {
),
),
),
if (isSearching.value)
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
if (filter.value.isEmpty)
const _SearchSuggestions()
else
_SearchResultGrid(onScrollEnd: loadMoreSearchResult),
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),
],
),
);
@@ -757,45 +734,85 @@ class _SearchResultGrid extends ConsumerWidget {
const _SearchResultGrid({required this.onScrollEnd});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets));
bool _onScrollUpdateNotification(ScrollNotification notification) {
final metrics = notification.metrics;
if (assets.isEmpty) {
return const _SearchEmptyContent();
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 NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final isBottomSheetNotification =
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
return false;
}
final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical;
Widget? _bottomWidget(BuildContext context, WidgetRef ref) {
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) {
onScrollEnd();
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent);
}
if (isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
);
}
return true;
},
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
Widget build(BuildContext context, WidgetRef ref) {
final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty));
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
if (!hasAssets && !isLoading) {
return const _SearchNoResults();
}
return NotificationListener<ScrollUpdateNotification>(
onNotification: _onScrollUpdateNotification,
child: SliverFillRemaining(
child: ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search);
ref.onDispose(timelineService.dispose);
return timelineService;
final notifier = ref.read(paginatedSearchProvider.notifier);
final service = ref
.watch(timelineFactoryProvider)
.fromAssetStream(
() => ref.read(paginatedSearchProvider).assets,
notifier.assetCount,
TimelineOrigin.search,
);
ref.onDispose(service.dispose);
return service;
}),
],
child: Timeline(
key: ValueKey(assets.length),
groupBy: GroupAssetsBy.none,
appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false,
initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)),
loadingWidget: const SizedBox.shrink(),
bottomSliverWidget: _bottomWidget(context, ref),
),
),
),
@@ -803,8 +820,35 @@ class _SearchResultGrid extends ConsumerWidget {
}
}
class _SearchEmptyContent extends StatelessWidget {
const _SearchEmptyContent();
class _SearchNoResults extends StatelessWidget {
const _SearchNoResults();
@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
Widget build(BuildContext context) {
@@ -1,5 +1,7 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
@@ -21,40 +23,52 @@ class SearchFilterProvider extends Notifier<SearchFilter?> {
}
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
class SearchState {
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)),
);
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final SearchService _searchService;
final _assetCountController = StreamController<int>.broadcast();
PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1));
PaginatedSearchNotifier(this._searchService) : super(const SearchState());
Future<bool> search(SearchFilter filter) async {
if (state.nextPage == null) {
return false;
}
Stream<int> get assetCount => _assetCountController.stream;
Future<void> search(SearchFilter filter) async {
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!);
if (result == null) {
return false;
state = SearchState(assets: state.assets, nextPage: state.nextPage);
return;
}
state = SearchResult(
assets: [...state.assets, ...result.assets],
nextPage: result.nextPage,
scrollOffset: state.scrollOffset,
);
final assets = [...state.assets, ...result.assets];
state = SearchState(assets: assets, nextPage: result.nextPage);
return true;
_assetCountController.add(assets.length);
}
void setScrollOffset(double offset) {
state = state.copyWith(scrollOffset: offset);
void clear() {
state = const SearchState();
_assetCountController.add(0);
}
clear() {
state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0);
@override
void dispose() {
_assetCountController.close();
super.dispose();
}
}
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
@@ -49,7 +49,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
}
List<Widget> _buildMenuChildren() {
final asset = ref.read(currentAssetNotifier);
final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) return [];
final user = ref.read(currentUserProvider);
@@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
}
void _openAlbumSelector() {
final currentAsset = ref.read(currentAssetNotifier);
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
@@ -133,7 +133,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
}
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
final latest = ref.read(currentAssetNotifier);
final latest = ref.read(assetViewerProvider).currentAsset;
if (latest == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
@@ -169,7 +169,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class EditImageActionButton extends ConsumerWidget {
@@ -12,7 +12,7 @@ class EditImageActionButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(currentAssetNotifier);
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
onPress() {
if (currentAsset == null) {
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -20,7 +20,7 @@ class LikeActivityActionButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider);
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?;
final user = ref.watch(currentUserProvider);
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
@@ -8,7 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SimilarPhotosActionButton extends ConsumerWidget {
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -809,7 +809,7 @@ class CreateAlbumButton extends ConsumerWidget {
return;
}
final asset = ref.read(currentAssetNotifier);
final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart';
@@ -11,34 +12,36 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/te
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
class AssetDetails extends ConsumerWidget {
final BaseAsset asset;
final double minHeight;
const AssetDetails({required this.minHeight, super.key});
const AssetDetails({super.key, required this.asset, required this.minHeight});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
return Container(
constraints: BoxConstraints(minHeight: minHeight),
decoration: BoxDecoration(
color: context.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const DragHandle(),
const DateTimeDetails(),
const PeopleDetails(),
const LocationDetails(),
const TechnicalDetails(),
const RatingDetails(),
const AppearsInDetails(),
SizedBox(height: context.padding.bottom + 48),
],
child: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const DragHandle(),
DateTimeDetails(asset: asset, exifInfo: exifInfo),
PeopleDetails(asset: asset),
LocationDetails(asset: asset, exifInfo: exifInfo),
TechnicalDetails(asset: asset, exifInfo: exifInfo),
RatingDetails(exifInfo: exifInfo),
AppearsInDetails(asset: asset),
SizedBox(height: context.padding.bottom + 48),
],
),
),
);
}
@@ -8,27 +8,25 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AppearsInDetails extends ConsumerWidget {
const AppearsInDetails({super.key});
final BaseAsset asset;
const AppearsInDetails({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
if (!asset.hasRemote) return const SizedBox.shrink();
String? remoteAssetId;
if (asset is RemoteAsset) {
remoteAssetId = asset.id;
} else if (asset is LocalAsset) {
remoteAssetId = asset.remoteAssetId;
}
final remoteAssetId = switch (asset) {
RemoteAsset(:final id) => id,
LocalAsset(:final remoteAssetId) => remoteAssetId,
};
if (remoteAssetId == null) return const SizedBox.shrink();
@@ -10,7 +10,6 @@ import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -18,14 +17,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = '';
class DateTimeDetails extends ConsumerWidget {
const DateTimeDetails({super.key});
final BaseAsset asset;
final ExifInfo? exifInfo;
const DateTimeDetails({super.key, required this.asset, this.exifInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final asset = this.asset;
final exifInfo = this.exifInfo;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
return Column(
@@ -106,9 +106,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
@override
Widget build(BuildContext context) {
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final currentDescription = currentExifInfo?.description ?? '';
final currentDescription = widget.exif.description ?? '';
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
context: context,
);
@@ -134,7 +132,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
onTapOutside: (_) => saveDescription(widget.exif.description),
),
),
);
@@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class LocationDetails extends ConsumerStatefulWidget {
const LocationDetails({super.key});
final BaseAsset asset;
final ExifInfo? exifInfo;
const LocationDetails({super.key, required this.asset, this.exifInfo});
@override
ConsumerState createState() => _LocationDetailsState();
@@ -40,17 +42,15 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
_mapController = controller;
}
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
final currentExif = current.valueOrNull;
if (currentExif != null && currentExif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
}
}
@override
void initState() {
super.initState();
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
void didUpdateWidget(LocationDetails oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.exifInfo != oldWidget.exifInfo) {
final exif = widget.exifInfo;
if (exif != null && exif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exif.latitude!, exif.longitude!)));
}
}
}
void editLocation() async {
@@ -59,8 +59,8 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final asset = widget.asset;
final exifInfo = widget.exifInfo;
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
// Guard local assets
@@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
@@ -15,17 +14,14 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
class PeopleDetails extends ConsumerStatefulWidget {
const PeopleDetails({super.key});
class PeopleDetails extends ConsumerWidget {
final BaseAsset asset;
const PeopleDetails({super.key, required this.asset});
@override
ConsumerState createState() => _PeopleDetailsState();
}
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
Widget build(BuildContext context, WidgetRef ref) {
final asset = this.asset;
if (asset is! RemoteAsset) {
return const SizedBox.shrink();
}
@@ -1,16 +1,18 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
class RatingDetails extends ConsumerWidget {
const RatingDetails({super.key});
final ExifInfo? exifInfo;
const RatingDetails({super.key, this.exifInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -20,8 +22,6 @@ class RatingDetails extends ConsumerWidget {
if (!isRatingEnabled) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
@@ -6,21 +6,20 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
const _kSeparator = '';
class TechnicalDetails extends ConsumerWidget {
const TechnicalDetails({super.key});
final BaseAsset asset;
final ExifInfo? exifInfo;
const TechnicalDetails({super.key, required this.asset, this.exifInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final exifInfo = this.exifInfo;
final cameraTitle = _getCameraInfoTitle(exifInfo);
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
@@ -12,16 +12,16 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -50,9 +50,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
bool _showingDetails = false;
bool _isZoomed = false;
final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
final _scrollController = SnapScrollController();
double _snapOffset = 0.0;
DragStartDetails? _dragStart;
@@ -64,17 +62,17 @@ class _AssetPageState extends ConsumerState<AssetPage> {
super.initState();
_eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_proxyScrollController.hasClients) return;
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
if (!mounted || !_scrollController.hasClients) return;
_scrollController.snapPosition.snapOffset = _snapOffset;
if (_showingDetails && _snapOffset > 0) {
_proxyScrollController.jumpTo(_snapOffset);
_scrollController.jumpTo(_snapOffset);
}
});
}
@override
void dispose() {
_proxyScrollController.dispose();
_scrollController.dispose();
_scaleBoundarySub?.cancel();
_eventSubscription?.cancel();
super.dispose();
@@ -89,21 +87,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _showDetails() {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
if (!_scrollController.hasClients || _snapOffset <= 0) return;
_viewer.setShowingDetails(true);
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
_scrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
}
bool _willClose(double scrollVelocity) {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false;
final position = _proxyScrollController.position;
return _proxyScrollController.position.pixels < _snapOffset &&
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
}
bool _willClose(double scrollVelocity) =>
_scrollController.hasClients &&
_snapOffset > 0 &&
_scrollController.position.pixels < _snapOffset &&
SnapScrollPhysics.target(_scrollController.position, scrollVelocity, _snapOffset) <
SnapScrollPhysics.minSnapDistance;
void _syncShowingDetails() {
final offset = _proxyScrollController.offset;
final offset = _scrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance) {
_viewer.setShowingDetails(true);
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
@@ -125,8 +122,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
void _startProxyDrag() {
if (_proxyScrollController.hasClients && _dragStart != null) {
_drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null);
if (_scrollController.hasClients && _dragStart != null) {
_drag = _scrollController.position.drag(_dragStart!, () => _drag = null);
}
}
@@ -246,14 +243,16 @@ class _AssetPageState extends ConsumerState<AssetPage> {
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
_isZoomed = switch (scaleState) {
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
_ => false,
};
_isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering;
_viewer.setZoomed(_isZoomed);
if (scaleState != PhotoViewScaleState.initial) {
if (_dragStart == null) _viewer.setControls(false);
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
if (heroTag != null) {
ref.read(videoPlayerProvider(heroTag).notifier).pause();
}
return;
}
@@ -288,22 +287,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_listenForScaleBoundaries(controller);
}
Widget _buildPhotoView(
BaseAsset displayAsset,
BaseAsset asset, {
required bool isCurrentPage,
required bool showingDetails,
Widget _buildPhotoView({
required BaseAsset asset,
required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrent,
required bool isPlayingMotionVideo,
required BoxDecoration backgroundDecoration,
}) {
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
final size = context.sizeData;
if (displayAsset.isImage && !isPlayingMotionVideo) {
final size = context.sizeData;
if (asset.isImage && !isPlayingMotionVideo) {
return PhotoView(
key: Key(displayAsset.heroTag),
key: Key(asset.heroTag),
index: widget.index,
imageProvider: getFullImageProvider(displayAsset, size: size),
imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
backgroundDecoration: backgroundDecoration,
@@ -311,7 +308,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
filterQuality: FilterQuality.high,
tightMode: true,
enablePanAlways: true,
disableScaleGestures: showingDetails,
disableScaleGestures: _showingDetails,
scaleStateChangedCallback: _onScaleStateChanged,
onPageBuild: _onPageBuild,
onDragStart: _onDragStart,
@@ -319,45 +316,42 @@ class _AssetPageState extends ConsumerState<AssetPage> {
onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null,
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
errorBuilder: (_, __, ___) => SizedBox(
width: size.width,
height: size.height,
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain),
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
),
);
}
final Size childSize;
if (displayAsset.width != null && displayAsset.height != null) {
final r = displayAsset.width! / displayAsset.height!;
final w = math.min(context.width, context.height * r);
childSize = Size(w, w / r);
} else {
childSize = Size(context.height, context.height);
}
return PhotoView.customChild(
key: Key(displayAsset.heroTag),
childSize: childSize,
filterQuality: FilterQuality.low,
key: Key(asset.heroTag),
childSize: asset.width != null && asset.height != null
? Size(asset.width!.toDouble(), asset.height!.toDouble())
: null,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
heroAttributes: heroAttributes,
basePosition: Alignment.center,
disableScaleGestures: showingDetails,
scaleStateChangedCallback: _onScaleStateChanged,
heroAttributes: heroAttributes,
filterQuality: FilterQuality.high,
basePosition: Alignment.center,
disableScaleGestures: _showingDetails,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
tightMode: true,
onPageBuild: _onPageBuild,
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer(
key: _NativeVideoViewerKey(displayAsset.heroTag),
asset: displayAsset,
key: _NativeVideoViewerKey(asset.heroTag),
asset: asset,
isCurrent: isCurrent,
image: Image(
image: getFullImageProvider(displayAsset, size: childSize),
image: getFullImageProvider(asset, size: size),
fit: BoxFit.contain,
alignment: Alignment.center,
),
@@ -383,6 +377,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
displayAsset = stackChildren.elementAt(stackIndex);
}
final isCurrent = currentHeroTag == displayAsset.heroTag;
final viewportWidth = MediaQuery.widthOf(context);
final viewportHeight = MediaQuery.heightOf(context);
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
@@ -392,69 +388,60 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_snapOffset = detailsOffset - snapTarget;
if (_proxyScrollController.hasClients) {
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
if (_scrollController.hasClients) {
_scrollController.snapPosition.snapOffset = _snapOffset;
}
return ProviderScope(
overrides: [
currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)),
currentAssetExifProvider.overrideWith((ref) {
final a = ref.watch(currentAssetNotifier);
if (a == null) return Future.value(null);
return ref.watch(assetServiceProvider).getExif(a);
}),
],
child: Stack(
children: [
Offstage(
child: SingleChildScrollView(
controller: _proxyScrollController,
physics: const SnapScrollPhysics(),
child: const SizedBox.shrink(),
),
),
SingleChildScrollView(
controller: _scrollController,
physics: const NeverScrollableScrollPhysics(),
child: Stack(
children: [
SizedBox(
width: viewportWidth,
height: viewportHeight,
child: _buildPhotoView(
displayAsset,
asset,
isCurrentPage: currentHeroTag == asset.heroTag,
showingDetails: _showingDetails,
isPlayingMotionVideo: isPlayingMotionVideo,
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
),
return Stack(
children: [
SingleChildScrollView(
controller: _scrollController,
physics: const SnapScrollPhysics(),
child: Stack(
children: [
SizedBox(
width: viewportWidth,
height: viewportHeight,
child: _buildPhotoView(
asset: displayAsset,
heroAttributes: isCurrent
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
: null,
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
),
IgnorePointer(
ignoring: !_showingDetails,
child: Column(
children: [
SizedBox(height: detailsOffset),
GestureDetector(
onVerticalDragStart: _beginDrag,
onVerticalDragUpdate: _updateDrag,
onVerticalDragEnd: _endDrag,
onVerticalDragCancel: _onDragCancel,
child: AnimatedOpacity(
opacity: _showingDetails ? 1.0 : 0.0,
duration: Durations.short2,
child: AssetDetails(minHeight: viewportHeight - snapTarget),
),
),
IgnorePointer(
ignoring: !_showingDetails,
child: Column(
children: [
SizedBox(height: detailsOffset),
GestureDetector(
onVerticalDragStart: _beginDrag,
onVerticalDragUpdate: _updateDrag,
onVerticalDragEnd: _endDrag,
onVerticalDragCancel: _onDragCancel,
child: AnimatedOpacity(
opacity: _showingDetails ? 1.0 : 0.0,
duration: Durations.short2,
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
),
],
),
),
],
),
],
),
),
],
),
],
),
),
if (stackChildren != null && stackChildren.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: context.padding.bottom,
child: AssetStackRow(stack: stackChildren),
),
],
);
}
}
@@ -1,53 +1,42 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
class AssetStackRow extends ConsumerWidget {
const AssetStackRow({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren == null || stackChildren.isEmpty) {
return const SizedBox.shrink();
}
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
if (showingDetails) {
return const SizedBox.shrink();
}
return _StackList(stack: stackChildren);
}
}
class _StackList extends ConsumerWidget {
final List<RemoteAsset> stack;
const _StackList({required this.stack});
const AssetStackRow({super.key, required this.stack});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 5.0,
children: List.generate(stack.length, (i) {
final asset = stack[i];
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
}),
if (stack.isEmpty) {
return const SizedBox.shrink();
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
return IgnorePointer(
ignoring: opacity < 1.0,
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.short2,
child: Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 5.0,
children: List.generate(stack.length, (i) {
final asset = stack[i];
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
}),
),
),
),
),
),
@@ -67,8 +56,9 @@ class _StackItem extends ConsumerStatefulWidget {
class _StackItemState extends ConsumerState<_StackItem> {
void _onTap() {
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
final notifier = ref.read(assetViewerProvider.notifier);
notifier.setAsset(widget.asset);
notifier.setStackIndex(widget.index);
}
@override
@@ -17,13 +17,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_statu
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -72,15 +69,7 @@ class AssetViewer extends ConsumerStatefulWidget {
}
static void _setAsset(WidgetRef ref, BaseAsset asset) {
// Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed
// which could be stack children as well
ref.read(currentAssetNotifier.notifier).setAsset(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
}
// Hide controls by default for videos
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
}
@@ -91,6 +80,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
late final _pageController = PageController(initialPage: widget.initialIndex);
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
late int _currentPage = widget.initialIndex;
StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive;
@@ -102,7 +93,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final target = page + direction;
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
if (target >= 0 && target <= maxPage) {
_currentPage = target;
_pageController.jumpToPage(target);
_onAssetChanged(target);
}
}
@@ -110,7 +103,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void initState() {
super.initState();
final asset = ref.read(currentAssetNotifier);
final asset = ref.read(assetViewerProvider).currentAsset;
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
@@ -134,6 +127,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.dispose();
}
// The normal onPageChange callback listens to OnScrollUpdate events, and will
// round the current page and update whenever that value changes. In practise,
// this means that the page will change when swiped half way, and may flip
// whilst dragging.
//
// Changing the page at the end of a scroll should be more robust, and allow
// the page to be dragged more than half way whilst keeping the current video
// playing, and preventing the video on the next page from becoming ready
// unnecessarily.
bool _onScrollEnd(ScrollEndNotification notification) {
if (notification.depth != 0) return false;
final page = _pageController.page?.round();
if (page != null && page != _currentPage) {
_currentPage = page;
_onAssetChanged(page);
}
return false;
}
void _onAssetInit(Duration timeStamp) {
_preloader.preload(widget.initialIndex, context.sizeData);
_handleCasting();
@@ -153,7 +166,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _handleCasting() {
if (!ref.read(castProvider).isCasting) return;
final asset = ref.read(currentAssetNotifier);
final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) return;
if (asset is RemoteAsset) {
@@ -195,17 +208,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
var index = _pageController.page?.round() ?? 0;
final currentAsset = ref.read(currentAssetNotifier);
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset != null) {
final newIndex = timelineService.getIndex(currentAsset.heroTag);
if (newIndex != null && newIndex != index) {
index = newIndex;
_currentPage = index;
_pageController.jumpToPage(index);
}
}
if (index >= totalAssets) {
index = totalAssets - 1;
_currentPage = index;
_pageController.jumpToPage(index);
}
@@ -221,7 +236,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) return;
final currentAsset = ref.read(currentAssetNotifier);
final currentAsset = ref.read(assetViewerProvider).currentAsset;
// Do not reload if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) return;
@@ -258,25 +273,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_setSystemUIMode(controls, details);
});
return PopScope(
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
child: Scaffold(
backgroundColor: backgroundColor,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
return Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
PhotoViewGestureDetectorScope(
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
NotificationListener<ScrollEndNotification>(
onNotification: _onScrollEnd,
child: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
@@ -286,21 +302,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: ref.read(timelineServiceProvider).totalAssets,
onPageChanged: (index) => _onAssetChanged(index),
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
),
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
],
),
),
],
),
);
}
@@ -9,8 +9,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -21,7 +20,7 @@ class ViewerBottomBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
@@ -65,9 +64,9 @@ class ViewerBottomBar extends ConsumerWidget {
color: Colors.black.withAlpha(125),
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) const VideoControls(),
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
@@ -1,8 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
@@ -11,420 +9,225 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
if (asset is RemoteAsset) {
return switch (currentAsset) {
RemoteAsset remoteAsset => remoteAsset.id == asset.id,
LocalAsset localAsset => localAsset.remoteId == asset.id,
_ => false,
};
} else if (asset is LocalAsset) {
return switch (currentAsset) {
RemoteAsset remoteAsset => remoteAsset.localId == asset.id,
LocalAsset localAsset => localAsset.id == asset.id,
_ => false,
};
}
return false;
}
class NativeVideoViewer extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
class NativeVideoViewer extends ConsumerStatefulWidget {
final BaseAsset asset;
final int playbackDelayFactor;
final bool isCurrent;
final bool showControls;
final Widget image;
const NativeVideoViewer({super.key, required this.asset, required this.image, this.playbackDelayFactor = 1});
const NativeVideoViewer({
super.key,
required this.asset,
required this.image,
this.isCurrent = false,
this.showControls = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useState<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
ConsumerState<NativeVideoViewer> createState() => _NativeVideoViewerState();
}
// Used to track whether the video should play when the app
// is brought back to the foreground
final shouldPlayOnForeground = useRef(true);
class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
static final _log = Logger('NativeVideoViewer');
// When a video is opened through the timeline, `isCurrent` will immediately be true.
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
final currentAsset = useState(ref.read(currentAssetNotifier));
final isCurrent = _isCurrentAsset(asset, currentAsset.value);
NativeVideoPlayerController? _controller;
late final Future<VideoSource?> _videoSource;
Timer? _loadTimer;
bool _isVideoReady = false;
bool _shouldPlayOnForeground = true;
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.hasLocal);
VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
Future<VideoSource?> createSource() async {
if (!context.mounted) {
return null;
}
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset;
if (!context.mounted) {
return null;
}
try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
if (!context.mounted) {
return null;
}
if (file == null) {
throw Exception('No file found for the video');
}
// Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers.
final source = await VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
return source;
}
final remoteId = (videoAsset as RemoteAsset).id;
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
final source = await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
return source;
} catch (error) {
log.severe('Error creating video source for asset ${videoAsset.name}: $error');
return null;
}
}
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(null);
useMemoized(() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
}
try {
aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset);
} catch (error) {
log.severe('Error getting aspect ratio for asset ${asset.name}: $error');
}
}, [asset.heroTag]);
void checkIfBuffering() {
if (!context.mounted) {
return;
}
final videoPlayback = ref.read(videoPlaybackValueProvider);
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) {
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
state: VideoPlaybackState.buffering,
);
}
}
// Timer to mark videos as buffering if the position does not change
useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the position changes, seek to the position
// Debounce the seek to avoid seeking too often
// But also don't delay the seek too much to maintain visual feedback
final seekDebouncer = useDebouncer(
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
final playerController = controller.value;
if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return;
}
final oldSeek = oldControls?.position.inMilliseconds;
final newSeek = newControls.position.inMilliseconds;
if (oldSeek != newSeek || newControls.restarted) {
seekDebouncer.run(() => playerController.seekTo(newSeek));
}
if (oldControls?.pause != newControls.pause || newControls.restarted) {
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
}
});
void onPlaybackReady() async {
final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) {
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
return;
}
try {
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
if (autoPlayVideo) {
await videoController.play();
}
await videoController.setVolume(0.9);
} catch (error) {
log.severe('Error playing video: $error');
}
}
void onPlaybackStatusChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
} else {
// Sync with the controls pause
WakelockPlus.disable();
}
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state;
}
void onPlaybackPositionChanged() {
// When seeking, these events sometimes move the slider to an older position
if (seekDebouncer.isActive) {
return;
}
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
isBuffering.value = false;
lastVideoPosition.value = -1;
}
}
void onPlaybackEnded() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
if (videoController.playbackInfo?.status == PlaybackStatus.stopped) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
}
}
void removeListeners(NativeVideoPlayerController controller) {
controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged);
controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged);
controller.onPlaybackReady.removeListener(onPlaybackReady);
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
}
void initController(NativeVideoPlayerController nc) async {
if (controller.value != null || !context.mounted) {
return;
}
ref.read(videoPlayerControlsProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).reset();
final source = await videoSource;
if (source == null || !context.mounted) {
return;
}
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(onPlaybackReady);
nc.onPlaybackEnded.addListener(onPlaybackEnded);
unawaited(
nc.loadVideoSource(source).catchError((error) {
log.severe('Error loading video source: $error');
}),
);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo));
controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
ref.listen(currentAssetNotifier, (_, value) {
final playerController = controller.value;
if (playerController != null && value != asset) {
removeListeners(playerController);
}
if (value != null) {
isVisible.value = _isCurrentAsset(value, asset);
}
final curAsset = currentAsset.value;
if (curAsset == asset) {
return;
}
final imageToVideo = curAsset != null && !curAsset.isVideo;
// No need to delay video playback when swiping from an image to a video
if (imageToVideo && Platform.isIOS) {
currentAsset.value = value;
onPlaybackReady();
return;
}
// Delay the video playback to avoid a stutter in the swipe animation
// Note, in some circumstances a longer delay is needed (eg: memories),
// the playbackDelayFactor can be used for this
// This delay seems like a hacky way to resolve underlying bugs in video
// playback, but other resolutions failed thus far
Timer(
Platform.isIOS
? Duration(milliseconds: 300 * playbackDelayFactor)
: imageToVideo
? Duration(milliseconds: 200 * playbackDelayFactor)
: Duration(milliseconds: 400 * playbackDelayFactor),
() {
if (!context.mounted) {
return;
}
currentAsset.value = value;
if (currentAsset.value == asset) {
onPlaybackReady();
}
},
);
});
useEffect(() {
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true);
return () {
timer?.cancel();
final playerController = controller.value;
if (playerController == null) {
return;
}
removeListeners(playerController);
playerController.stop().catchError((error) {
log.fine('Error stopping video: $error');
});
WakelockPlus.disable();
};
}, const []);
useOnAppLifecycleStateChange((_, state) async {
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
await controller.value?.play();
} else if (state == AppLifecycleState.paused) {
final videoPlaying = await controller.value?.isPlaying();
if (videoPlaying ?? true) {
shouldPlayOnForeground.value = true;
await controller.value?.pause();
} else {
shouldPlayOnForeground.value = false;
}
}
});
return Stack(
children: [
// This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset
Center(child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain(
visible: isVisible.value,
child: NativeVideoPlayerView(onViewReady: initController),
),
const Center(child: VideoViewerControls()),
],
);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_videoSource = _createSource();
}
Future<void> _onPauseChange(
BuildContext context,
NativeVideoPlayerController controller,
Debouncer seekDebouncer,
bool isPaused,
) async {
if (!context.mounted) {
@override
void didUpdateWidget(NativeVideoViewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return;
if (!widget.isCurrent) {
_loadTimer?.cancel();
_notifier.pause();
return;
}
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
// Prevent unnecessary loading when swiping between assets.
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
}
try {
if (isPaused) {
await controller.pause();
} else {
await controller.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_loadTimer?.cancel();
_removeListeners();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
switch (state) {
case AppLifecycleState.resumed:
if (_shouldPlayOnForeground) await _notifier.play();
case AppLifecycleState.paused:
_shouldPlayOnForeground = await _controller?.isPlaying() ?? true;
if (_shouldPlayOnForeground) await _notifier.pause();
default:
}
}
Future<VideoSource?> _createSource() async {
if (!mounted) return null;
final videoAsset = await ref.read(assetServiceProvider).getAsset(widget.asset) ?? widget.asset;
if (!mounted) return null;
try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
if (!mounted) return null;
if (file == null) {
throw Exception('No file found for the video');
}
// Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers.
return VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
}
final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
} catch (error) {
_log.severe('Error creating video source for asset ${videoAsset.name}: $error');
return null;
}
}
void _onPlaybackReady() async {
if (!mounted || !widget.isCurrent) return;
_notifier.onNativePlaybackReady();
// onPlaybackReady may be called multiple times, usually when more data
// loads. If this is not the first time that the player has become ready, we
// should not autoplay.
if (_isVideoReady) return;
setState(() => _isVideoReady = true);
if (ref.read(assetViewerProvider).showingDetails) return;
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
if (autoPlayVideo) await _notifier.play();
}
void _onPlaybackEnded() {
if (!mounted) return;
_notifier.onNativePlaybackEnded();
if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
}
}
void _onPlaybackPositionChanged() {
if (!mounted) return;
_notifier.onNativePositionChanged();
}
void _onPlaybackStatusChanged() {
if (!mounted) return;
_notifier.onNativeStatusChanged();
}
void _removeListeners() {
_controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged);
_controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged);
_controller?.onPlaybackReady.removeListener(_onPlaybackReady);
_controller?.onPlaybackEnded.removeListener(_onPlaybackEnded);
}
void _loadVideo() async {
final nc = _controller;
if (nc == null || nc.videoSource != null || !mounted) return;
final source = await _videoSource;
if (source == null || !mounted) return;
unawaited(
nc.loadVideoSource(source).catchError((error) {
_log.severe('Error loading video source: $error');
}),
);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1);
}
void _initController(NativeVideoPlayerController nc) {
if (_controller != null || !mounted) return;
_notifier.attachController(nc);
nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(_onPlaybackReady);
nc.onPlaybackEnded.addListener(_onPlaybackEnded);
_controller = nc;
if (widget.isCurrent) _loadVideo();
}
@override
Widget build(BuildContext context) {
// Prevent the provider from being disposed whilst the widget is alive.
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
return Stack(
children: [
Center(child: widget.image),
if (!isCasting)
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
],
);
}
}
@@ -1,29 +1,26 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class VideoViewerControls extends HookConsumerWidget {
final BaseAsset asset;
final Duration hideTimerDuration;
const VideoViewerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
if (showingDetails) {
showControls = false;
}
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final videoPlayerName = asset.heroTag;
final assetIsVideo = asset.isVideo;
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
final cast = ref.watch(castProvider);
@@ -32,14 +29,14 @@ class VideoViewerControls extends HookConsumerWidget {
if (!context.mounted) {
return;
}
final state = ref.read(videoPlaybackValueProvider).state;
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
// Do not hide on paused
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
@@ -47,9 +44,11 @@ class VideoViewerControls extends HookConsumerWidget {
ref.read(assetViewerProvider.notifier).setControls(true);
}
// When we change position, show or hide timer
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
showControlsAndStartHideTimer();
// When playback starts, reset the hide timer
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
if (next == VideoPlaybackStatus.playing) {
hideTimer.reset();
}
});
/// Toggles between playing and pausing depending on the state of the video
@@ -57,34 +56,30 @@ class VideoViewerControls extends HookConsumerWidget {
showControlsAndStartHideTimer();
if (cast.isCasting) {
if (cast.castState == CastState.playing) {
ref.read(castProvider.notifier).pause();
} else if (cast.castState == CastState.paused) {
ref.read(castProvider.notifier).play();
} else if (cast.castState == CastState.idle) {
// resend the play command since its finished
final asset = ref.read(currentAssetNotifier);
if (asset == null) {
return;
}
// ref.read(castProvider.notifier).loadMedia(asset, true);
switch (cast.castState) {
case CastState.playing:
ref.read(castProvider.notifier).pause();
case CastState.paused:
ref.read(castProvider.notifier).play();
default:
}
return;
}
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) {
ref.read(videoPlayerControlsProvider.notifier).restart();
} else {
ref.read(videoPlayerControlsProvider.notifier).play();
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
switch (status) {
case VideoPlaybackStatus.playing:
notifier.pause();
case VideoPlaybackStatus.completed:
notifier.restart();
default:
notifier.play();
}
}
void toggleControlsVisibility() {
if (showBuffering) {
return;
}
if (showBuffering) return;
if (showControls) {
ref.read(assetViewerProvider.notifier).setControls(false);
} else {
@@ -105,9 +100,9 @@ class VideoViewerControls extends HookConsumerWidget {
CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isFinished: status == VideoPlaybackStatus.completed,
isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
class ViewerBottomAppBar extends ConsumerWidget {
@@ -9,24 +8,12 @@ class ViewerBottomAppBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (!showControls) {
opacity = 0.0;
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
return IgnorePointer(
ignoring: opacity < 1.0,
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.short2,
child: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [AssetStackRow(), ViewerBottomBar()],
),
),
child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const ViewerBottomBar()),
);
}
}
@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -21,7 +21,7 @@ class ViewerKebabMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
@@ -8,10 +8,9 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
@@ -22,7 +21,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
@@ -35,16 +34,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
ref.watch(albumActivityProvider(album.id, asset.id));
}
if (!showControls) {
opacity = 0.0;
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
final originalTheme = context.themeData;
@@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
@@ -13,12 +13,14 @@ class DriftMemoryCard extends StatelessWidget {
final RemoteAsset asset;
final String title;
final bool showTitle;
final bool isCurrent;
final Function()? onVideoEnded;
const DriftMemoryCard({
required this.asset,
required this.title,
required this.showTitle,
this.isCurrent = false,
this.onVideoEnded,
super.key,
});
@@ -37,32 +39,35 @@ class DriftMemoryCard extends StatelessWidget {
SizedBox.expand(child: _BlurredBackdrop(asset: asset)),
LayoutBuilder(
builder: (context, constraints) {
final r = asset.width != null && asset.height != null
? asset.width! / asset.height!
: constraints.maxWidth / constraints.maxHeight;
// Determine the fit using the aspect ratio
BoxFit fit = BoxFit.contain;
if (asset.width != null && asset.height != null) {
final aspectRatio = asset.width! / asset.height!;
final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight;
// Look for a 25% difference in either direction
if (phoneAspectRatio * .75 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) {
if (phoneAspectRatio * .75 < r && phoneAspectRatio * 1.25 > r) {
// Cover to look nice if we have nearly the same aspect ratio
fit = BoxFit.cover;
}
}
if (asset.isImage) {
return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
} else {
return SizedBox(
width: context.width,
height: context.height,
if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
return Center(
child: AspectRatio(
aspectRatio: r,
child: NativeVideoViewer(
key: ValueKey(asset.id),
asset: asset,
playbackDelayFactor: 2,
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
isCurrent: isCurrent,
showControls: false,
image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
),
);
}
),
);
},
),
if (showTitle)
@@ -5,6 +5,7 @@ const Size kTimelineFixedTileExtent = Size.square(256);
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;
const double kScrubberThumbHeight = 48.0;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
@@ -530,12 +530,14 @@ class _CircularThumb extends StatelessWidget {
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(48.0),
bottomLeft: Radius.circular(48.0),
topLeft: Radius.circular(kScrubberThumbHeight),
bottomLeft: Radius.circular(kScrubberThumbHeight),
topRight: Radius.circular(4.0),
bottomRight: Radius.circular(4.0),
),
child: Container(constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0))),
child: Container(
constraints: BoxConstraints.tight(const Size(kScrubberThumbHeight * 0.6, kScrubberThumbHeight)),
),
),
);
}
@@ -17,6 +17,7 @@ import 'package:immich_mobile/extensions/asyncvalue_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/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/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
@@ -34,6 +35,7 @@ class Timeline extends StatelessWidget {
super.key,
this.topSliverWidget,
this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.showStorageIndicator = false,
this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
@@ -41,13 +43,14 @@ class Timeline extends StatelessWidget {
this.groupBy,
this.withScrubber = true,
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
this.persistentBottomBar = false,
this.loadingWidget,
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final bool showStorageIndicator;
final Widget? appBar;
final Widget? bottomSheet;
@@ -55,9 +58,9 @@ class Timeline extends StatelessWidget {
final GroupAssetsBy? groupBy;
final bool withScrubber;
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
final bool persistentBottomBar;
final Widget? loadingWidget;
@override
Widget build(BuildContext context) {
@@ -82,13 +85,14 @@ class Timeline extends StatelessWidget {
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
initialScrollOffset: initialScrollOffset,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
),
),
),
@@ -111,24 +115,26 @@ class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
this.topSliverWidget,
this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.appBar,
this.bottomSheet,
this.withScrubber = true,
this.persistentBottomBar = false,
this.snapToMonth = true,
this.initialScrollOffset,
this.maxWidth,
this.loadingWidget,
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final Widget? appBar;
final Widget? bottomSheet;
final bool withScrubber;
final bool persistentBottomBar;
final bool snapToMonth;
final double? initialScrollOffset;
final double? maxWidth;
final Widget? loadingWidget;
@override
ConsumerState createState() => _SliverTimelineState();
@@ -152,10 +158,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void initState() {
super.initState();
_scrollController = ScrollController(
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
onAttach: _restoreAssetPosition,
);
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent);
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
@@ -373,6 +376,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
},
child: asyncSegments.widgetWhen(
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
@@ -380,12 +384,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
: 0;
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0;
const bottomSheetOpenModifier = 120.0;
final bottomPadding =
context.padding.bottom +
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
final grid = CustomScrollView(
primary: true,
@@ -408,7 +409,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false,
),
),
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
],
);
@@ -419,7 +421,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
bottomPadding: scrubberBottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid,
@@ -1,5 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class AssetViewerState {
@@ -68,6 +70,12 @@ class AssetViewerState {
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
@override
AssetViewerState build() {
ref.listen(_watchedCurrentAssetProvider, (_, next) {
final updated = next.valueOrNull;
if (updated != null) {
state = state.copyWith(currentAsset: updated);
}
});
return const AssetViewerState();
}
@@ -75,10 +83,8 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
state = const AssetViewerState();
}
void setAsset(BaseAsset? asset) {
if (asset == state.currentAsset) {
return;
}
void setAsset(BaseAsset asset) {
if (asset == state.currentAsset) return;
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
@@ -95,7 +101,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
}
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
if (showing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
final heroTag = state.currentAsset?.heroTag;
if (heroTag != null) {
ref.read(videoPlayerProvider(heroTag).notifier).pause();
}
}
}
@@ -126,3 +135,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
}
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
final asset = ref.read(assetViewerProvider).currentAsset;
if (asset == null) return const Stream.empty();
return ref.read(assetServiceProvider).watchAsset(asset);
});
@@ -1,71 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
class VideoPlaybackControls {
const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false});
final Duration position;
final bool pause;
final bool restarted;
}
final videoPlayerControlsProvider = StateNotifierProvider<VideoPlayerControls, VideoPlaybackControls>((ref) {
return VideoPlayerControls(ref);
});
const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
final Ref ref;
VideoPlaybackControls get value => state;
set value(VideoPlaybackControls value) {
state = value;
}
void reset() {
state = videoPlayerControlsDefault;
}
Duration get position => state.position;
bool get paused => state.pause;
set position(Duration value) {
if (state.position == value) {
return;
}
state = VideoPlaybackControls(position: value, pause: state.pause);
}
void pause() {
if (state.pause) {
return;
}
state = VideoPlaybackControls(position: state.position, pause: true);
}
void play() {
if (!state.pause) {
return;
}
state = VideoPlaybackControls(position: state.position, pause: false);
}
void togglePlay() {
state = VideoPlaybackControls(position: state.position, pause: !state.pause);
}
void restart() {
state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true);
ref.read(videoPlaybackValueProvider.notifier).value = ref
.read(videoPlaybackValueProvider.notifier)
.value
.copyWith(state: VideoPlaybackState.playing, position: Duration.zero);
}
}
@@ -0,0 +1,200 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
enum VideoPlaybackStatus { paused, playing, buffering, completed }
class VideoPlayerState {
final Duration position;
final Duration duration;
final VideoPlaybackStatus status;
const VideoPlayerState({required this.position, required this.duration, required this.status});
VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) {
return VideoPlayerState(
position: position ?? this.position,
duration: duration ?? this.duration,
status: status ?? this.status,
);
}
}
const _defaultState = VideoPlayerState(
position: Duration.zero,
duration: Duration.zero,
status: VideoPlaybackStatus.paused,
);
final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayerNotifier, VideoPlayerState, String>((
ref,
name,
) {
return VideoPlayerNotifier();
});
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
static final _log = Logger('VideoPlayerNotifier');
VideoPlayerNotifier() : super(_defaultState);
NativeVideoPlayerController? _controller;
Timer? _bufferingTimer;
Timer? _seekTimer;
void attachController(NativeVideoPlayerController controller) {
_controller = controller;
}
@override
void dispose() {
_bufferingTimer?.cancel();
_seekTimer?.cancel();
WakelockPlus.disable();
_controller = null;
super.dispose();
}
Future<void> pause() async {
if (_controller == null) return;
_bufferingTimer?.cancel();
try {
await _controller!.pause();
await _flushSeek();
} catch (e) {
_log.severe('Error pausing video: $e');
}
}
Future<void> play() async {
if (_controller == null) return;
try {
await _flushSeek();
await _controller!.play();
} catch (e) {
_log.severe('Error playing video: $e');
}
_startBufferingTimer();
}
Future<void> _flushSeek() async {
final timer = _seekTimer;
if (timer == null || !timer.isActive) return;
timer.cancel();
await _controller?.seekTo(state.position.inMilliseconds);
}
void seekTo(Duration position) {
if (_controller == null) return;
state = state.copyWith(position: position);
_seekTimer?.cancel();
_seekTimer = Timer(const Duration(milliseconds: 100), () {
_controller?.seekTo(position.inMilliseconds);
});
}
Future<void> restart() async {
seekTo(Duration.zero);
await play();
}
Future<void> setVolume(double volume) async {
try {
await _controller?.setVolume(volume);
} catch (e) {
_log.severe('Error setting volume: $e');
}
}
Future<void> setLoop(bool loop) async {
try {
await _controller?.setLoop(loop);
} catch (e) {
_log.severe('Error setting loop: $e');
}
}
void onNativePlaybackReady() {
if (!mounted) return;
final playbackInfo = _controller?.playbackInfo;
final videoInfo = _controller?.videoInfo;
if (playbackInfo == null || videoInfo == null) return;
state = state.copyWith(
position: Duration(milliseconds: playbackInfo.position),
duration: Duration(milliseconds: videoInfo.duration),
status: _mapStatus(playbackInfo.status),
);
}
void onNativePositionChanged() {
if (!mounted || (_seekTimer?.isActive ?? false)) return;
final playbackInfo = _controller?.playbackInfo;
if (playbackInfo == null) return;
final position = Duration(milliseconds: playbackInfo.position);
if (state.position == position) return;
if (state.status == VideoPlaybackStatus.buffering) {
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
} else {
state = state.copyWith(position: position);
}
_startBufferingTimer();
}
void onNativeStatusChanged() {
if (!mounted) return;
final playbackInfo = _controller?.playbackInfo;
if (playbackInfo == null) return;
final newStatus = _mapStatus(playbackInfo.status);
switch (newStatus) {
case VideoPlaybackStatus.playing:
WakelockPlus.enable();
_startBufferingTimer();
default:
onNativePlaybackEnded();
}
if (state.status != newStatus) {
state = state.copyWith(status: newStatus);
}
}
void onNativePlaybackEnded() {
WakelockPlus.disable();
_bufferingTimer?.cancel();
}
void _startBufferingTimer() {
_bufferingTimer?.cancel();
_bufferingTimer = Timer(const Duration(seconds: 3), () {
if (mounted && state.status == VideoPlaybackStatus.playing) {
state = state.copyWith(status: VideoPlaybackStatus.buffering);
}
});
}
static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) {
PlaybackStatus.playing => VideoPlaybackStatus.playing,
PlaybackStatus.paused => VideoPlaybackStatus.paused,
PlaybackStatus.stopped => VideoPlaybackStatus.completed,
};
}
@@ -1,88 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:native_video_player/native_video_player.dart';
enum VideoPlaybackState { initializing, paused, playing, buffering, completed }
class VideoPlaybackValue {
/// The current position of the video
final Duration position;
/// The total duration of the video
final Duration duration;
/// The current state of the video playback
final VideoPlaybackState state;
/// The volume of the video
final double volume;
const VideoPlaybackValue({required this.position, required this.duration, required this.state, required this.volume});
factory VideoPlaybackValue.fromNativeController(NativeVideoPlayerController controller) {
final playbackInfo = controller.playbackInfo;
final videoInfo = controller.videoInfo;
if (playbackInfo == null || videoInfo == null) {
return videoPlaybackValueDefault;
}
final VideoPlaybackState status = switch (playbackInfo.status) {
PlaybackStatus.playing => VideoPlaybackState.playing,
PlaybackStatus.paused => VideoPlaybackState.paused,
PlaybackStatus.stopped => VideoPlaybackState.completed,
};
return VideoPlaybackValue(
position: Duration(milliseconds: playbackInfo.position),
duration: Duration(milliseconds: videoInfo.duration),
state: status,
volume: playbackInfo.volume,
);
}
VideoPlaybackValue copyWith({Duration? position, Duration? duration, VideoPlaybackState? state, double? volume}) {
return VideoPlaybackValue(
position: position ?? this.position,
duration: duration ?? this.duration,
state: state ?? this.state,
volume: volume ?? this.volume,
);
}
}
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
final videoPlaybackValueProvider = StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
return VideoPlaybackValueState(ref);
});
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
final Ref ref;
VideoPlaybackValue get value => state;
set value(VideoPlaybackValue value) {
state = value;
}
set position(Duration value) {
if (state.position == value) return;
state = VideoPlaybackValue(position: value, duration: state.duration, state: state.state, volume: state.volume);
}
set status(VideoPlaybackState value) {
if (state.state == value) return;
state = VideoPlaybackValue(position: state.position, duration: state.duration, state: value, volume: state.volume);
}
void reset() {
state = videoPlaybackValueDefault;
}
}
@@ -8,9 +8,9 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -123,7 +123,7 @@ class ActionNotifier extends Notifier<void> {
Set<BaseAsset> _getAssets(ActionSource source) {
return switch (source) {
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
ActionSource.viewer => switch (ref.read(currentAssetNotifier)) {
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
BaseAsset asset => {asset},
null => const {},
},
@@ -307,7 +307,10 @@ class ActionNotifier extends Notifier<void> {
// does not update the currentAsset which means
// the exif provider will not be refreshed automatically
if (source == ActionSource.viewer) {
ref.invalidate(currentAssetExifProvider);
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset != null) {
ref.invalidate(assetExifProvider(currentAsset));
}
}
return ActionResult(count: ids.length, success: true);
@@ -409,7 +412,6 @@ class ActionNotifier extends Notifier<void> {
if (source == ActionSource.viewer) {
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
if (updatedParent != null) {
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
}
}
@@ -1,52 +1,8 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
final currentAssetNotifier = AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset?>(CurrentAssetNotifier.new);
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
KeepAliveLink? _keepAliveLink;
StreamSubscription<BaseAsset?>? _assetSubscription;
@override
BaseAsset? build() => null;
void setAsset(BaseAsset asset) {
_keepAliveLink?.close();
_assetSubscription?.cancel();
state = asset;
_assetSubscription = ref.watch(assetServiceProvider).watchAsset(asset).listen((updatedAsset) {
if (updatedAsset != null) {
state = updatedAsset;
}
});
_keepAliveLink = ref.keepAlive();
}
void dispose() {
_keepAliveLink?.close();
_assetSubscription?.cancel();
}
}
class ScopedAssetNotifier extends CurrentAssetNotifier {
final BaseAsset _asset;
ScopedAssetNotifier(this._asset);
@override
BaseAsset? build() {
setAsset(_asset);
return _asset;
}
}
final currentAssetExifProvider = FutureProvider.autoDispose((ref) {
final currentAsset = ref.watch(currentAssetNotifier);
if (currentAsset == null) {
return null;
}
return ref.watch(assetServiceProvider).getExif(currentAsset);
final assetExifProvider = FutureProvider.autoDispose.family<ExifInfo?, BaseAsset>((ref, asset) {
return ref.watch(assetServiceProvider).getExif(asset);
});
-15
View File
@@ -1,15 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter_hooks/flutter_hooks.dart';
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
void useInterval(Duration delay, VoidCallback callback) {
final savedCallback = useRef(callback);
savedCallback.value = callback;
useEffect(() {
final timer = Timer.periodic(delay, (_) => savedCallback.value());
return timer.cancel;
}, [delay]);
}
@@ -333,7 +333,7 @@ class BottomGalleryBar extends ConsumerWidget {
padding: const EdgeInsets.only(top: 40.0),
child: Column(
children: [
if (asset.isVideo) const VideoControls(),
if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()),
BottomNavigationBar(
elevation: 0.0,
backgroundColor: Colors.transparent,
@@ -3,23 +3,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class CustomVideoPlayerControls extends HookConsumerWidget {
final String videoId;
final Duration hideTimerDuration;
const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
const CustomVideoPlayerControls({
super.key,
required this.videoId,
this.hideTimerDuration = const Duration(seconds: 5),
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
final showControls = ref.watch(showControlsProvider);
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status));
final cast = ref.watch(castProvider);
@@ -28,14 +32,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
if (!context.mounted) {
return;
}
final state = ref.read(videoPlaybackValueProvider).state;
final s = ref.read(videoPlayerProvider(videoId)).status;
// Do not hide on paused
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) {
ref.read(showControlsProvider.notifier).show = false;
}
});
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
@@ -43,9 +47,11 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
ref.read(showControlsProvider.notifier).show = true;
}
// When we change position, show or hide timer
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
showControlsAndStartHideTimer();
// When playback starts, reset the hide timer
ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) {
if (next == VideoPlaybackStatus.playing) {
hideTimer.reset();
}
});
/// Toggles between playing and pausing depending on the state of the video
@@ -68,12 +74,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
return;
}
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) {
ref.read(videoPlayerControlsProvider.notifier).restart();
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
if (status == VideoPlaybackStatus.playing) {
notifier.pause();
} else if (status == VideoPlaybackStatus.completed) {
notifier.restart();
} else {
ref.read(videoPlayerControlsProvider.notifier).play();
notifier.play();
}
}
@@ -92,9 +99,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isFinished: status == VideoPlaybackStatus.completed,
isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
@@ -3,15 +3,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
/// The video controls for the [videoPlayerControlsProvider]
/// The video controls for the [videoPlayerProvider]
class VideoControls extends ConsumerWidget {
const VideoControls({super.key});
final String videoPlayerName;
const VideoControls({super.key, required this.videoPlayerName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPortrait = context.orientation == Orientation.portrait;
return isPortrait
? const VideoPosition()
: const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition());
? VideoPosition(videoPlayerName: videoPlayerName)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 60.0),
child: VideoPosition(videoPlayerName: videoPlayerName),
);
}
}
@@ -4,13 +4,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
class VideoPosition extends HookConsumerWidget {
const VideoPosition({super.key});
final String videoPlayerName;
const VideoPosition({super.key, required this.videoPlayerName});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -18,7 +19,7 @@ class VideoPosition extends HookConsumerWidget {
final (position, duration) = isCasting
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
: ref.watch(videoPlaybackValueProvider.select((v) => (v.position, v.duration)));
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
final wasPlaying = useRef<bool>(true);
return duration == Duration.zero
@@ -44,13 +45,13 @@ class VideoPosition extends HookConsumerWidget {
activeColor: Colors.white,
inactiveColor: whiteOpacity75,
onChangeStart: (value) {
final state = ref.read(videoPlaybackValueProvider).state;
wasPlaying.value = state != VideoPlaybackState.paused;
ref.read(videoPlayerControlsProvider.notifier).pause();
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
wasPlaying.value = status != VideoPlaybackStatus.paused;
ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
},
onChangeEnd: (value) {
if (wasPlaying.value) {
ref.read(videoPlayerControlsProvider.notifier).play();
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
}
},
onChanged: (value) {
@@ -61,10 +62,7 @@ class VideoPosition extends HookConsumerWidget {
return;
}
ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration;
// This immediately updates the slider position without waiting for the video to update
ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration;
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
},
),
),
@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
@@ -34,9 +33,6 @@ class MemoryLane extends HookConsumerWidget {
if (memories[memoryIndex].assets.isNotEmpty) {
final asset = memories[memoryIndex].assets[0];
ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
}
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
},
+4 -4
View File
@@ -1217,10 +1217,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@@ -1910,10 +1910,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.6"
thumbhash:
dependency: "direct main"
description:
+287 -126
View File
@@ -66,8 +66,8 @@ importers:
specifier: ^24.10.14
version: 24.11.0
'@vitest/coverage-v8':
specifier: ^3.0.0
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))
specifier: ^4.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))
byte-size:
specifier: ^9.0.0
version: 9.0.1
@@ -114,11 +114,11 @@ importers:
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))
vitest:
specifier: ^3.0.0
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)
specifier: ^4.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)
vitest-fetch-mock:
specifier: ^0.4.0
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))
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))
yaml:
specifier: ^2.3.1
version: 2.8.2
@@ -285,9 +285,12 @@ importers:
utimes:
specifier: ^5.2.1
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:
specifier: ^3.0.0
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)
specifier: ^4.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)
e2e-auth-server:
devDependencies:
@@ -501,7 +504,7 @@ importers:
version: 0.40.3
multer:
specifier: ^2.0.2
version: 2.1.0
version: 2.1.1
nest-commander:
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)
@@ -884,7 +887,7 @@ importers:
version: 6.9.1
'@testing-library/svelte':
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@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))
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))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
@@ -907,8 +910,8 @@ importers:
specifier: ^1.5.5
version: 1.5.6
'@vitest/coverage-v8':
specifier: ^3.0.0
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))
specifier: ^4.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))
dotenv:
specifier: ^17.0.0
version: 17.3.1
@@ -970,8 +973,8 @@ importers:
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)
vitest:
specifier: ^3.0.0
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)
specifier: ^4.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)
packages:
@@ -5195,9 +5198,21 @@ packages:
'@vitest/browser':
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':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/expect@4.0.14':
resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==}
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
@@ -5209,21 +5224,47 @@ packages:
vite:
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':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/pretty-format@4.0.14':
resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/runner@4.0.14':
resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/snapshot@4.0.14':
resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/spy@4.0.14':
resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
'@vitest/utils@4.0.14':
resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -5833,6 +5874,10 @@ packages:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
chai@6.2.1:
resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -5863,8 +5908,8 @@ packages:
chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
cheerio-select@2.1.0:
@@ -7175,8 +7220,8 @@ packages:
resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==}
engines: {node: '>=20.0.0'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
expect-type@1.2.2:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
engines: {node: '>=12.0.0'}
exponential-backoff@3.1.3:
@@ -8605,6 +8650,9 @@ packages:
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
magicast@0.5.1:
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
@@ -9070,8 +9118,8 @@ packages:
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
engines: {node: '>= 10.16.0'}
multer@2.1.0:
resolution: {integrity: sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==}
multer@2.1.1:
resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==}
engines: {node: '>= 10.16.0'}
multicast-dns@7.2.5:
@@ -11295,8 +11343,8 @@ packages:
engines: {node: '>=10'}
hasBin: true
test-exclude@7.0.1:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
test-exclude@7.0.2:
resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==}
engines: {node: '>=18'}
testcontainers@11.12.0:
@@ -11379,6 +11427,10 @@ packages:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
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:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
@@ -11863,6 +11915,40 @@ packages:
jsdom:
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:
resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
engines: {node: '>=14.0.0'}
@@ -16513,14 +16599,14 @@ snapshots:
dependencies:
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@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/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))':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.53.5)
svelte: 5.53.5
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)
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)
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/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
@@ -17227,28 +17313,43 @@ snapshots:
magic-string: 0.30.21
magicast: 0.3.5
std-env: 3.10.0
test-exclude: 7.0.1
test-exclude: 7.0.2
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)
transitivePeerDependencies:
- supports-color
'@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))':
'@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))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.14
ast-v8-to-istanbul: 0.3.8
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magic-string: 0.30.21
magicast: 0.3.5
magicast: 0.5.1
obug: 2.1.1
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
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)
tinyrainbow: 3.0.3
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)
transitivePeerDependencies:
- 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:
- supports-color
@@ -17260,6 +17361,15 @@ snapshots:
chai: 5.3.3
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))':
dependencies:
'@vitest/spy': 3.2.4
@@ -17268,9 +17378,17 @@ snapshots:
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@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/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))':
dependencies:
'@vitest/spy': 3.2.4
'@vitest/spy': 4.0.14
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
magic-string: 0.30.21
optionalDependencies:
@@ -17280,28 +17398,50 @@ snapshots:
dependencies:
tinyrainbow: 2.0.0
'@vitest/pretty-format@4.0.14':
dependencies:
tinyrainbow: 3.0.3
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
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':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.21
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':
dependencies:
tinyspy: 4.0.4
'@vitest/spy@4.0.14': {}
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.2.1
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':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@@ -18000,11 +18140,13 @@ snapshots:
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
chai@6.2.1: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -18026,7 +18168,7 @@ snapshots:
chardet@2.1.1: {}
check-error@2.1.1: {}
check-error@2.1.3: {}
cheerio-select@2.1.0:
dependencies:
@@ -19496,7 +19638,7 @@ snapshots:
optionalDependencies:
exiftool-vendored.exe: 13.51.0
expect-type@1.3.0: {}
expect-type@1.2.2: {}
exponential-backoff@3.1.3: {}
@@ -21122,6 +21264,12 @@ snapshots:
'@babel/types': 7.28.5
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:
dependencies:
semver: 6.3.1
@@ -21909,7 +22057,7 @@ snapshots:
type-is: 1.6.18
xtend: 4.0.2
multer@2.1.0:
multer@2.1.1:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
@@ -24567,11 +24715,11 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
test-exclude@7.0.1:
test-exclude@7.0.2:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 10.5.0
minimatch: 9.0.6
minimatch: 10.2.4
testcontainers@11.12.0:
dependencies:
@@ -24659,6 +24807,8 @@ snapshots:
tinyrainbow@2.0.0: {}
tinyrainbow@3.0.3: {}
tinyspy@4.0.4: {}
tldts-core@6.1.86:
@@ -25083,27 +25233,6 @@ snapshots:
- tsx
- 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)):
dependencies:
debug: 4.4.3
@@ -25154,53 +25283,9 @@ snapshots:
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)
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)):
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)):
dependencies:
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: 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):
dependencies:
@@ -25214,7 +25299,7 @@ snapshots:
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
expect-type: 1.2.2
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.3
@@ -25246,33 +25331,110 @@ snapshots:
- tsx
- yaml
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):
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):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@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/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
'@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
tinypool: 1.1.1
tinyrainbow: 2.0.0
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)
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:
'@types/debug': 4.1.12
'@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)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 25.3.3
happy-dom: 20.7.0
jsdom: 26.1.0(canvas@2.11.2)
@@ -25285,7 +25447,6 @@ snapshots:
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS builder
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202601131104@sha256:c649c5838b6348836d27db6d49cadbbc6157feae7a1a237180c3dec03577ba8f
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
WORKDIR /usr/src/app
ENV NODE_ENV=production \
+2 -2
View File
@@ -1,9 +1,9 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS dev
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
COREPACK_HOME=/tmp \
PNPM_HOME=/buildcache/pnpm-store
RUN npm install --global corepack@latest && \
+2 -2
View File
@@ -7,8 +7,8 @@
"license": "GNU Affero General Public License version 3",
"scripts": {
"build": "nest build",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"start": "pnpm run start:dev",
"nest": "nest",
"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 { AssetMediaService } from 'src/services/asset-media.service';
import { UploadFiles } from 'src/types';
import { ImmichFileResponse, sendFile } from 'src/utils/file';
import { ImmichFileResponse, sendFile, sendFileThrottled } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets)
@@ -109,7 +109,7 @@ export class AssetMediaController {
@Res() res: Response,
@Next() next: NextFunction,
) {
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
await sendFileThrottled(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger, 10);
}
@Put(':id/original')
@@ -162,7 +162,14 @@ export class AssetMediaController {
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
if (viewThumbnailRes instanceof ImmichFileResponse) {
await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
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 {
// viewThumbnailRes is a AssetMediaRedirectResponse
// which redirects to the original asset or a specific size to make better use of caching
+88 -1
View File
@@ -1,13 +1,41 @@
import { HttpException, StreamableFile } from '@nestjs/common';
import { NextFunction, Response } from 'express';
import { access, constants } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { access, constants, stat } from 'node:fs/promises';
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 sharp from 'sharp';
import { CacheControl } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { ImmichReadStream } from 'src/repositories/storage.repository';
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 {
return basename(path, extname(path));
}
@@ -20,6 +48,11 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string
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 {
public readonly path!: string;
public readonly contentType!: string;
@@ -85,3 +118,57 @@ export const sendFile = async (
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
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,6 +3,7 @@ import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { resolve } from 'node:path';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
@@ -78,6 +79,9 @@ import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
import { automock, wait } from 'test/utils';
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 {
new (...args: any[]): T;
}
@@ -2,7 +2,7 @@ import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
@@ -11,7 +11,7 @@ const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const originalPath = resolve(testAssetsDir, testAssetPath);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
@@ -1,7 +1,7 @@
import { Kysely } from 'kysely';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
@@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const originalPath = resolve(testAssetsDir, testAssetPath);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
@@ -1,7 +1,7 @@
import { Kysely } from 'kysely';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
@@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const originalPath = resolve(testAssetsDir, testAssetPath);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
+6 -1
View File
@@ -1,10 +1,15 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import swc from 'unplugin-swc';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export default defineConfig({
test: {
root: './',
name: 'server:medium',
root: serverRoot,
globals: true,
include: ['test/medium/**/*.spec.ts'],
globalSetup: ['test/medium/globalSetup.ts'],
+6 -1
View File
@@ -1,10 +1,15 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import swc from 'unplugin-swc';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export default defineConfig({
test: {
root: './',
name: 'server:unit',
root: serverRoot,
globals: true,
include: ['src/**/*.spec.ts'],
coverage: {
+4 -4
View File
@@ -16,8 +16,8 @@
"check:all": "pnpm run check:code && pnpm run test:cov",
"lint": "eslint . --max-warnings 0 --concurrency 4",
"lint:fix": "pnpm run lint --fix",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"test": "vitest",
"test:cov": "vitest --coverage",
"test:watch": "vitest dev",
@@ -83,7 +83,7 @@
"@types/lodash-es": "^4.17.12",
"@types/luxon": "^3.4.2",
"@types/qrcode": "^1.5.5",
"@vitest/coverage-v8": "^3.0.0",
"@vitest/coverage-v8": "^4.0.0",
"dotenv": "^17.0.0",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
@@ -105,7 +105,7 @@
"typescript": "^5.8.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.2",
"vitest": "^3.0.0"
"vitest": "^4.0.0"
},
"volta": {
"node": "24.13.1"
+1 -1
View File
@@ -2,7 +2,7 @@ import { tick } from 'svelte';
import { vi } from 'vitest';
export const getAnimateMock = () =>
vi.fn().mockImplementation(() => {
vi.fn().mockImplementation(function () {
let onfinish: (() => void) | null = null;
void tick().then(() => onfinish?.());
@@ -1,9 +1,11 @@
import { vi } from 'vitest';
export const getIntersectionObserverMock = () =>
vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
takeRecords: vi.fn(),
unobserve: vi.fn(),
}));
vi.fn(function () {
return {
disconnect: vi.fn(),
observe: vi.fn(),
takeRecords: vi.fn(),
unobserve: vi.fn(),
};
});
@@ -1,8 +1,10 @@
import { vi } from 'vitest';
export const getResizeObserverMock = () =>
vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));
vi.fn(function () {
return {
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
};
});
@@ -0,0 +1,25 @@
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;
+10 -3
View File
@@ -1,8 +1,12 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean; zoomTarget?: HTMLElement }) => {
const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
initialState: assetViewerManager.zoomState,
zoomTarget: options?.zoomTarget,
});
const unsubscribes = [
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
@@ -20,8 +24,11 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
node.style.overflow = 'visible';
return {
update(newOptions?: { disabled?: boolean }) {
update(newOptions?: { disabled?: boolean; zoomTarget?: HTMLElement }) {
options = newOptions;
if (newOptions?.zoomTarget !== undefined) {
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
}
},
destroy() {
for (const unsubscribe of unsubscribes) {
+212
View File
@@ -0,0 +1,212 @@
<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>
@@ -0,0 +1,11 @@
<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>
@@ -0,0 +1,20 @@
<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
@@ -0,0 +1,47 @@
<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}
@@ -0,0 +1,103 @@
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,18 +18,19 @@ describe('AssetViewerNavBar component', () => {
};
beforeAll(() => {
Element.prototype.animate = vi.fn().mockImplementation(() => ({
cancel: () => {},
}));
Element.prototype.animate = vi.fn().mockImplementation(function () {
return {
cancel: () => {},
};
});
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { trash: true, smartSearch: true },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
value: { smartSearch: true, trash: true },
} as never,
};
});
});
@@ -21,7 +21,7 @@
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
import { getAssetActions } from '$lib/services/asset.service';
import { user } from '$lib/stores/user.store';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
@@ -42,7 +42,6 @@
mdiDotsVertical,
mdiImageSearch,
mdiPresentationPlay,
mdiUpload,
mdiVideoOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -170,11 +169,6 @@
{#if !isLocked}
{#if isOwner}
<ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiUpload}
onClick={() => handleReplaceAsset(asset.id)}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
@@ -1,22 +1,20 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap';
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 PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -95,20 +93,20 @@
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
slideshowTransition,
slideshowRepeat,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
const asset = $derived(cursor.current);
let stack: StackResponseDto | undefined = $state();
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 previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
let stack: StackResponseDto | null = $state(null);
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>();
@@ -118,62 +116,60 @@
};
const refreshStack = async () => {
if (authManager.isSharedLink) {
if (authManager.isSharedLink || !withStacked) {
return;
}
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
if (!cursor.current.stack) {
stack = undefined;
return;
}
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
untrack(() => {
imageManager.preload(stack?.assets[1]);
});
stack = await getStack({ id: cursor.current.stack.id });
};
const handleFavorite = async () => {
if (album && album.isActivityEnabled) {
try {
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
}
if (!album || !album.isActivityEnabled) {
return;
}
try {
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
}
};
onMount(() => {
syncAssetViewerOpenClass(true);
unsubscribes.push(
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
}
}),
slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
}),
);
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
}
});
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
});
return () => {
slideshowStateUnsubscribe();
slideshowNavigationUnsubscribe();
};
});
onDestroy(() => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
activityManager.reset();
assetViewerManager.closeEditor();
syncAssetViewerOpenClass(false);
preloadManager.destroy();
});
const closeViewer = () => {
@@ -189,52 +185,57 @@
assetViewerManager.closeEditor();
};
const tracker = new InvocationTracker();
const getNavigationTarget = () => {
if ($slideshowState === SlideshowState.PlaySlideshow) {
return $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
} else {
return 'skip';
}
};
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
} else {
return;
const completeNavigation = async (target: 'previous' | 'next') => {
preloadManager.cancelBeforeNavigation(target);
let hasNext: boolean;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = target === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else {
hasNext =
target === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
}
e?.stopPropagation();
imageManager.cancel(asset);
if (tracker.isActive()) {
if ($slideshowState !== SlideshowState.PlaySlideshow) {
return;
}
void tracker.invoke(async () => {
let hasNext: boolean;
if (hasNext) {
$restartSlideshowProgress = true;
} else if ($slideshowRepeat && slideshowStartAssetId) {
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
};
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else {
hasNext =
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
}
const tracker = new InvocationTracker();
const navigateAsset = (target: 'previous' | 'next' | 'skip') => {
if (target === 'skip' || tracker.isActive()) {
return;
}
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();
}
}
}, $t('error_while_navigating'));
void tracker.invoke(
() => completeNavigation(target),
(error: unknown) => handleError(error, $t('error_while_navigating')),
() => eventManager.emit('ViewerFinishNavigate'),
);
};
/**
@@ -277,12 +278,10 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? asset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.DELETE:
@@ -291,7 +290,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
stack = action.stack ?? undefined;
if (stack) {
cursor.current = stack.assets[0];
}
@@ -348,39 +347,41 @@
const refresh = async () => {
await refreshStack();
ocrManager.clear();
if (!sharedLink) {
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
}
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => handlePromiseError(refresh()));
imageManager.preload(cursor.nextAsset);
imageManager.preload(cursor.previousAsset);
});
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
if (oldAssetId !== asset.id) {
if (sharedLink) {
return;
}
await new Promise((promise) => setTimeout(promise, 500));
await goto(Route.viewAsset({ id: newAssetId }));
};
const onAssetUpdate = (update: AssetResponseDto) => {
if (asset.id === update.id) {
cursor = { ...cursor, current: update };
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
cursor.current;
untrack(() => handlePromiseError(refresh()));
});
let lastCursor = $state<AssetCursor>();
$effect(() => {
if (cursor.current.id === lastCursor?.current.id) {
return;
}
if (lastCursor) {
selectedStackAsset = undefined;
previewStackedAsset = undefined;
preloadManager.updateAfterNavigation(lastCursor, cursor);
}
if (!lastCursor) {
preloadManager.initializePreloads(cursor);
}
lastCursor = cursor;
});
const viewerKind = $derived.by(() => {
if (previewStackedAsset) {
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
}
if (asset.type === AssetTypeEnum.Video) {
return 'VideoViewer';
@@ -424,7 +425,6 @@
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<svelte:document bind:fullscreenElement />
@@ -467,44 +467,36 @@
</div>
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
{/if}
<!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer
cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackVideoViewer'}
<VideoViewer
asset={previewStackedAsset!}
cursor={{ ...cursor, current: previewStackedAsset! }}
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoEnded={() => navigateAsset(getNavigationTarget())}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{asset}
{cursor}
assetId={asset.livePhotoVideoId!}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo}
/>
@@ -514,22 +506,20 @@
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
cursor={{ ...cursor, current: asset }}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{asset}
{cursor}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoEnded={() => navigateAsset(getNavigationTarget())}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
@@ -554,7 +544,7 @@
{/if}
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
@@ -582,10 +572,14 @@
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
<div
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)}
<div
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
class={['inline-block px-1 relative transition-all pb-2']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
>
<Thumbnail
@@ -594,21 +588,19 @@
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
cursor.current = stackedAsset;
selectedStackAsset = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
onMouseEvent={({ isMouseOver }) => isMouseOver && (previewStackedAsset = stackedAsset)}
readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
disableLinkMouseOver
/>
{#if stackedAsset.id === asset.id}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
</div>
{/if}
<div class="w-full flex place-items-center place-content-center">
<div class={['w-2 h-2 rounded-full flex mt-0.5', { 'bg-white': stackedAsset.id === asset.id }]}></div>
</div>
</div>
{/each}
</div>
@@ -3,7 +3,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -81,15 +81,20 @@
await getPeople();
});
$effect(() => {
const metrics = getContentMetrics(htmlElement);
const imageBoundingBox = {
top: metrics.offsetY,
left: metrics.offsetX,
width: metrics.contentWidth,
height: metrics.contentHeight,
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(() => {
const { offsetX, offsetY } = imageContentMetrics;
if (!canvas) {
return;
@@ -105,8 +110,8 @@
}
faceRect.set({
top: imageBoundingBox.top + 200,
left: imageBoundingBox.left + 200,
top: offsetY + 200,
left: offsetX + 200,
});
faceRect.setCoords();
@@ -214,13 +219,13 @@
}
const { left, top, width, height } = faceRect.getBoundingRect();
const metrics = getContentMetrics(htmlElement);
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
const natural = getNaturalSize(htmlElement);
const scaleX = natural.width / metrics.contentWidth;
const scaleY = natural.height / metrics.contentHeight;
const imageX = (left - metrics.offsetX) * scaleX;
const imageY = (top - metrics.offsetY) * scaleY;
const scaleX = natural.width / contentWidth;
const scaleY = natural.height / contentHeight;
const imageX = (left - offsetX) * scaleX;
const imageY = (top - offsetY) * scaleY;
return {
imageWidth: natural.width,
@@ -19,7 +19,7 @@
<div class="absolute left-0 top-0">
<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-all 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-colors 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;"
>
{ocrBox.text}
@@ -1,66 +1,57 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { thumbhash } from '$lib/actions/thumbhash';
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 OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-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 { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils';
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fromAction } from 'svelte/attachments';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { AssetCursor } from './asset-viewer.svelte';
interface Props {
cursor: AssetCursor;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
onPreviousAsset?: (() => void) | null;
onNextAsset?: (() => void) | null;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
onReady?: () => void;
onError?: () => void;
onSwipe?: (direction: 'left' | 'right') => void;
}
let {
cursor,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
}: Props = $props();
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
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 loader = $state<HTMLImageElement>();
let previousAssetId: string | undefined;
$effect.pre(() => {
void asset.id;
const id = asset.id;
if (id === previousAssetId) {
return;
}
previousAssetId = id;
untrack(() => {
assetViewerManager.resetZoomState();
visibleImageReady = false;
$boundingBoxesArray = [];
});
});
@@ -69,25 +60,30 @@
$boundingBoxesArray = [];
});
let containerWidth = $state(0);
let containerHeight = $state(0);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
const overlayMetrics = $derived.by((): ContentMetrics => {
if (!assetViewerManager.imgRef || !visibleImageReady) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container);
return {
contentWidth: contentWidth * currentZoom,
contentHeight: contentHeight * currentZoom,
offsetX: offsetX * currentZoom + currentPositionX,
offsetY: offsetY * currentZoom + currentPositionY,
contentWidth: scaled.width,
contentHeight: scaled.height,
offsetX: 0,
offsetY: 0,
};
});
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
let isOcrActive = $derived(ocrManager.showOverlay);
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
const onCopy = async () => {
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
@@ -124,29 +120,15 @@
handlePromiseError(onCopy());
};
const onSwipe = (event: SwipeCustomEvent) => {
if (assetViewerManager.zoom > 1) {
return;
}
let currentPreviewUrl = $state<string>();
if (ocrManager.showOverlay) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
onNextAsset();
}
if (onPreviousAsset && event.detail.direction === 'right') {
onPreviousAsset();
}
const onUrlChange = (url: string) => {
currentPreviewUrl = url;
};
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
$effect(() => {
if (imageLoaderUrl) {
void cast(imageLoaderUrl);
if (currentPreviewUrl) {
void cast(currentPreviewUrl);
}
});
@@ -164,36 +146,30 @@
}
};
const onload = () => {
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 }),
const blurredSlideshow = $derived(
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
);
let containerWidth = $state(0);
let containerHeight = $state(0);
let adaptiveImage = $state<HTMLDivElement | undefined>();
let lastUrl: string | undefined;
$effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) {
untrack(() => {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
visibleImageReady = false;
});
const faceToNameMap = $derived.by(() => {
// 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);
}
}
lastUrl = imageLoaderUrl;
return map;
});
const faces = $derived(Array.from(faceToNameMap.keys()));
let swipeFeedbackReset = $state<(() => void) | undefined>();
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
untrack(() => swipeFeedbackReset?.());
});
</script>
@@ -207,74 +183,110 @@
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
]}
/>
{#if imageError}
<div id="broken-asset" class="h-full w-full">
<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}
<SwipeFeedback
bind:element
class="relative h-full w-full select-none"
bind:clientWidth={containerWidth}
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 }))}
>
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !imageError}
<div
use:zoomImageAction={{ disabled: isOcrActive }}
{...useSwipe(onSwipe)}
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={imageLoaderUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
<AdaptiveImage
{asset}
{sharedLink}
{container}
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
{onUrlChange}
onImageReady={() => {
visibleImageReady = true;
onReady?.();
}}
onError={() => {
onError?.();
onReady?.();
}}
bind:imgRef={assetViewerManager.imgRef}
bind:ref={adaptiveImage}
>
{#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}
<img
bind:this={assetViewerManager.imgRef}
src={imageLoaderUrl}
onload={() => (visibleImageReady = true)}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox (boundingbox.id)}
{/snippet}
{#snippet overlays()}
{#if !isFaceEditMode.value && !ocrManager.showOverlay}
{#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)}
<button
type="button"
class="absolute pointer-events-auto outline-none"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
aria-label="{$t('person')}: {faceToNameMap.get(faces[index]) || $t('unknown')}"
onmouseenter={() => ($boundingBoxesArray = [faces[index]])}
onmouseleave={() => ($boundingBoxesArray = [])}
onfocus={() => ($boundingBoxesArray = [faces[index]])}
onblur={() => ($boundingBoxesArray = [])}
></button>
{/each}
{/if}
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
<div
class="absolute border-solid border-white border-3 rounded-lg"
class="absolute border-solid border-white border-3 rounded-lg pointer-events-none"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></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 ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{/snippet}
</AdaptiveImage>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#broken-asset,
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>
{#snippet leftPreview()}
{#if cursor.previousAsset}
<AdaptiveImage
asset={cursor.previousAsset}
{sharedLink}
{container}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
{#snippet rightPreview()}
{#if cursor.nextAsset}
<AdaptiveImage
asset={cursor.nextAsset}
{sharedLink}
{container}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
</SwipeFeedback>
@@ -0,0 +1,393 @@
<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>

Some files were not shown because too many files have changed in this diff Show More