mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fe63b70a6 | |||
| 6e9a425592 | |||
| 6012d22d98 | |||
| abfcffb423 | |||
| ec7246b86f | |||
| 9597f8c37f | |||
| 7b0deb1fd3 | |||
| 5ab05e57fa | |||
| ba3f114625 | |||
| 9b642633c1 | |||
| a05c8c6087 | |||
| 35a521c6ec | |||
| 09fabb36b6 | |||
| c259fee309 | |||
| 78ba9cbc63 | |||
| 33d75462c9 | |||
| e9451f10d6 | |||
| 480b7e8d65 | |||
| 228ac63ab9 | |||
| 7e9da945f6 | |||
| dd03c9c0a9 | |||
| 16e4a2b92a | |||
| 5caa7e1902 | |||
| 8279e1078a | |||
| 011ecbb43d | |||
| 2725c96cb1 | |||
| 3c476b1987 | |||
| 5989c9b4aa | |||
| 13c4260a1f | |||
| 54bc9ddd69 | |||
| f94e0fbc39 | |||
| 5532f669eb | |||
| e4c24bdec8 | |||
| 56f14162f6 | |||
| 8abbbc49cf | |||
| 4eb08eee18 | |||
| 0560f98c2d | |||
| 49ad411d50 | |||
| 2478cc40f4 | |||
| 44eeb1e088 | |||
| a868ae3ad0 | |||
| acac0d4f37 | |||
| 8c40a28fef | |||
| b2081eda1e | |||
| 9670c853c6 | |||
| cc2dacb308 | |||
| 15fc6b18f3 | |||
| a284e38890 | |||
| 05010c3a84 | |||
| 4da3d68a67 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --check .",
|
"format": "prettier --cache --check .",
|
||||||
"format:fix": "prettier --write ."
|
"format:fix": "prettier --cache --write --list-different ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.7.4"
|
"prettier": "^3.7.4"
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for breaking API changes
|
- name: Check for breaking API changes
|
||||||
# sha is pinning to a commit instead of a tag since the action does not tag versions
|
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
|
||||||
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
|
||||||
with:
|
with:
|
||||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||||
revision: open-api/immich-openapi-specs.json
|
revision: open-api/immich-openapi-specs.json
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
@@ -83,6 +83,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
Vendored
+8
-1
@@ -5,6 +5,13 @@
|
|||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"dart-code.flutter",
|
"dart-code.flutter",
|
||||||
"dart-code.dart-code",
|
"dart-code.dart-code",
|
||||||
"dcmdev.dcm-vscode-extension"
|
"dcmdev.dcm-vscode-extension",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"ms-playwright.playwright",
|
||||||
|
"vitest.explorer",
|
||||||
|
"editorconfig.editorconfig",
|
||||||
|
"foxundermoon.shell-format",
|
||||||
|
"timonwong.shellcheck",
|
||||||
|
"bluebrown.yamlfmt"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+35
-13
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"[css]": {
|
"[css]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
},
|
||||||
"[dart]": {
|
"[dart]": {
|
||||||
"editor.defaultFormatter": "Dart-Code.dart-code",
|
"editor.defaultFormatter": "Dart-Code.dart-code",
|
||||||
@@ -19,18 +18,15 @@
|
|||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
},
|
||||||
"[jsonc]": {
|
"[jsonc]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
},
|
||||||
"[svelte]": {
|
"[svelte]": {
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
@@ -38,8 +34,7 @@
|
|||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
"editor.defaultFormatter": "svelte.svelte-vscode",
|
"editor.defaultFormatter": "svelte.svelte-vscode",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
@@ -47,18 +42,45 @@
|
|||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
},
|
||||||
"cSpell.words": ["immich"],
|
"cSpell.words": ["immich"],
|
||||||
|
"css.lint.unknownAtRules": "ignore",
|
||||||
|
"editor.bracketPairColorization.enabled": true,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
"eslint.useFlatConfig": true,
|
||||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||||
|
"eslint.workingDirectories": [
|
||||||
|
{ "directory": "cli", "changeProcessCWD": true },
|
||||||
|
{ "directory": "e2e", "changeProcessCWD": true },
|
||||||
|
{ "directory": "server", "changeProcessCWD": true },
|
||||||
|
{ "directory": "web", "changeProcessCWD": true }
|
||||||
|
],
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/.jj/**": true,
|
||||||
|
"**/.git/**": true,
|
||||||
|
"**/node_modules/**": true,
|
||||||
|
"**/build/**": true,
|
||||||
|
"**/dist/**": true,
|
||||||
|
"**/.svelte-kit/**": true
|
||||||
|
},
|
||||||
"explorer.fileNesting.enabled": true,
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||||
},
|
},
|
||||||
|
"search.exclude": {
|
||||||
|
"**/node_modules": true,
|
||||||
|
"**/build": true,
|
||||||
|
"**/dist": true,
|
||||||
|
"**/.svelte-kit": true,
|
||||||
|
"**/open-api/typescript-sdk/src": true
|
||||||
|
},
|
||||||
"svelte.enable-ts-plugin": true,
|
"svelte.enable-ts-plugin": true,
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
"tailwindCSS.experimental.configFile": {
|
||||||
|
"web/src/app.css": "web/src/**"
|
||||||
|
},
|
||||||
|
"js/ts.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
"vitest.maximumConfigs": 10
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!
|
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
|
||||||
|
|
||||||
|
We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR.
|
||||||
|
|
||||||
## Use of generative AI
|
## Use of generative AI
|
||||||
|
|
||||||
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
|
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
|
||||||
|
|||||||
+5
-5
@@ -20,8 +20,8 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.14",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^4.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vite-tsconfig-paths": "^6.0.0",
|
"vite-tsconfig-paths": "^6.0.0",
|
||||||
"vitest": "^3.0.0",
|
"vitest": "^4.0.0",
|
||||||
"vitest-fetch-mock": "^0.4.0",
|
"vitest-fetch-mock": "^0.4.0",
|
||||||
"yaml": "^2.3.1"
|
"yaml": "^2.3.1"
|
||||||
},
|
},
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
"prepack": "pnpm run build",
|
"prepack": "pnpm run build",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:cov": "vitest --coverage",
|
"test:cov": "vitest --coverage",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --cache --check .",
|
||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --cache --write --list-different .",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import * as os from 'node:os';
|
import os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import path from 'node:path';
|
||||||
import { setTimeout as sleep } from 'node:timers/promises';
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ describe('uploadFiles', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns new assets when upload file is successful', async () => {
|
it('returns new assets when upload file is successful', async () => {
|
||||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
|
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
|
||||||
@@ -75,7 +75,7 @@ describe('uploadFiles', () => {
|
|||||||
|
|
||||||
it('returns new assets when upload file retry is successful', async () => {
|
it('returns new assets when upload file retry is successful', async () => {
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||||
counter++;
|
counter++;
|
||||||
if (counter < retry) {
|
if (counter < retry) {
|
||||||
throw new Error('Network error');
|
throw new Error('Network error');
|
||||||
@@ -96,7 +96,7 @@ describe('uploadFiles', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns new assets when upload file retry is failed', async () => {
|
it('returns new assets when upload file retry is failed', async () => {
|
||||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||||
throw new Error('Network error');
|
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 sleep(100); // to debounce the watcher from considering the test file as a existing file
|
||||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||||
|
|
||||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
await vi.waitFor(
|
||||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
() =>
|
||||||
assetBulkUploadCheckDto: {
|
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||||
assets: [
|
assetBulkUploadCheckDto: {
|
||||||
expect.objectContaining({
|
assets: [
|
||||||
id: testFilePath,
|
expect.objectContaining({
|
||||||
}),
|
id: testFilePath,
|
||||||
],
|
}),
|
||||||
},
|
],
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter out unsupported files', async () => {
|
it('should filter out unsupported files', async () => {
|
||||||
@@ -257,16 +260,19 @@ describe('startWatch', () => {
|
|||||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||||
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
|
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
|
||||||
|
|
||||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
await vi.waitFor(
|
||||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
() =>
|
||||||
assetBulkUploadCheckDto: {
|
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||||
assets: expect.arrayContaining([
|
assetBulkUploadCheckDto: {
|
||||||
expect.objectContaining({
|
assets: expect.arrayContaining([
|
||||||
id: testFilePath,
|
expect.objectContaining({
|
||||||
}),
|
id: testFilePath,
|
||||||
]),
|
}),
|
||||||
},
|
]),
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
|
||||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||||
assetBulkUploadCheckDto: {
|
assetBulkUploadCheckDto: {
|
||||||
@@ -291,16 +297,19 @@ describe('startWatch', () => {
|
|||||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||||
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
|
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
|
||||||
|
|
||||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
await vi.waitFor(
|
||||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
() =>
|
||||||
assetBulkUploadCheckDto: {
|
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||||
assets: expect.arrayContaining([
|
assetBulkUploadCheckDto: {
|
||||||
expect.objectContaining({
|
assets: expect.arrayContaining([
|
||||||
id: testFilePath,
|
expect.objectContaining({
|
||||||
}),
|
id: testFilePath,
|
||||||
]),
|
}),
|
||||||
},
|
]),
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
|
||||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||||
assetBulkUploadCheckDto: {
|
assetBulkUploadCheckDto: {
|
||||||
|
|||||||
+6
-2
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, UserConfig } from 'vite';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -17,4 +17,8 @@ export default defineConfig({
|
|||||||
noExternal: /^(?!node:).*$/,
|
noExternal: /^(?!node:).*$/,
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths()],
|
plugins: [tsconfigPaths()],
|
||||||
});
|
test: {
|
||||||
|
name: 'cli:unit',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
} as UserConfig);
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
terragrunt = "0.98.0"
|
terragrunt = "0.99.4"
|
||||||
opentofu = "1.11.4"
|
opentofu = "1.11.5"
|
||||||
|
|
||||||
[tasks."tg:fmt"]
|
[tasks."tg:fmt"]
|
||||||
run = "terragrunt hclfmt"
|
run = "terragrunt hclfmt"
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -85,7 +85,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||||
user: '1000:1000'
|
user: '1000:1000'
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ graph TD
|
|||||||
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
||||||
D --> E[Smart Search]
|
D --> E[Smart Search]
|
||||||
D --> F[Face Detection]
|
D --> F[Face Detection]
|
||||||
D --> G[Video Transcoding]
|
D --> G[OCR]
|
||||||
E --> H[Duplicate Detection]
|
D --> H[Video Transcoding]
|
||||||
F --> I[Facial Recognition]
|
E --> I[Duplicate Detection]
|
||||||
|
F --> J[Facial Recognition]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ Redis (Sentinel) URL example JSON before encoding:
|
|||||||
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
||||||
|
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
|
||||||
|
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
||||||
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
||||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"docusaurus": "docusaurus",
|
"docusaurus": "docusaurus",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --cache --check .",
|
||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --cache --write --list-different .",
|
||||||
"start": "docusaurus start --port 3005",
|
"start": "docusaurus start --port 3005",
|
||||||
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||||
"build": "pnpm run copy:openapi && docusaurus build",
|
"build": "pnpm run copy:openapi && docusaurus build",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich-e2e-redis
|
container_name: immich-e2e-redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -14,8 +14,8 @@
|
|||||||
"start:web": "pnpm exec playwright test --ui --project=web",
|
"start:web": "pnpm exec playwright test --ui --project=web",
|
||||||
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
||||||
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
|
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --cache --check .",
|
||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --cache --write --list-different .",
|
||||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||||
"lint:fix": "pnpm run lint --fix",
|
"lint:fix": "pnpm run lint --fix",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
"@eslint/js": "^10.0.0",
|
"@eslint/js": "^10.0.0",
|
||||||
"@faker-js/faker": "^10.1.0",
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@immich/cli": "workspace:*",
|
"@immich/cli": "workspace:*",
|
||||||
"@immich/e2e-auth-server": "workspace:*",
|
"@immich/e2e-auth-server": "workspace:*",
|
||||||
"@immich/sdk": "workspace:*",
|
"@immich/sdk": "workspace:*",
|
||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.14",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
@@ -54,7 +54,8 @@
|
|||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"utimes": "^5.2.1",
|
"utimes": "^5.2.1",
|
||||||
"vitest": "^3.0.0"
|
"vite-tsconfig-paths": "^6.1.1",
|
||||||
|
"vitest": "^4.0.0"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.1"
|
"node": "24.13.1"
|
||||||
|
|||||||
+1
-1
@@ -17,6 +17,6 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"baseUrl": "./"
|
"baseUrl": "./"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts", "vitest*.config.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||||
@@ -14,15 +15,14 @@ if (!skipDockerSetup) {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
|
name: 'e2e:server',
|
||||||
retry: process.env.CI ? 4 : 0,
|
retry: process.env.CI ? 4 : 0,
|
||||||
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
||||||
globalSetup,
|
globalSetup,
|
||||||
testTimeout: 15_000,
|
testTimeout: 15_000,
|
||||||
pool: 'threads',
|
pool: 'threads',
|
||||||
poolOptions: {
|
maxWorkers: 1,
|
||||||
threads: {
|
isolate: false,
|
||||||
singleThread: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||||
@@ -14,15 +15,14 @@ if (!skipDockerSetup) {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
|
name: 'e2e:maintenance',
|
||||||
retry: process.env.CI ? 4 : 0,
|
retry: process.env.CI ? 4 : 0,
|
||||||
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
|
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
|
||||||
globalSetup,
|
globalSetup,
|
||||||
testTimeout: 15_000,
|
testTimeout: 15_000,
|
||||||
pool: 'threads',
|
pool: 'threads',
|
||||||
poolOptions: {
|
maxWorkers: 1,
|
||||||
threads: {
|
isolate: false,
|
||||||
singleThread: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-5
@@ -411,7 +411,7 @@
|
|||||||
"transcoding_tone_mapping": "Tone-mapping",
|
"transcoding_tone_mapping": "Tone-mapping",
|
||||||
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
|
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
|
||||||
"transcoding_transcode_policy": "Transcode policy",
|
"transcoding_transcode_policy": "Transcode policy",
|
||||||
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).",
|
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos and videos with a pixel format other than YUV 4:2:0 will always be transcoded (except if transcoding is disabled).",
|
||||||
"transcoding_two_pass_encoding": "Two-pass encoding",
|
"transcoding_two_pass_encoding": "Two-pass encoding",
|
||||||
"transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
|
"transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
|
||||||
"transcoding_video_codec": "Video codec",
|
"transcoding_video_codec": "Video codec",
|
||||||
@@ -871,8 +871,8 @@
|
|||||||
"current_pin_code": "Current PIN code",
|
"current_pin_code": "Current PIN code",
|
||||||
"current_server_address": "Current server address",
|
"current_server_address": "Current server address",
|
||||||
"custom_date": "Custom date",
|
"custom_date": "Custom date",
|
||||||
"custom_locale": "Custom Locale",
|
"custom_locale": "Custom locale",
|
||||||
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
|
||||||
"custom_url": "Custom URL",
|
"custom_url": "Custom URL",
|
||||||
"cutoff_date_description": "Keep photos from the last…",
|
"cutoff_date_description": "Keep photos from the last…",
|
||||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||||
@@ -895,8 +895,6 @@
|
|||||||
"deduplication_criteria_2": "Count of EXIF data",
|
"deduplication_criteria_2": "Count of EXIF data",
|
||||||
"deduplication_info": "Deduplication Info",
|
"deduplication_info": "Deduplication Info",
|
||||||
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
||||||
"default_locale": "Default Locale",
|
|
||||||
"default_locale_description": "Format dates and numbers based on your browser locale",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
||||||
"delete_action_prompt": "{count} deleted",
|
"delete_action_prompt": "{count} deleted",
|
||||||
@@ -2338,6 +2336,8 @@
|
|||||||
"url": "URL",
|
"url": "URL",
|
||||||
"usage": "Usage",
|
"usage": "Usage",
|
||||||
"use_biometric": "Use biometric",
|
"use_biometric": "Use biometric",
|
||||||
|
"use_browser_locale": "Use browser locale",
|
||||||
|
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
||||||
"use_current_connection": "Use current connection",
|
"use_current_connection": "Use current connection",
|
||||||
"use_custom_date_range": "Use custom date range instead",
|
"use_custom_date_range": "Use custom date range instead",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@
|
|||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --check .",
|
"format": "prettier --cache --check .",
|
||||||
"format:fix": "prettier --write ."
|
"format:fix": "prettier --cache --write --list-different ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
|
|||||||
@@ -48,8 +48,11 @@ class PreloadModelData(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MaxBatchSize(BaseModel):
|
class MaxBatchSize(BaseModel):
|
||||||
|
ocr_fallback: str | None = os.getenv("MACHINE_LEARNING_MAX_BATCH_SIZE__TEXT_RECOGNITION", None)
|
||||||
|
if ocr_fallback is not None:
|
||||||
|
os.environ["MACHINE_LEARNING_MAX_BATCH_SIZE__OCR"] = ocr_fallback
|
||||||
facial_recognition: int | None = None
|
facial_recognition: int | None = None
|
||||||
text_recognition: int | None = None
|
ocr: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class FaceRecognizer(InferenceModel):
|
|||||||
|
|
||||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
super().__init__(model_name, **model_kwargs)
|
super().__init__(model_name, **model_kwargs)
|
||||||
max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None
|
max_batch_size = settings.max_batch_size and settings.max_batch_size.facial_recognition
|
||||||
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default
|
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default
|
||||||
|
|
||||||
def _load(self) -> ModelSession:
|
def _load(self) -> ModelSession:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class TextDetector(InferenceModel):
|
|||||||
depends = []
|
depends = []
|
||||||
identity = (ModelType.DETECTION, ModelTask.OCR)
|
identity = (ModelType.DETECTION, ModelTask.OCR)
|
||||||
|
|
||||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, min_score: float = 0.5, **model_kwargs: Any) -> None:
|
||||||
super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
|
super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
|
||||||
self.max_resolution = 736
|
self.max_resolution = 736
|
||||||
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
||||||
@@ -33,7 +33,7 @@ class TextDetector(InferenceModel):
|
|||||||
}
|
}
|
||||||
self.postprocess = DBPostProcess(
|
self.postprocess = DBPostProcess(
|
||||||
thresh=0.3,
|
thresh=0.3,
|
||||||
box_thresh=model_kwargs.get("minScore", 0.5),
|
box_thresh=model_kwargs.get("minScore", min_score),
|
||||||
max_candidates=1000,
|
max_candidates=1000,
|
||||||
unclip_ratio=1.6,
|
unclip_ratio=1.6,
|
||||||
use_dilation=True,
|
use_dilation=True,
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ class TextRecognizer(InferenceModel):
|
|||||||
depends = [(ModelType.DETECTION, ModelTask.OCR)]
|
depends = [(ModelType.DETECTION, ModelTask.OCR)]
|
||||||
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
||||||
|
|
||||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, min_score: float = 0.9, **model_kwargs: Any) -> None:
|
||||||
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
|
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
|
||||||
self.min_score = model_kwargs.get("minScore", 0.9)
|
self.min_score = model_kwargs.get("minScore", min_score)
|
||||||
self._empty: TextRecognitionOutput = {
|
self._empty: TextRecognitionOutput = {
|
||||||
"box": np.empty(0, dtype=np.float32),
|
"box": np.empty(0, dtype=np.float32),
|
||||||
"boxScore": np.empty(0, dtype=np.float32),
|
"boxScore": np.empty(0, dtype=np.float32),
|
||||||
@@ -57,10 +57,11 @@ class TextRecognizer(InferenceModel):
|
|||||||
def _load(self) -> ModelSession:
|
def _load(self) -> ModelSession:
|
||||||
# TODO: support other runtimes
|
# TODO: support other runtimes
|
||||||
session = OrtSession(self.model_path)
|
session = OrtSession(self.model_path)
|
||||||
|
max_batch_size = settings.max_batch_size and settings.max_batch_size.ocr
|
||||||
self.model = RapidTextRecognizer(
|
self.model = RapidTextRecognizer(
|
||||||
OcrOptions(
|
OcrOptions(
|
||||||
session=session.session,
|
session=session.session,
|
||||||
rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6,
|
rec_batch_num=max_batch_size if max_batch_size else 6,
|
||||||
rec_img_shape=(3, 48, 320),
|
rec_img_shape=(3, 48, 320),
|
||||||
lang_type=self.language,
|
lang_type=self.language,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from PIL import Image
|
|||||||
from pytest import MonkeyPatch
|
from pytest import MonkeyPatch
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from immich_ml.config import Settings, settings
|
from immich_ml.config import MaxBatchSize, Settings, settings
|
||||||
from immich_ml.main import load, preload_models
|
from immich_ml.main import load, preload_models
|
||||||
from immich_ml.models.base import InferenceModel
|
from immich_ml.models.base import InferenceModel
|
||||||
from immich_ml.models.cache import ModelCache
|
from immich_ml.models.cache import ModelCache
|
||||||
@@ -26,6 +26,9 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn
|
|||||||
from immich_ml.models.clip.visual import OpenClipVisualEncoder
|
from immich_ml.models.clip.visual import OpenClipVisualEncoder
|
||||||
from immich_ml.models.facial_recognition.detection import FaceDetector
|
from immich_ml.models.facial_recognition.detection import FaceDetector
|
||||||
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
|
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
|
||||||
|
from immich_ml.models.ocr.detection import TextDetector
|
||||||
|
from immich_ml.models.ocr.recognition import TextRecognizer
|
||||||
|
from immich_ml.models.ocr.schemas import OcrOptions
|
||||||
from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
|
from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
|
||||||
from immich_ml.sessions.ann import AnnSession
|
from immich_ml.sessions.ann import AnnSession
|
||||||
from immich_ml.sessions.ort import OrtSession
|
from immich_ml.sessions.ort import OrtSession
|
||||||
@@ -855,6 +858,78 @@ class TestFaceRecognition:
|
|||||||
onnx.load.assert_not_called()
|
onnx.load.assert_not_called()
|
||||||
onnx.save.assert_not_called()
|
onnx.save.assert_not_called()
|
||||||
|
|
||||||
|
def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None:
|
||||||
|
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2))
|
||||||
|
|
||||||
|
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
|
||||||
|
|
||||||
|
assert recognizer.batch_size == 2
|
||||||
|
|
||||||
|
def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None:
|
||||||
|
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2))
|
||||||
|
|
||||||
|
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
|
||||||
|
|
||||||
|
assert recognizer.batch_size is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestOcr:
|
||||||
|
def test_set_det_min_score(self, path: mock.Mock) -> None:
|
||||||
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
||||||
|
|
||||||
|
text_detector = TextDetector("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
|
||||||
|
|
||||||
|
assert text_detector.postprocess.box_thresh == 0.8
|
||||||
|
|
||||||
|
def test_set_rec_min_score(self, path: mock.Mock) -> None:
|
||||||
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
||||||
|
|
||||||
|
text_recognizer = TextRecognizer("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
|
||||||
|
|
||||||
|
assert text_recognizer.min_score == 0.8
|
||||||
|
|
||||||
|
def test_set_rec_set_default_max_batch_size(
|
||||||
|
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
||||||
|
) -> None:
|
||||||
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
||||||
|
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
||||||
|
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
||||||
|
|
||||||
|
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
||||||
|
text_recognizer.load()
|
||||||
|
|
||||||
|
rapid_recognizer.assert_called_once_with(
|
||||||
|
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
|
||||||
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
||||||
|
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
||||||
|
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
||||||
|
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=4))
|
||||||
|
|
||||||
|
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
||||||
|
text_recognizer.load()
|
||||||
|
|
||||||
|
rapid_recognizer.assert_called_once_with(
|
||||||
|
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ignore_other_custom_max_batch_size(
|
||||||
|
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
||||||
|
) -> None:
|
||||||
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
||||||
|
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
||||||
|
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
||||||
|
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=3))
|
||||||
|
|
||||||
|
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
||||||
|
text_recognizer.load()
|
||||||
|
|
||||||
|
rapid_recognizer.assert_called_once_with(
|
||||||
|
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
class TestCache:
|
class TestCache:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ config_roots = [
|
|||||||
[tools]
|
[tools]
|
||||||
node = "24.13.1"
|
node = "24.13.1"
|
||||||
flutter = "3.35.7"
|
flutter = "3.35.7"
|
||||||
pnpm = "10.30.0"
|
pnpm = "10.30.3"
|
||||||
terragrunt = "0.98.0"
|
terragrunt = "0.99.4"
|
||||||
opentofu = "1.11.4"
|
opentofu = "1.11.5"
|
||||||
java = "21.0.2"
|
java = "21.0.2"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"]
|
[tools."github:CQLabs/homebrew-dcm"]
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ linter:
|
|||||||
require_trailing_commas: true
|
require_trailing_commas: true
|
||||||
unrelated_type_equality_checks: true
|
unrelated_type_equality_checks: true
|
||||||
prefer_const_constructors: true
|
prefer_const_constructors: true
|
||||||
|
always_use_package_imports: true
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ android {
|
|||||||
|
|
||||||
release {
|
release {
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
namespace 'app.alextran.immich'
|
namespace 'app.alextran.immich'
|
||||||
|
|||||||
+8
@@ -37,3 +37,11 @@
|
|||||||
|
|
||||||
# Keep all widget model classes and their fields for Gson
|
# Keep all widget model classes and their fields for Gson
|
||||||
-keep class app.alextran.immich.widget.model.** { *; }
|
-keep class app.alextran.immich.widget.model.** { *; }
|
||||||
|
|
||||||
|
##---------------Begin: proguard configuration for ok_http JNI ----------
|
||||||
|
# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI
|
||||||
|
# string-based reflection (JClass.forName), which R8 cannot trace.
|
||||||
|
-keep class okhttp3.** { *; }
|
||||||
|
-keep class okio.** { *; }
|
||||||
|
-keep class com.example.ok_http.** { *; }
|
||||||
|
##---------------End: proguard configuration for ok_http JNI ----------
|
||||||
|
|||||||
@@ -36,3 +36,17 @@ Java_app_alextran_immich_NativeBuffer_copy(
|
|||||||
memcpy((void *) destAddress, (char *) src + offset, length);
|
memcpy((void *) destAddress, (char *) src + offset, length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a JNI global reference to the given object and returns its address.
|
||||||
|
* The caller is responsible for deleting the global reference when it's no longer needed.
|
||||||
|
*/
|
||||||
|
JNIEXPORT jlong JNICALL
|
||||||
|
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
|
||||||
|
if (obj == NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobject globalRef = (*env)->NewGlobalRef(env, obj);
|
||||||
|
return (jlong) globalRef;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ object NativeBuffer {
|
|||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
|
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
external fun createGlobalRef(obj: Any): Long
|
||||||
}
|
}
|
||||||
|
|
||||||
class NativeByteBuffer(initialCapacity: Int) {
|
class NativeByteBuffer(initialCapacity: Int) {
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
package app.alextran.immich.core
|
package app.alextran.immich.core
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.security.KeyChain
|
||||||
|
import androidx.core.content.edit
|
||||||
import app.alextran.immich.BuildConfig
|
import app.alextran.immich.BuildConfig
|
||||||
|
import app.alextran.immich.NativeBuffer
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.ConnectionPool
|
import okhttp3.ConnectionPool
|
||||||
import okhttp3.Dispatcher
|
import okhttp3.Dispatcher
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.Credentials
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import org.json.JSONObject
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
@@ -20,8 +27,12 @@ import javax.net.ssl.TrustManagerFactory
|
|||||||
import javax.net.ssl.X509KeyManager
|
import javax.net.ssl.X509KeyManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
const val CERT_ALIAS = "client_cert"
|
|
||||||
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
|
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
|
||||||
|
private const val CERT_ALIAS = "client_cert"
|
||||||
|
private const val PREFS_NAME = "immich.ssl"
|
||||||
|
private const val PREFS_CERT_ALIAS = "immich.client_cert"
|
||||||
|
private const val PREFS_HEADERS = "immich.request_headers"
|
||||||
|
private const val PREFS_SERVER_URL = "immich.server_url"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages a shared OkHttpClient with SSL configuration support.
|
* Manages a shared OkHttpClient with SSL configuration support.
|
||||||
@@ -36,22 +47,56 @@ object HttpClientManager {
|
|||||||
private val clientChangedListeners = mutableListOf<() -> Unit>()
|
private val clientChangedListeners = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
private lateinit var client: OkHttpClient
|
private lateinit var client: OkHttpClient
|
||||||
|
private lateinit var appContext: Context
|
||||||
|
private lateinit var prefs: SharedPreferences
|
||||||
|
|
||||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
|
||||||
val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS)
|
var keyChainAlias: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
var headers: Headers = Headers.headersOf()
|
||||||
|
private set
|
||||||
|
|
||||||
|
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
fun initialize(context: Context) {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
|
|
||||||
|
appContext = context.applicationContext
|
||||||
|
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
|
||||||
|
|
||||||
|
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
|
||||||
|
if (savedHeaders != null) {
|
||||||
|
val json = JSONObject(savedHeaders)
|
||||||
|
val builder = Headers.Builder()
|
||||||
|
for (key in json.keys()) {
|
||||||
|
builder.add(key, json.getString(key))
|
||||||
|
}
|
||||||
|
headers = builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
||||||
client = build(cacheDir)
|
client = build(cacheDir)
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setKeyChainAlias(alias: String) {
|
||||||
|
synchronized(this) {
|
||||||
|
val wasMtls = isMtls
|
||||||
|
keyChainAlias = alias
|
||||||
|
prefs.edit { putString(PREFS_CERT_ALIAS, alias) }
|
||||||
|
|
||||||
|
if (wasMtls != isMtls) {
|
||||||
|
clientChangedListeners.forEach { it() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setKeyEntry(clientData: ByteArray, password: CharArray) {
|
fun setKeyEntry(clientData: ByteArray, password: CharArray) {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
val wasMtls = isMtls
|
val wasMtls = isMtls
|
||||||
@@ -63,7 +108,7 @@ object HttpClientManager {
|
|||||||
val key = tmpKeyStore.getKey(tmpAlias, password)
|
val key = tmpKeyStore.getKey(tmpAlias, password)
|
||||||
val chain = tmpKeyStore.getCertificateChain(tmpAlias)
|
val chain = tmpKeyStore.getCertificateChain(tmpAlias)
|
||||||
|
|
||||||
if (wasMtls) {
|
if (keyStore.containsAlias(CERT_ALIAS)) {
|
||||||
keyStore.deleteEntry(CERT_ALIAS)
|
keyStore.deleteEntry(CERT_ALIAS)
|
||||||
}
|
}
|
||||||
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
|
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
|
||||||
@@ -75,24 +120,58 @@ object HttpClientManager {
|
|||||||
|
|
||||||
fun deleteKeyEntry() {
|
fun deleteKeyEntry() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (!isMtls) {
|
val wasMtls = isMtls
|
||||||
return
|
|
||||||
|
if (keyChainAlias != null) {
|
||||||
|
keyChainAlias = null
|
||||||
|
prefs.edit { remove(PREFS_CERT_ALIAS) }
|
||||||
}
|
}
|
||||||
|
|
||||||
keyStore.deleteEntry(CERT_ALIAS)
|
keyStore.deleteEntry(CERT_ALIAS)
|
||||||
clientChangedListeners.forEach { it() }
|
|
||||||
|
if (wasMtls) {
|
||||||
|
clientChangedListeners.forEach { it() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var clientGlobalRef: Long = 0L
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getClient(): OkHttpClient {
|
fun getClient(): OkHttpClient {
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getClientPointer(): Long {
|
||||||
|
if (clientGlobalRef == 0L) {
|
||||||
|
clientGlobalRef = NativeBuffer.createGlobalRef(client)
|
||||||
|
}
|
||||||
|
return clientGlobalRef
|
||||||
|
}
|
||||||
|
|
||||||
fun addClientChangedListener(listener: () -> Unit) {
|
fun addClientChangedListener(listener: () -> Unit) {
|
||||||
synchronized(this) { clientChangedListeners.add(listener) }
|
synchronized(this) { clientChangedListeners.add(listener) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>) {
|
||||||
|
synchronized(this) {
|
||||||
|
val builder = Headers.Builder()
|
||||||
|
headerMap.forEach { (key, value) -> builder[key] = value }
|
||||||
|
val newHeaders = builder.build()
|
||||||
|
val headersChanged = headers != newHeaders
|
||||||
|
val newUrl = serverUrls.firstOrNull()
|
||||||
|
val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null)
|
||||||
|
if (!headersChanged && !urlChanged) return
|
||||||
|
headers = newHeaders
|
||||||
|
prefs.edit {
|
||||||
|
if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString())
|
||||||
|
if (urlChanged) {
|
||||||
|
if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun build(cacheDir: File): OkHttpClient {
|
private fun build(cacheDir: File): OkHttpClient {
|
||||||
val connectionPool = ConnectionPool(
|
val connectionPool = ConnectionPool(
|
||||||
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
||||||
@@ -109,8 +188,16 @@ object HttpClientManager {
|
|||||||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||||
|
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
.addInterceptor { chain ->
|
.addInterceptor {
|
||||||
chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build())
|
val request = it.request()
|
||||||
|
val builder = request.newBuilder()
|
||||||
|
builder.header("User-Agent", USER_AGENT)
|
||||||
|
headers.forEach { (key, value) -> builder.header(key, value) }
|
||||||
|
val url = request.url
|
||||||
|
if (url.username.isNotEmpty()) {
|
||||||
|
builder.header("Authorization", Credentials.basic(url.username, url.password))
|
||||||
|
}
|
||||||
|
it.proceed(builder.build())
|
||||||
}
|
}
|
||||||
.connectionPool(connectionPool)
|
.connectionPool(connectionPool)
|
||||||
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
|
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
|
||||||
@@ -119,23 +206,39 @@ object HttpClientManager {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reads from the key store rather than taking a snapshot at initialization time
|
/**
|
||||||
|
* Resolves client certificates dynamically at TLS handshake time.
|
||||||
|
* Checks the system KeyChain alias first, then falls back to the app's private KeyStore.
|
||||||
|
*/
|
||||||
private class DynamicKeyManager : X509KeyManager {
|
private class DynamicKeyManager : X509KeyManager {
|
||||||
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
|
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? {
|
||||||
if (isMtls) arrayOf(CERT_ALIAS) else null
|
val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null
|
||||||
|
return arrayOf(alias)
|
||||||
|
}
|
||||||
|
|
||||||
override fun chooseClientAlias(
|
override fun chooseClientAlias(
|
||||||
keyTypes: Array<String>,
|
keyTypes: Array<String>,
|
||||||
issuers: Array<Principal>?,
|
issuers: Array<Principal>?,
|
||||||
socket: Socket?
|
socket: Socket?
|
||||||
): String? =
|
): String? {
|
||||||
if (isMtls) CERT_ALIAS else null
|
keyChainAlias?.let { return it }
|
||||||
|
if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
override fun getCertificateChain(alias: String): Array<X509Certificate>? =
|
override fun getCertificateChain(alias: String): Array<X509Certificate>? {
|
||||||
keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
|
if (alias == keyChainAlias) {
|
||||||
|
return KeyChain.getCertificateChain(appContext, alias)
|
||||||
|
}
|
||||||
|
return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
|
||||||
|
}
|
||||||
|
|
||||||
override fun getPrivateKey(alias: String): PrivateKey? =
|
override fun getPrivateKey(alias: String): PrivateKey? {
|
||||||
keyStore.getKey(alias, null) as? PrivateKey
|
if (alias == keyChainAlias) {
|
||||||
|
return KeyChain.getPrivateKey(appContext, alias)
|
||||||
|
}
|
||||||
|
return keyStore.getKey(alias, null) as? PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
|
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -180,8 +180,11 @@ private open class NetworkPigeonCodec : StandardMessageCodec() {
|
|||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface NetworkApi {
|
interface NetworkApi {
|
||||||
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
|
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
|
||||||
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit)
|
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit)
|
||||||
fun removeCertificate(callback: (Result<Unit>) -> Unit)
|
fun removeCertificate(callback: (Result<Unit>) -> Unit)
|
||||||
|
fun hasCertificate(): Boolean
|
||||||
|
fun getClientPointer(): Long
|
||||||
|
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NetworkApi. */
|
/** The codec used by NetworkApi. */
|
||||||
@@ -217,13 +220,12 @@ interface NetworkApi {
|
|||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val promptTextArg = args[0] as ClientCertPrompt
|
val promptTextArg = args[0] as ClientCertPrompt
|
||||||
api.selectCertificate(promptTextArg) { result: Result<ClientCertData> ->
|
api.selectCertificate(promptTextArg) { result: Result<Unit> ->
|
||||||
val error = result.exceptionOrNull()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(NetworkPigeonUtils.wrapError(error))
|
reply.reply(NetworkPigeonUtils.wrapError(error))
|
||||||
} else {
|
} else {
|
||||||
val data = result.getOrNull()
|
reply.reply(NetworkPigeonUtils.wrapResult(null))
|
||||||
reply.reply(NetworkPigeonUtils.wrapResult(data))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,6 +250,55 @@ interface NetworkApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.hasCertificate())
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
NetworkPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.getClientPointer())
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
NetworkPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val headersArg = args[0] as Map<String, String>
|
||||||
|
val serverUrlsArg = args[1] as List<String>
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
api.setRequestHeaders(headersArg, serverUrlsArg)
|
||||||
|
listOf(null)
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
NetworkPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,9 @@ package app.alextran.immich.core
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
import android.text.InputType
|
import android.security.KeyChain
|
||||||
import android.view.ContextThemeWrapper
|
import app.alextran.immich.NativeBuffer
|
||||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
@@ -24,7 +13,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
|
|||||||
private var networkApi: NetworkApiImpl? = null
|
private var networkApi: NetworkApiImpl? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
networkApi = NetworkApiImpl(binding.applicationContext)
|
networkApi = NetworkApiImpl()
|
||||||
NetworkApi.setUp(binding.binaryMessenger, networkApi)
|
NetworkApi.setUp(binding.binaryMessenger, networkApi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,48 +23,24 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
networkApi?.onAttachedToActivity(binding)
|
networkApi?.activity = binding.activity
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivityForConfigChanges() {
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
networkApi?.onDetachedFromActivityForConfigChanges()
|
networkApi?.activity = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
networkApi?.onReattachedToActivityForConfigChanges(binding)
|
networkApi?.activity = binding.activity
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivity() {
|
override fun onDetachedFromActivity() {
|
||||||
networkApi?.onDetachedFromActivity()
|
networkApi?.activity = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NetworkApiImpl(private val context: Context) : NetworkApi {
|
private class NetworkApiImpl() : NetworkApi {
|
||||||
private var activity: Activity? = null
|
var activity: Activity? = null
|
||||||
private var pendingCallback: ((Result<ClientCertData>) -> Unit)? = null
|
|
||||||
private var filePicker: ActivityResultLauncher<Array<String>>? = null
|
|
||||||
private var promptText: ClientCertPrompt? = null
|
|
||||||
|
|
||||||
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
|
||||||
activity = binding.activity
|
|
||||||
(binding.activity as? ComponentActivity)?.let { componentActivity ->
|
|
||||||
filePicker = componentActivity.registerForActivityResult(
|
|
||||||
ActivityResultContracts.OpenDocument()
|
|
||||||
) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDetachedFromActivityForConfigChanges() {
|
|
||||||
activity = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
|
||||||
activity = binding.activity
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDetachedFromActivity() {
|
|
||||||
activity = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
|
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
|
||||||
try {
|
try {
|
||||||
@@ -86,11 +51,19 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit) {
|
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit) {
|
||||||
val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity")))
|
val currentActivity = activity
|
||||||
pendingCallback = callback
|
?: return callback(Result.failure(IllegalStateException("No activity")))
|
||||||
this.promptText = promptText
|
|
||||||
picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file"))
|
val onAlias = { alias: String? ->
|
||||||
|
if (alias != null) {
|
||||||
|
HttpClientManager.setKeyChainAlias(alias)
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(OperationCanceledException()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyChain.choosePrivateKeyAlias(currentActivity, onAlias, null, null, null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
|
override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
|
||||||
@@ -98,62 +71,15 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi {
|
|||||||
callback(Result.success(Unit))
|
callback(Result.success(Unit))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handlePickedFile(uri: Uri) {
|
override fun hasCertificate(): Boolean {
|
||||||
val callback = pendingCallback ?: return
|
return HttpClientManager.isMtls
|
||||||
pendingCallback = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
|
||||||
?: throw IllegalStateException("Could not read file")
|
|
||||||
|
|
||||||
val activity = activity ?: throw IllegalStateException("No activity")
|
|
||||||
promptForPassword(activity) { password ->
|
|
||||||
promptText = null
|
|
||||||
if (password == null) {
|
|
||||||
callback(Result.failure(OperationCanceledException()))
|
|
||||||
return@promptForPassword
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
HttpClientManager.setKeyEntry(data, password.toCharArray())
|
|
||||||
callback(Result.success(ClientCertData(data, password)))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
callback(Result.failure(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
callback(Result.failure(e))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) {
|
override fun getClientPointer(): Long {
|
||||||
val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog)
|
return HttpClientManager.getClientPointer()
|
||||||
val density = activity.resources.displayMetrics.density
|
}
|
||||||
val horizontalPadding = (24 * density).toInt()
|
|
||||||
|
|
||||||
val textInputLayout = TextInputLayout(themedContext).apply {
|
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>) {
|
||||||
hint = "Password"
|
HttpClientManager.setRequestHeaders(headers, serverUrls)
|
||||||
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
|
|
||||||
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
|
|
||||||
setMargins(horizontalPadding, 0, horizontalPadding, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val editText = TextInputEditText(textInputLayout.context).apply {
|
|
||||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
|
||||||
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
|
||||||
}
|
|
||||||
textInputLayout.addView(editText)
|
|
||||||
|
|
||||||
val container = FrameLayout(themedContext).apply { addView(textInputLayout) }
|
|
||||||
|
|
||||||
val text = promptText!!
|
|
||||||
MaterialAlertDialogBuilder(themedContext)
|
|
||||||
.setTitle(text.title)
|
|
||||||
.setMessage(text.message)
|
|
||||||
.setView(container)
|
|
||||||
.setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) }
|
|
||||||
.setNegativeButton(text.cancel) { _, _ -> callback(null) }
|
|
||||||
.setOnCancelListener { callback(null) }
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
|
|||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface RemoteImageApi {
|
interface RemoteImageApi {
|
||||||
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||||
fun cancelRequest(requestId: Long)
|
fun cancelRequest(requestId: Long)
|
||||||
fun clearCache(callback: (Result<Long>) -> Unit)
|
fun clearCache(callback: (Result<Long>) -> Unit)
|
||||||
|
|
||||||
@@ -66,10 +66,9 @@ interface RemoteImageApi {
|
|||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val urlArg = args[0] as String
|
val urlArg = args[0] as String
|
||||||
val headersArg = args[1] as Map<String, String>
|
val requestIdArg = args[1] as Long
|
||||||
val requestIdArg = args[2] as Long
|
val preferEncodedArg = args[2] as Boolean
|
||||||
val preferEncodedArg = args[3] as Boolean
|
api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
||||||
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
|
||||||
val error = result.exceptionOrNull()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import okhttp3.Callback
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import org.chromium.net.CronetEngine
|
import org.chromium.net.CronetEngine
|
||||||
import org.chromium.net.CronetException
|
import org.chromium.net.CronetException
|
||||||
import org.chromium.net.UrlRequest
|
import org.chromium.net.UrlRequest
|
||||||
@@ -49,7 +51,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
|
|
||||||
override fun requestImage(
|
override fun requestImage(
|
||||||
url: String,
|
url: String,
|
||||||
headers: Map<String, String>,
|
|
||||||
requestId: Long,
|
requestId: Long,
|
||||||
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
||||||
callback: (Result<Map<String, Long>?>) -> Unit
|
callback: (Result<Map<String, Long>?>) -> Unit
|
||||||
@@ -59,7 +60,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
|
|
||||||
ImageFetcherManager.fetch(
|
ImageFetcherManager.fetch(
|
||||||
url,
|
url,
|
||||||
headers,
|
|
||||||
signal,
|
signal,
|
||||||
onSuccess = { buffer ->
|
onSuccess = { buffer ->
|
||||||
requestMap.remove(requestId)
|
requestMap.remove(requestId)
|
||||||
@@ -120,12 +120,11 @@ private object ImageFetcherManager {
|
|||||||
|
|
||||||
fun fetch(
|
fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
headers: Map<String, String>,
|
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
) {
|
) {
|
||||||
fetcher.fetch(url, headers, signal, onSuccess, onFailure)
|
fetcher.fetch(url, signal, onSuccess, onFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearCache(onCleared: (Result<Long>) -> Unit) {
|
fun clearCache(onCleared: (Result<Long>) -> Unit) {
|
||||||
@@ -152,7 +151,6 @@ private object ImageFetcherManager {
|
|||||||
private sealed interface ImageFetcher {
|
private sealed interface ImageFetcher {
|
||||||
fun fetch(
|
fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
headers: Map<String, String>,
|
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
@@ -179,7 +177,6 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
|
|||||||
|
|
||||||
override fun fetch(
|
override fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
headers: Map<String, String>,
|
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
@@ -194,7 +191,12 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
|
|||||||
|
|
||||||
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
||||||
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
||||||
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||||
|
url.toHttpUrlOrNull()?.let { httpUrl ->
|
||||||
|
if (httpUrl.username.isNotEmpty()) {
|
||||||
|
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
|
||||||
|
}
|
||||||
|
}
|
||||||
val request = requestBuilder.build()
|
val request = requestBuilder.build()
|
||||||
signal.setOnCancelListener(request::cancel)
|
signal.setOnCancelListener(request::cancel)
|
||||||
request.start()
|
request.start()
|
||||||
@@ -391,7 +393,6 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
|
|
||||||
override fun fetch(
|
override fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
headers: Map<String, String>,
|
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
@@ -404,7 +405,6 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val requestBuilder = Request.Builder().url(url)
|
val requestBuilder = Request.Builder().url(url)
|
||||||
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
|
||||||
val call = client.newCall(requestBuilder.build())
|
val call = client.newCall(requestBuilder.build())
|
||||||
signal.setOnCancelListener(call::cancel)
|
signal.setOnCancelListener(call::cancel)
|
||||||
|
|
||||||
|
|||||||
+1
File diff suppressed because one or more lines are too long
@@ -221,8 +221,11 @@ class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
|||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol NetworkApi {
|
protocol NetworkApi {
|
||||||
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
|
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, Error>) -> Void)
|
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
|
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
func hasCertificate() throws -> Bool
|
||||||
|
func getClientPointer() throws -> Int64
|
||||||
|
func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -255,8 +258,8 @@ class NetworkApiSetup {
|
|||||||
let promptTextArg = args[0] as! ClientCertPrompt
|
let promptTextArg = args[0] as! ClientCertPrompt
|
||||||
api.selectCertificate(promptText: promptTextArg) { result in
|
api.selectCertificate(promptText: promptTextArg) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let res):
|
case .success:
|
||||||
reply(wrapResult(res))
|
reply(wrapResult(nil))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
reply(wrapError(error))
|
reply(wrapError(error))
|
||||||
}
|
}
|
||||||
@@ -280,5 +283,47 @@ class NetworkApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
removeCertificateChannel.setMessageHandler(nil)
|
removeCertificateChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let hasCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
hasCertificateChannel.setMessageHandler { _, reply in
|
||||||
|
do {
|
||||||
|
let result = try api.hasCertificate()
|
||||||
|
reply(wrapResult(result))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasCertificateChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
getClientPointerChannel.setMessageHandler { _, reply in
|
||||||
|
do {
|
||||||
|
let result = try api.getClientPointer()
|
||||||
|
reply(wrapResult(result))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getClientPointerChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let setRequestHeadersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
setRequestHeadersChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let headersArg = args[0] as! [String: String]
|
||||||
|
let serverUrlsArg = args[1] as! [String]
|
||||||
|
do {
|
||||||
|
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg)
|
||||||
|
reply(wrapResult(nil))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setRequestHeadersChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import native_video_player
|
||||||
|
|
||||||
enum ImportError: Error {
|
enum ImportError: Error {
|
||||||
case noFile
|
case noFile
|
||||||
@@ -16,15 +17,26 @@ class NetworkApiImpl: NetworkApi {
|
|||||||
self.viewController = viewController
|
self.viewController = viewController
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, any Error>) -> Void) {
|
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
|
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
|
||||||
self?.activeImporter = nil
|
self?.activeImporter = nil
|
||||||
completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) })
|
completion(result)
|
||||||
}, viewController: viewController)
|
}, viewController: viewController)
|
||||||
activeImporter = importer
|
activeImporter = importer
|
||||||
importer.load()
|
importer.load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasCertificate() throws -> Bool {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassIdentity,
|
||||||
|
kSecAttrLabel as String: CLIENT_CERT_LABEL,
|
||||||
|
kSecReturnRef as String: true,
|
||||||
|
]
|
||||||
|
var item: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
|
return status == errSecSuccess
|
||||||
|
}
|
||||||
|
|
||||||
func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
|
func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
let status = clearCerts()
|
let status = clearCerts()
|
||||||
if status == errSecSuccess || status == errSecItemNotFound {
|
if status == errSecSuccess || status == errSecItemNotFound {
|
||||||
@@ -40,14 +52,58 @@ class NetworkApiImpl: NetworkApi {
|
|||||||
}
|
}
|
||||||
completion(.failure(ImportError.keychainError(status)))
|
completion(.failure(ImportError.keychainError(status)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getClientPointer() throws -> Int64 {
|
||||||
|
let pointer = URLSessionManager.shared.sessionPointer
|
||||||
|
return Int64(Int(bitPattern: pointer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws {
|
||||||
|
var headers = headers
|
||||||
|
if let token = headers.removeValue(forKey: "x-immich-user-token") {
|
||||||
|
for serverUrl in serverUrls {
|
||||||
|
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
|
||||||
|
let isSecure = serverUrl.hasPrefix("https")
|
||||||
|
let cookies: [(String, String, Bool)] = [
|
||||||
|
("immich_access_token", token, true),
|
||||||
|
("immich_is_authenticated", "true", false),
|
||||||
|
("immich_auth_type", "password", true),
|
||||||
|
]
|
||||||
|
let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60)
|
||||||
|
for (name, value, httpOnly) in cookies {
|
||||||
|
var properties: [HTTPCookiePropertyKey: Any] = [
|
||||||
|
.name: name,
|
||||||
|
.value: value,
|
||||||
|
.domain: domain,
|
||||||
|
.path: "/",
|
||||||
|
.expires: expiry,
|
||||||
|
]
|
||||||
|
if isSecure { properties[.secure] = "TRUE" }
|
||||||
|
if httpOnly { properties[.init("HttpOnly")] = "TRUE" }
|
||||||
|
if let cookie = HTTPCookie(properties: properties) {
|
||||||
|
URLSessionManager.cookieStorage.setCookie(cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) {
|
||||||
|
UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
|
||||||
|
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
|
||||||
|
URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CertImporter: NSObject, UIDocumentPickerDelegate {
|
private class CertImporter: NSObject, UIDocumentPickerDelegate {
|
||||||
private let promptText: ClientCertPrompt
|
private let promptText: ClientCertPrompt
|
||||||
private var completion: ((Result<(Data, String), Error>) -> Void)
|
private var completion: ((Result<Void, Error>) -> Void)
|
||||||
private weak var viewController: UIViewController?
|
private weak var viewController: UIViewController?
|
||||||
|
|
||||||
init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) {
|
init(promptText: ClientCertPrompt, completion: (@escaping (Result<Void, Error>) -> Void), viewController: UIViewController?) {
|
||||||
self.promptText = promptText
|
self.promptText = promptText
|
||||||
self.completion = completion
|
self.completion = completion
|
||||||
self.viewController = viewController
|
self.viewController = viewController
|
||||||
@@ -81,7 +137,7 @@ private class CertImporter: NSObject, UIDocumentPickerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await URLSessionManager.shared.session.flush()
|
await URLSessionManager.shared.session.flush()
|
||||||
self.completion(.success((data, password)))
|
self.completion(.success(()))
|
||||||
} catch {
|
} catch {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,77 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import native_video_player
|
||||||
|
|
||||||
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
|
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
|
||||||
|
let HEADERS_KEY = "immich.request_headers"
|
||||||
|
let SERVER_URL_KEY = "immich.server_url"
|
||||||
|
let APP_GROUP = "group.app.immich.share"
|
||||||
|
|
||||||
|
extension UserDefaults {
|
||||||
|
static let group = UserDefaults(suiteName: APP_GROUP)!
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages a shared URLSession with SSL configuration support.
|
/// Manages a shared URLSession with SSL configuration support.
|
||||||
|
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
|
||||||
class URLSessionManager: NSObject {
|
class URLSessionManager: NSObject {
|
||||||
static let shared = URLSessionManager()
|
static let shared = URLSessionManager()
|
||||||
|
|
||||||
let session: URLSession
|
private(set) var session: URLSession
|
||||||
private let configuration = {
|
let delegate: URLSessionManagerDelegate
|
||||||
let config = URLSessionConfiguration.default
|
private static let cacheDir: URL = {
|
||||||
|
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
||||||
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
|
||||||
.first!
|
.first!
|
||||||
.appendingPathComponent("api", isDirectory: true)
|
.appendingPathComponent("api", isDirectory: true)
|
||||||
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}()
|
||||||
|
private static let urlCache = URLCache(
|
||||||
|
memoryCapacity: 0,
|
||||||
|
diskCapacity: 1024 * 1024 * 1024,
|
||||||
|
directory: cacheDir
|
||||||
|
)
|
||||||
|
private static let userAgent: String = {
|
||||||
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||||
|
return "Immich_iOS_\(version)"
|
||||||
|
}()
|
||||||
|
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
|
||||||
|
|
||||||
config.urlCache = URLCache(
|
var sessionPointer: UnsafeMutableRawPointer {
|
||||||
memoryCapacity: 0,
|
Unmanaged.passUnretained(session).toOpaque()
|
||||||
diskCapacity: 1024 * 1024 * 1024,
|
}
|
||||||
directory: cacheDir
|
|
||||||
)
|
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
delegate = URLSessionManagerDelegate()
|
||||||
|
session = Self.buildSession(delegate: delegate)
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func recreateSession() {
|
||||||
|
session = Self.buildSession(delegate: delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.urlCache = urlCache
|
||||||
|
config.httpCookieStorage = cookieStorage
|
||||||
config.httpMaximumConnectionsPerHost = 64
|
config.httpMaximumConnectionsPerHost = 64
|
||||||
config.timeoutIntervalForRequest = 60
|
config.timeoutIntervalForRequest = 60
|
||||||
config.timeoutIntervalForResource = 300
|
config.timeoutIntervalForResource = 300
|
||||||
|
|
||||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
|
||||||
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
|
headers["User-Agent"] = headers["User-Agent"] ?? userAgent
|
||||||
|
config.httpAdditionalHeaders = headers
|
||||||
|
|
||||||
return config
|
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||||
}()
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil)
|
|
||||||
super.init()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
|
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
|
||||||
func urlSession(
|
func urlSession(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
didReceive challenge: URLAuthenticationChallenge,
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
) {
|
) {
|
||||||
handleChallenge(challenge, completionHandler: completionHandler)
|
handleChallenge(session, challenge, completionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(
|
func urlSession(
|
||||||
@@ -52,20 +80,24 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
|
|||||||
didReceive challenge: URLAuthenticationChallenge,
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
) {
|
) {
|
||||||
handleChallenge(challenge, completionHandler: completionHandler)
|
handleChallenge(session, challenge, completionHandler, task: task)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleChallenge(
|
func handleChallenge(
|
||||||
|
_ session: URLSession,
|
||||||
_ challenge: URLAuthenticationChallenge,
|
_ challenge: URLAuthenticationChallenge,
|
||||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
_ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void,
|
||||||
|
task: URLSessionTask? = nil
|
||||||
) {
|
) {
|
||||||
switch challenge.protectionSpace.authenticationMethod {
|
switch challenge.protectionSpace.authenticationMethod {
|
||||||
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler)
|
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(session, completion: completionHandler)
|
||||||
|
case NSURLAuthenticationMethodHTTPBasic: handleBasicAuth(session, task: task, completion: completionHandler)
|
||||||
default: completionHandler(.performDefaultHandling, nil)
|
default: completionHandler(.performDefaultHandling, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleClientCertificate(
|
private func handleClientCertificate(
|
||||||
|
_ session: URLSession,
|
||||||
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
) {
|
) {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
@@ -80,8 +112,29 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
|
|||||||
let credential = URLCredential(identity: identity as! SecIdentity,
|
let credential = URLCredential(identity: identity as! SecIdentity,
|
||||||
certificates: nil,
|
certificates: nil,
|
||||||
persistence: .forSession)
|
persistence: .forSession)
|
||||||
|
if #available(iOS 15, *) {
|
||||||
|
VideoProxyServer.shared.session = session
|
||||||
|
}
|
||||||
return completion(.useCredential, credential)
|
return completion(.useCredential, credential)
|
||||||
}
|
}
|
||||||
completion(.performDefaultHandling, nil)
|
completion(.performDefaultHandling, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleBasicAuth(
|
||||||
|
_ session: URLSession,
|
||||||
|
task: URLSessionTask?,
|
||||||
|
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
|
) {
|
||||||
|
guard let url = task?.originalRequest?.url,
|
||||||
|
let user = url.user,
|
||||||
|
let password = url.password
|
||||||
|
else {
|
||||||
|
return completion(.performDefaultHandling, nil)
|
||||||
|
}
|
||||||
|
if #available(iOS 15, *) {
|
||||||
|
VideoProxyServer.shared.session = session
|
||||||
|
}
|
||||||
|
let credential = URLCredential(user: user, password: password, persistence: .forSession)
|
||||||
|
completion(.useCredential, credential)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
|
|||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol RemoteImageApi {
|
protocol RemoteImageApi {
|
||||||
func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||||
func cancelRequest(requestId: Int64) throws
|
func cancelRequest(requestId: Int64) throws
|
||||||
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
||||||
}
|
}
|
||||||
@@ -86,10 +86,9 @@ class RemoteImageApiSetup {
|
|||||||
requestImageChannel.setMessageHandler { message, reply in
|
requestImageChannel.setMessageHandler { message, reply in
|
||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let urlArg = args[0] as! String
|
let urlArg = args[0] as! String
|
||||||
let headersArg = args[1] as! [String: String]
|
let requestIdArg = args[1] as! Int64
|
||||||
let requestIdArg = args[2] as! Int64
|
let preferEncodedArg = args[2] as! Bool
|
||||||
let preferEncodedArg = args[3] as! Bool
|
api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
|
||||||
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
|
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let res):
|
case .success(let res):
|
||||||
reply(wrapResult(res))
|
reply(wrapResult(res))
|
||||||
|
|||||||
@@ -33,12 +33,9 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
kCGImageSourceCreateThumbnailFromImageAlways: true
|
kCGImageSourceCreateThumbnailFromImageAlways: true
|
||||||
] as CFDictionary
|
] as CFDictionary
|
||||||
|
|
||||||
func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||||
var urlRequest = URLRequest(url: URL(string: url)!)
|
var urlRequest = URLRequest(url: URL(string: url)!)
|
||||||
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
||||||
for (key, value) in headers {
|
|
||||||
urlRequest.setValue(value, forHTTPHeaderField: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
||||||
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
|
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import "package:openapi/api.dart" as api show AssetEditAction;
|
||||||
|
|
||||||
|
enum AssetEditAction { rotate, crop, mirror, other }
|
||||||
|
|
||||||
|
extension AssetEditActionExtension on AssetEditAction {
|
||||||
|
api.AssetEditAction? toDto() {
|
||||||
|
return switch (this) {
|
||||||
|
AssetEditAction.rotate => api.AssetEditAction.rotate,
|
||||||
|
AssetEditAction.crop => api.AssetEditAction.crop,
|
||||||
|
AssetEditAction.mirror => api.AssetEditAction.mirror,
|
||||||
|
AssetEditAction.other => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssetEdit {
|
||||||
|
final AssetEditAction action;
|
||||||
|
final Map<String, dynamic> parameters;
|
||||||
|
|
||||||
|
const AssetEdit({required this.action, required this.parameters});
|
||||||
|
}
|
||||||
@@ -3,30 +3,21 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
|
|
||||||
class SearchResult {
|
class SearchResult {
|
||||||
final List<BaseAsset> assets;
|
final List<BaseAsset> assets;
|
||||||
final double scrollOffset;
|
|
||||||
final int? nextPage;
|
final int? nextPage;
|
||||||
|
|
||||||
const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});
|
const SearchResult({required this.assets, this.nextPage});
|
||||||
|
|
||||||
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
|
|
||||||
return SearchResult(
|
|
||||||
assets: assets ?? this.assets,
|
|
||||||
nextPage: nextPage ?? this.nextPage,
|
|
||||||
scrollOffset: scrollOffset ?? this.scrollOffset,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
|
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant SearchResult other) {
|
bool operator ==(covariant SearchResult other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
final listEquals = const DeepCollectionEquality().equals;
|
final listEquals = const DeepCollectionEquality().equals;
|
||||||
|
|
||||||
return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
|
return listEquals(other.assets, assets) && other.nextPage == nextPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
|
int get hashCode => assets.hashCode ^ nextPage.hashCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:cancellation_token_http/http.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
@@ -28,7 +27,6 @@ import 'package:immich_mobile/services/localization.service.dart';
|
|||||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
|
||||||
import 'package:immich_mobile/wm_executor.dart';
|
import 'package:immich_mobile/wm_executor.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -64,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
final Drift _drift;
|
final Drift _drift;
|
||||||
final DriftLogger _driftLogger;
|
final DriftLogger _driftLogger;
|
||||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||||
final CancellationToken _cancellationToken = CancellationToken();
|
final _cancellationToken = Completer<void>();
|
||||||
final Logger _logger = Logger('BackgroundWorkerBgService');
|
final Logger _logger = Logger('BackgroundWorkerBgService');
|
||||||
|
|
||||||
bool _isCleanedUp = false;
|
bool _isCleanedUp = false;
|
||||||
@@ -88,8 +86,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
try {
|
try {
|
||||||
HttpSSLOptions.apply();
|
|
||||||
|
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
[
|
[
|
||||||
loadTranslations(),
|
loadTranslations(),
|
||||||
@@ -198,7 +194,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
_ref?.dispose();
|
_ref?.dispose();
|
||||||
_ref = null;
|
_ref = null;
|
||||||
|
|
||||||
_cancellationToken.cancel();
|
_cancellationToken.complete();
|
||||||
_logger.info("Cleaning up background worker");
|
_logger.info("Cleaning up background worker");
|
||||||
|
|
||||||
final cleanupFutures = [
|
final cleanupFutures = [
|
||||||
|
|||||||
@@ -70,13 +70,14 @@ extension on AssetResponseDto {
|
|||||||
_ => AssetVisibility.timeline,
|
_ => AssetVisibility.timeline,
|
||||||
},
|
},
|
||||||
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
||||||
height: exifInfo?.exifImageHeight?.toInt(),
|
height: height?.toInt(),
|
||||||
width: exifInfo?.exifImageWidth?.toInt(),
|
width: width?.toInt(),
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
livePhotoVideoId: livePhotoVideoId,
|
livePhotoVideoId: livePhotoVideoId,
|
||||||
thumbHash: thumbhash,
|
thumbHash: thumbhash,
|
||||||
localId: null,
|
localId: null,
|
||||||
type: type.toAssetType(),
|
type: type.toAssetType(),
|
||||||
|
stackId: stack?.id,
|
||||||
isEdited: isEdited,
|
isEdited: isEdited,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,10 @@ class SyncStreamService {
|
|||||||
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
||||||
case SyncEntityType.assetExifV1:
|
case SyncEntityType.assetExifV1:
|
||||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||||
|
case SyncEntityType.assetEditV1:
|
||||||
|
return _syncStreamRepository.updateAssetEditsV1(data.cast());
|
||||||
|
case SyncEntityType.assetEditDeleteV1:
|
||||||
|
return _syncStreamRepository.deleteAssetEditsV1(data.cast());
|
||||||
case SyncEntityType.assetMetadataV1:
|
case SyncEntityType.assetMetadataV1:
|
||||||
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
|
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
|
||||||
case SyncEntityType.assetMetadataDeleteV1:
|
case SyncEntityType.assetMetadataDeleteV1:
|
||||||
@@ -336,39 +340,43 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
|
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
|
||||||
if (batchData.isEmpty) return;
|
_logger.info('Processing AssetEditReadyV1 event');
|
||||||
|
|
||||||
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
|
|
||||||
|
|
||||||
final List<SyncAssetV1> assets = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (final data in batchData) {
|
if (data is! Map<String, dynamic>) {
|
||||||
if (data is! Map<String, dynamic>) {
|
throw ArgumentError("Invalid data format for AssetEditReadyV1 event");
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final payload = data;
|
|
||||||
final assetData = payload['asset'];
|
|
||||||
|
|
||||||
if (assetData == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final asset = SyncAssetV1.fromJson(assetData);
|
|
||||||
|
|
||||||
if (asset != null) {
|
|
||||||
assets.add(asset);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assets.isNotEmpty) {
|
final payload = data;
|
||||||
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
|
|
||||||
_logger.info('Successfully processed ${assets.length} edited assets');
|
if (payload['asset'] == null) {
|
||||||
|
throw ArgumentError("Missing 'asset' field in AssetEditReadyV1 event data");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final asset = SyncAssetV1.fromJson(payload['asset']);
|
||||||
|
if (asset == null) {
|
||||||
|
throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV1 event data");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SyncAssetEditV1> assetEdits = [];
|
||||||
|
|
||||||
|
// Edits are only send on v2.6.0+
|
||||||
|
if (payload['edit'] != null && payload['edit'] is List<dynamic>) {
|
||||||
|
assetEdits = (payload['edit'] as List<dynamic>)
|
||||||
|
.map((e) => SyncAssetEditV1.fromJson(e))
|
||||||
|
.whereType<SyncAssetEditV1>()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
|
||||||
|
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits',
|
||||||
|
);
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
|
_logger.severe("Error processing AssetEditReadyV1 websocket event", error, stackTrace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ class TimelineFactory {
|
|||||||
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||||
TimelineService(_timelineRepository.fromAssets(assets, type));
|
TimelineService(_timelineRepository.fromAssets(assets, type));
|
||||||
|
|
||||||
|
TimelineService fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin type) =>
|
||||||
|
TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type));
|
||||||
|
|
||||||
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||||
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
||||||
|
|
||||||
@@ -112,7 +115,7 @@ class TimelineService {
|
|||||||
|
|
||||||
if (totalAssets == 0) {
|
if (totalAssets == 0) {
|
||||||
_bufferOffset = 0;
|
_bufferOffset = 0;
|
||||||
_buffer.clear();
|
_buffer = [];
|
||||||
} else {
|
} else {
|
||||||
final int offset;
|
final int offset;
|
||||||
final int count;
|
final int count;
|
||||||
|
|||||||
@@ -196,11 +196,11 @@ class BackgroundSyncManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
|
Future<void> syncWebsocketEdit(dynamic data) {
|
||||||
if (_syncWebsocketTask != null) {
|
if (_syncWebsocketTask != null) {
|
||||||
return _syncWebsocketTask!.future;
|
return _syncWebsocketTask!.future;
|
||||||
}
|
}
|
||||||
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
|
_syncWebsocketTask = _handleWsAssetEditReadyV1(data);
|
||||||
return _syncWebsocketTask!.whenComplete(() {
|
return _syncWebsocketTask!.whenComplete(() {
|
||||||
_syncWebsocketTask = null;
|
_syncWebsocketTask = null;
|
||||||
});
|
});
|
||||||
@@ -242,7 +242,7 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
|
|||||||
debugLabel: 'websocket-batch',
|
debugLabel: 'websocket-batch',
|
||||||
);
|
);
|
||||||
|
|
||||||
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
|
Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
|
||||||
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
|
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
|
||||||
debugLabel: 'websocket-edit',
|
debugLabel: 'websocket-edit',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
class SnapScrollPhysics extends ScrollPhysics {
|
||||||
static const _minFlingVelocity = 700.0;
|
static const _minFlingVelocity = 700.0;
|
||||||
static const minSnapDistance = 30.0;
|
static const minSnapDistance = 30.0;
|
||||||
|
|
||||||
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
|
|
||||||
|
|
||||||
const SnapScrollPhysics({super.parent});
|
const SnapScrollPhysics({super.parent});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -66,91 +81,21 @@ class SnapScrollPhysics extends ScrollPhysics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ScrollSpringSimulation(
|
return ScrollSpringSimulation(spring, position.pixels, target(position, velocity, snapOffset), velocity);
|
||||||
_spring,
|
|
||||||
position.pixels,
|
|
||||||
target(position, velocity, snapOffset),
|
|
||||||
velocity,
|
|
||||||
tolerance: toleranceFor(position),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SpringDescription get spring => SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get allowImplicitScrolling => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get allowUserScrolling => false;
|
||||||
|
|
||||||
static double target(ScrollMetrics position, double velocity, double snapOffset) {
|
static double target(ScrollMetrics position, double velocity, double snapOffset) {
|
||||||
if (velocity > _minFlingVelocity) return snapOffset;
|
if (velocity > _minFlingVelocity) return snapOffset;
|
||||||
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
|
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
|
||||||
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
|
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SnapScrollPosition extends ScrollPositionWithSingleContext {
|
|
||||||
double snapOffset;
|
|
||||||
|
|
||||||
SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition});
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProxyScrollController extends ScrollController {
|
|
||||||
final ScrollController scrollController;
|
|
||||||
|
|
||||||
ProxyScrollController({required this.scrollController});
|
|
||||||
|
|
||||||
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
|
|
||||||
|
|
||||||
@override
|
|
||||||
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
|
|
||||||
return ProxyScrollPosition(
|
|
||||||
scrollController: scrollController,
|
|
||||||
physics: physics,
|
|
||||||
context: context,
|
|
||||||
oldPosition: oldPosition,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
scrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProxyScrollPosition extends SnapScrollPosition {
|
|
||||||
final ScrollController scrollController;
|
|
||||||
|
|
||||||
ProxyScrollPosition({
|
|
||||||
required this.scrollController,
|
|
||||||
required super.physics,
|
|
||||||
required super.context,
|
|
||||||
super.oldPosition,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
double setPixels(double newPixels) {
|
|
||||||
final overscroll = super.setPixels(newPixels);
|
|
||||||
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
|
||||||
scrollController.position.forcePixels(pixels);
|
|
||||||
}
|
|
||||||
return overscroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void forcePixels(double value) {
|
|
||||||
super.forcePixels(value);
|
|
||||||
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
|
||||||
scrollController.position.forcePixels(pixels);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
|
||||||
? scrollController.position.maxScrollExtent
|
|
||||||
: super.maxScrollExtent;
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
|
||||||
? scrollController.position.minScrollExtent
|
|
||||||
: super.minScrollExtent;
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension
|
|
||||||
? scrollController.position.viewportDimension
|
|
||||||
: super.viewportDimension;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||||
|
|
||||||
|
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)')
|
||||||
|
class AssetEditEntity extends Table with DriftDefaultsMixin {
|
||||||
|
const AssetEditEntity();
|
||||||
|
|
||||||
|
TextColumn get id => text()();
|
||||||
|
|
||||||
|
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
IntColumn get action => intEnum<AssetEditAction>()();
|
||||||
|
|
||||||
|
BlobColumn get parameters => blob().map(editParameterConverter)();
|
||||||
|
|
||||||
|
IntColumn get sequence => integer()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonTypeConverter2<Map<String, Object?>, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb(
|
||||||
|
fromJson: (json) => json as Map<String, Object?>,
|
||||||
|
);
|
||||||
|
|
||||||
|
extension AssetEditEntityDataDomainEx on AssetEditEntityData {
|
||||||
|
AssetEdit toDto() {
|
||||||
|
return AssetEdit(action: action, parameters: parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,752 @@
|
|||||||
|
// dart format width=80
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
import 'package:drift/drift.dart' as i0;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
|
||||||
|
as i1;
|
||||||
|
import 'package:immich_mobile/domain/models/asset_edit.model.dart' as i2;
|
||||||
|
import 'dart:typed_data' as i3;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'
|
||||||
|
as i4;
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
|
||||||
|
as i5;
|
||||||
|
import 'package:drift/internal/modular.dart' as i6;
|
||||||
|
|
||||||
|
typedef $$AssetEditEntityTableCreateCompanionBuilder =
|
||||||
|
i1.AssetEditEntityCompanion Function({
|
||||||
|
required String id,
|
||||||
|
required String assetId,
|
||||||
|
required i2.AssetEditAction action,
|
||||||
|
required Map<String, Object?> parameters,
|
||||||
|
required int sequence,
|
||||||
|
});
|
||||||
|
typedef $$AssetEditEntityTableUpdateCompanionBuilder =
|
||||||
|
i1.AssetEditEntityCompanion Function({
|
||||||
|
i0.Value<String> id,
|
||||||
|
i0.Value<String> assetId,
|
||||||
|
i0.Value<i2.AssetEditAction> action,
|
||||||
|
i0.Value<Map<String, Object?>> parameters,
|
||||||
|
i0.Value<int> sequence,
|
||||||
|
});
|
||||||
|
|
||||||
|
final class $$AssetEditEntityTableReferences
|
||||||
|
extends
|
||||||
|
i0.BaseReferences<
|
||||||
|
i0.GeneratedDatabase,
|
||||||
|
i1.$AssetEditEntityTable,
|
||||||
|
i1.AssetEditEntityData
|
||||||
|
> {
|
||||||
|
$$AssetEditEntityTableReferences(
|
||||||
|
super.$_db,
|
||||||
|
super.$_table,
|
||||||
|
super.$_typedResult,
|
||||||
|
);
|
||||||
|
|
||||||
|
static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
|
||||||
|
i6.ReadDatabaseContainer(db)
|
||||||
|
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
|
||||||
|
.createAlias(
|
||||||
|
i0.$_aliasNameGenerator(
|
||||||
|
i6.ReadDatabaseContainer(db)
|
||||||
|
.resultSet<i1.$AssetEditEntityTable>('asset_edit_entity')
|
||||||
|
.assetId,
|
||||||
|
i6.ReadDatabaseContainer(
|
||||||
|
db,
|
||||||
|
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
i5.$$RemoteAssetEntityTableProcessedTableManager get assetId {
|
||||||
|
final $_column = $_itemColumn<String>('asset_id')!;
|
||||||
|
|
||||||
|
final manager = i5
|
||||||
|
.$$RemoteAssetEntityTableTableManager(
|
||||||
|
$_db,
|
||||||
|
i6.ReadDatabaseContainer(
|
||||||
|
$_db,
|
||||||
|
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||||
|
)
|
||||||
|
.filter((f) => f.id.sqlEquals($_column));
|
||||||
|
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
|
||||||
|
if (item == null) return manager;
|
||||||
|
return i0.ProcessedTableManager(
|
||||||
|
manager.$state.copyWith(prefetchedData: [item]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$AssetEditEntityTableFilterComposer
|
||||||
|
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
|
||||||
|
$$AssetEditEntityTableFilterComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
i0.ColumnFilters<String> get id => $composableBuilder(
|
||||||
|
column: $table.id,
|
||||||
|
builder: (column) => i0.ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
i0.ColumnWithTypeConverterFilters<i2.AssetEditAction, i2.AssetEditAction, int>
|
||||||
|
get action => $composableBuilder(
|
||||||
|
column: $table.action,
|
||||||
|
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
i0.ColumnWithTypeConverterFilters<
|
||||||
|
Map<String, Object?>,
|
||||||
|
Map<String, Object>,
|
||||||
|
i3.Uint8List
|
||||||
|
>
|
||||||
|
get parameters => $composableBuilder(
|
||||||
|
column: $table.parameters,
|
||||||
|
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
i0.ColumnFilters<int> get sequence => $composableBuilder(
|
||||||
|
column: $table.sequence,
|
||||||
|
builder: (column) => i0.ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
i5.$$RemoteAssetEntityTableFilterComposer get assetId {
|
||||||
|
final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
|
||||||
|
composer: this,
|
||||||
|
getCurrentColumn: (t) => t.assetId,
|
||||||
|
referencedTable: i6.ReadDatabaseContainer(
|
||||||
|
$db,
|
||||||
|
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||||
|
getReferencedColumn: (t) => t.id,
|
||||||
|
builder:
|
||||||
|
(
|
||||||
|
joinBuilder, {
|
||||||
|
$addJoinBuilderToRootComposer,
|
||||||
|
$removeJoinBuilderFromRootComposer,
|
||||||
|
}) => i5.$$RemoteAssetEntityTableFilterComposer(
|
||||||
|
$db: $db,
|
||||||
|
$table: i6.ReadDatabaseContainer(
|
||||||
|
$db,
|
||||||
|
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||||
|
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||||
|
joinBuilder: joinBuilder,
|
||||||
|
$removeJoinBuilderFromRootComposer:
|
||||||
|
$removeJoinBuilderFromRootComposer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return composer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$AssetEditEntityTableOrderingComposer
|
||||||
|
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
|
||||||
|
$$AssetEditEntityTableOrderingComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
i0.ColumnOrderings<String> get id => $composableBuilder(
|
||||||
|
column: $table.id,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
i0.ColumnOrderings<int> get action => $composableBuilder(
|
||||||
|
column: $table.action,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
i0.ColumnOrderings<i3.Uint8List> get parameters => $composableBuilder(
|
||||||
|
column: $table.parameters,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
i0.ColumnOrderings<int> get sequence => $composableBuilder(
|
||||||
|
column: $table.sequence,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
i5.$$RemoteAssetEntityTableOrderingComposer get assetId {
|
||||||
|
final i5.$$RemoteAssetEntityTableOrderingComposer composer =
|
||||||
|
$composerBuilder(
|
||||||
|
composer: this,
|
||||||
|
getCurrentColumn: (t) => t.assetId,
|
||||||
|
referencedTable: i6.ReadDatabaseContainer(
|
||||||
|
$db,
|
||||||
|
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||||
|
getReferencedColumn: (t) => t.id,
|
||||||
|
builder:
|
||||||
|
(
|
||||||
|
joinBuilder, {
|
||||||
|
$addJoinBuilderToRootComposer,
|
||||||
|
$removeJoinBuilderFromRootComposer,
|
||||||
|
}) => i5.$$RemoteAssetEntityTableOrderingComposer(
|
||||||
|
$db: $db,
|
||||||
|
$table: i6.ReadDatabaseContainer(
|
||||||
|
$db,
|
||||||
|
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||||
|
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||||
|
joinBuilder: joinBuilder,
|
||||||
|
$removeJoinBuilderFromRootComposer:
|
||||||
|
$removeJoinBuilderFromRootComposer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return composer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$AssetEditEntityTableAnnotationComposer
|
||||||
|
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
|
||||||
|
$$AssetEditEntityTableAnnotationComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
i0.GeneratedColumn<String> get id =>
|
||||||
|
$composableBuilder(column: $table.id, builder: (column) => column);
|
||||||
|
|
||||||
|
i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int> get action =>
|
||||||
|
$composableBuilder(column: $table.action, builder: (column) => column);
|
||||||
|
|
||||||
|
i0.GeneratedColumnWithTypeConverter<Map<String, Object?>, i3.Uint8List>
|
||||||
|
get parameters => $composableBuilder(
|
||||||
|
column: $table.parameters,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
|
i0.GeneratedColumn<int> get sequence =>
|
||||||
|
$composableBuilder(column: $table.sequence, builder: (column) => column);
|
||||||
|
|
||||||
|
i5.$$RemoteAssetEntityTableAnnotationComposer get assetId {
|
||||||
|
final i5.$$RemoteAssetEntityTableAnnotationComposer composer =
|
||||||
|
$composerBuilder(
|
||||||
|
composer: this,
|
||||||
|
getCurrentColumn: (t) => t.assetId,
|
||||||
|
referencedTable: i6.ReadDatabaseContainer(
|
||||||
|
$db,
|
||||||
|
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||||
|
getReferencedColumn: (t) => t.id,
|
||||||
|
builder:
|
||||||
|
(
|
||||||
|
joinBuilder, {
|
||||||
|
$addJoinBuilderToRootComposer,
|
||||||
|
$removeJoinBuilderFromRootComposer,
|
||||||
|
}) => i5.$$RemoteAssetEntityTableAnnotationComposer(
|
||||||
|
$db: $db,
|
||||||
|
$table: i6.ReadDatabaseContainer(
|
||||||
|
$db,
|
||||||
|
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||||
|
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||||
|
joinBuilder: joinBuilder,
|
||||||
|
$removeJoinBuilderFromRootComposer:
|
||||||
|
$removeJoinBuilderFromRootComposer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return composer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$AssetEditEntityTableTableManager
|
||||||
|
extends
|
||||||
|
i0.RootTableManager<
|
||||||
|
i0.GeneratedDatabase,
|
||||||
|
i1.$AssetEditEntityTable,
|
||||||
|
i1.AssetEditEntityData,
|
||||||
|
i1.$$AssetEditEntityTableFilterComposer,
|
||||||
|
i1.$$AssetEditEntityTableOrderingComposer,
|
||||||
|
i1.$$AssetEditEntityTableAnnotationComposer,
|
||||||
|
$$AssetEditEntityTableCreateCompanionBuilder,
|
||||||
|
$$AssetEditEntityTableUpdateCompanionBuilder,
|
||||||
|
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
|
||||||
|
i1.AssetEditEntityData,
|
||||||
|
i0.PrefetchHooks Function({bool assetId})
|
||||||
|
> {
|
||||||
|
$$AssetEditEntityTableTableManager(
|
||||||
|
i0.GeneratedDatabase db,
|
||||||
|
i1.$AssetEditEntityTable table,
|
||||||
|
) : super(
|
||||||
|
i0.TableManagerState(
|
||||||
|
db: db,
|
||||||
|
table: table,
|
||||||
|
createFilteringComposer: () =>
|
||||||
|
i1.$$AssetEditEntityTableFilterComposer($db: db, $table: table),
|
||||||
|
createOrderingComposer: () =>
|
||||||
|
i1.$$AssetEditEntityTableOrderingComposer($db: db, $table: table),
|
||||||
|
createComputedFieldComposer: () => i1
|
||||||
|
.$$AssetEditEntityTableAnnotationComposer($db: db, $table: table),
|
||||||
|
updateCompanionCallback:
|
||||||
|
({
|
||||||
|
i0.Value<String> id = const i0.Value.absent(),
|
||||||
|
i0.Value<String> assetId = const i0.Value.absent(),
|
||||||
|
i0.Value<i2.AssetEditAction> action = const i0.Value.absent(),
|
||||||
|
i0.Value<Map<String, Object?>> parameters =
|
||||||
|
const i0.Value.absent(),
|
||||||
|
i0.Value<int> sequence = const i0.Value.absent(),
|
||||||
|
}) => i1.AssetEditEntityCompanion(
|
||||||
|
id: id,
|
||||||
|
assetId: assetId,
|
||||||
|
action: action,
|
||||||
|
parameters: parameters,
|
||||||
|
sequence: sequence,
|
||||||
|
),
|
||||||
|
createCompanionCallback:
|
||||||
|
({
|
||||||
|
required String id,
|
||||||
|
required String assetId,
|
||||||
|
required i2.AssetEditAction action,
|
||||||
|
required Map<String, Object?> parameters,
|
||||||
|
required int sequence,
|
||||||
|
}) => i1.AssetEditEntityCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
assetId: assetId,
|
||||||
|
action: action,
|
||||||
|
parameters: parameters,
|
||||||
|
sequence: sequence,
|
||||||
|
),
|
||||||
|
withReferenceMapper: (p0) => p0
|
||||||
|
.map(
|
||||||
|
(e) => (
|
||||||
|
e.readTable(table),
|
||||||
|
i1.$$AssetEditEntityTableReferences(db, table, e),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
prefetchHooksCallback: ({assetId = false}) {
|
||||||
|
return i0.PrefetchHooks(
|
||||||
|
db: db,
|
||||||
|
explicitlyWatchedTables: [],
|
||||||
|
addJoins:
|
||||||
|
<
|
||||||
|
T extends i0.TableManagerState<
|
||||||
|
dynamic,
|
||||||
|
dynamic,
|
||||||
|
dynamic,
|
||||||
|
dynamic,
|
||||||
|
dynamic,
|
||||||
|
dynamic,
|
||||||
|
dynamic,
|
||||||
|
dynamic,
|
||||||
|
dynamic,
|
||||||
|
dynamic,
|
||||||
|
dynamic
|
||||||
|
>
|
||||||
|
>(state) {
|
||||||
|
if (assetId) {
|
||||||
|
state =
|
||||||
|
state.withJoin(
|
||||||
|
currentTable: table,
|
||||||
|
currentColumn: table.assetId,
|
||||||
|
referencedTable: i1
|
||||||
|
.$$AssetEditEntityTableReferences
|
||||||
|
._assetIdTable(db),
|
||||||
|
referencedColumn: i1
|
||||||
|
.$$AssetEditEntityTableReferences
|
||||||
|
._assetIdTable(db)
|
||||||
|
.id,
|
||||||
|
)
|
||||||
|
as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
getPrefetchedDataCallback: (items) async {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef $$AssetEditEntityTableProcessedTableManager =
|
||||||
|
i0.ProcessedTableManager<
|
||||||
|
i0.GeneratedDatabase,
|
||||||
|
i1.$AssetEditEntityTable,
|
||||||
|
i1.AssetEditEntityData,
|
||||||
|
i1.$$AssetEditEntityTableFilterComposer,
|
||||||
|
i1.$$AssetEditEntityTableOrderingComposer,
|
||||||
|
i1.$$AssetEditEntityTableAnnotationComposer,
|
||||||
|
$$AssetEditEntityTableCreateCompanionBuilder,
|
||||||
|
$$AssetEditEntityTableUpdateCompanionBuilder,
|
||||||
|
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
|
||||||
|
i1.AssetEditEntityData,
|
||||||
|
i0.PrefetchHooks Function({bool assetId})
|
||||||
|
>;
|
||||||
|
i0.Index get idxAssetEditAssetId => i0.Index(
|
||||||
|
'idx_asset_edit_asset_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||||
|
);
|
||||||
|
|
||||||
|
class $AssetEditEntityTable extends i4.AssetEditEntity
|
||||||
|
with i0.TableInfo<$AssetEditEntityTable, i1.AssetEditEntityData> {
|
||||||
|
@override
|
||||||
|
final i0.GeneratedDatabase attachedDatabase;
|
||||||
|
final String? _alias;
|
||||||
|
$AssetEditEntityTable(this.attachedDatabase, [this._alias]);
|
||||||
|
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
|
||||||
|
'id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i0.DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
|
||||||
|
'assetId',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
|
||||||
|
'asset_id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i0.DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
||||||
|
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int>
|
||||||
|
action =
|
||||||
|
i0.GeneratedColumn<int>(
|
||||||
|
'action',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i0.DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
).withConverter<i2.AssetEditAction>(
|
||||||
|
i1.$AssetEditEntityTable.$converteraction,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumnWithTypeConverter<
|
||||||
|
Map<String, Object?>,
|
||||||
|
i3.Uint8List
|
||||||
|
>
|
||||||
|
parameters =
|
||||||
|
i0.GeneratedColumn<i3.Uint8List>(
|
||||||
|
'parameters',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i0.DriftSqlType.blob,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
).withConverter<Map<String, Object?>>(
|
||||||
|
i1.$AssetEditEntityTable.$converterparameters,
|
||||||
|
);
|
||||||
|
static const i0.VerificationMeta _sequenceMeta = const i0.VerificationMeta(
|
||||||
|
'sequence',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumn<int> sequence = i0.GeneratedColumn<int>(
|
||||||
|
'sequence',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i0.DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<i0.GeneratedColumn> get $columns => [
|
||||||
|
id,
|
||||||
|
assetId,
|
||||||
|
action,
|
||||||
|
parameters,
|
||||||
|
sequence,
|
||||||
|
];
|
||||||
|
@override
|
||||||
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
|
@override
|
||||||
|
String get actualTableName => $name;
|
||||||
|
static const String $name = 'asset_edit_entity';
|
||||||
|
@override
|
||||||
|
i0.VerificationContext validateIntegrity(
|
||||||
|
i0.Insertable<i1.AssetEditEntityData> instance, {
|
||||||
|
bool isInserting = false,
|
||||||
|
}) {
|
||||||
|
final context = i0.VerificationContext();
|
||||||
|
final data = instance.toColumns(true);
|
||||||
|
if (data.containsKey('id')) {
|
||||||
|
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_idMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('asset_id')) {
|
||||||
|
context.handle(
|
||||||
|
_assetIdMeta,
|
||||||
|
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_assetIdMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('sequence')) {
|
||||||
|
context.handle(
|
||||||
|
_sequenceMeta,
|
||||||
|
sequence.isAcceptableOrUnknown(data['sequence']!, _sequenceMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_sequenceMeta);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<i0.GeneratedColumn> get $primaryKey => {id};
|
||||||
|
@override
|
||||||
|
i1.AssetEditEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||||
|
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||||
|
return i1.AssetEditEntityData(
|
||||||
|
id: attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}id'],
|
||||||
|
)!,
|
||||||
|
assetId: attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}asset_id'],
|
||||||
|
)!,
|
||||||
|
action: i1.$AssetEditEntityTable.$converteraction.fromSql(
|
||||||
|
attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.int,
|
||||||
|
data['${effectivePrefix}action'],
|
||||||
|
)!,
|
||||||
|
),
|
||||||
|
parameters: i1.$AssetEditEntityTable.$converterparameters.fromSql(
|
||||||
|
attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.blob,
|
||||||
|
data['${effectivePrefix}parameters'],
|
||||||
|
)!,
|
||||||
|
),
|
||||||
|
sequence: attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.int,
|
||||||
|
data['${effectivePrefix}sequence'],
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
$AssetEditEntityTable createAlias(String alias) {
|
||||||
|
return $AssetEditEntityTable(attachedDatabase, alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
static i0.JsonTypeConverter2<i2.AssetEditAction, int, int> $converteraction =
|
||||||
|
const i0.EnumIndexConverter<i2.AssetEditAction>(
|
||||||
|
i2.AssetEditAction.values,
|
||||||
|
);
|
||||||
|
static i0.JsonTypeConverter2<Map<String, Object?>, i3.Uint8List, Object?>
|
||||||
|
$converterparameters = i4.editParameterConverter;
|
||||||
|
@override
|
||||||
|
bool get withoutRowId => true;
|
||||||
|
@override
|
||||||
|
bool get isStrict => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssetEditEntityData extends i0.DataClass
|
||||||
|
implements i0.Insertable<i1.AssetEditEntityData> {
|
||||||
|
final String id;
|
||||||
|
final String assetId;
|
||||||
|
final i2.AssetEditAction action;
|
||||||
|
final Map<String, Object?> parameters;
|
||||||
|
final int sequence;
|
||||||
|
const AssetEditEntityData({
|
||||||
|
required this.id,
|
||||||
|
required this.assetId,
|
||||||
|
required this.action,
|
||||||
|
required this.parameters,
|
||||||
|
required this.sequence,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, i0.Expression>{};
|
||||||
|
map['id'] = i0.Variable<String>(id);
|
||||||
|
map['asset_id'] = i0.Variable<String>(assetId);
|
||||||
|
{
|
||||||
|
map['action'] = i0.Variable<int>(
|
||||||
|
i1.$AssetEditEntityTable.$converteraction.toSql(action),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
map['parameters'] = i0.Variable<i3.Uint8List>(
|
||||||
|
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
map['sequence'] = i0.Variable<int>(sequence);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AssetEditEntityData.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
i0.ValueSerializer? serializer,
|
||||||
|
}) {
|
||||||
|
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||||
|
return AssetEditEntityData(
|
||||||
|
id: serializer.fromJson<String>(json['id']),
|
||||||
|
assetId: serializer.fromJson<String>(json['assetId']),
|
||||||
|
action: i1.$AssetEditEntityTable.$converteraction.fromJson(
|
||||||
|
serializer.fromJson<int>(json['action']),
|
||||||
|
),
|
||||||
|
parameters: i1.$AssetEditEntityTable.$converterparameters.fromJson(
|
||||||
|
serializer.fromJson<Object?>(json['parameters']),
|
||||||
|
),
|
||||||
|
sequence: serializer.fromJson<int>(json['sequence']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
||||||
|
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||||
|
return <String, dynamic>{
|
||||||
|
'id': serializer.toJson<String>(id),
|
||||||
|
'assetId': serializer.toJson<String>(assetId),
|
||||||
|
'action': serializer.toJson<int>(
|
||||||
|
i1.$AssetEditEntityTable.$converteraction.toJson(action),
|
||||||
|
),
|
||||||
|
'parameters': serializer.toJson<Object?>(
|
||||||
|
i1.$AssetEditEntityTable.$converterparameters.toJson(parameters),
|
||||||
|
),
|
||||||
|
'sequence': serializer.toJson<int>(sequence),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.AssetEditEntityData copyWith({
|
||||||
|
String? id,
|
||||||
|
String? assetId,
|
||||||
|
i2.AssetEditAction? action,
|
||||||
|
Map<String, Object?>? parameters,
|
||||||
|
int? sequence,
|
||||||
|
}) => i1.AssetEditEntityData(
|
||||||
|
id: id ?? this.id,
|
||||||
|
assetId: assetId ?? this.assetId,
|
||||||
|
action: action ?? this.action,
|
||||||
|
parameters: parameters ?? this.parameters,
|
||||||
|
sequence: sequence ?? this.sequence,
|
||||||
|
);
|
||||||
|
AssetEditEntityData copyWithCompanion(i1.AssetEditEntityCompanion data) {
|
||||||
|
return AssetEditEntityData(
|
||||||
|
id: data.id.present ? data.id.value : this.id,
|
||||||
|
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
||||||
|
action: data.action.present ? data.action.value : this.action,
|
||||||
|
parameters: data.parameters.present
|
||||||
|
? data.parameters.value
|
||||||
|
: this.parameters,
|
||||||
|
sequence: data.sequence.present ? data.sequence.value : this.sequence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('AssetEditEntityData(')
|
||||||
|
..write('id: $id, ')
|
||||||
|
..write('assetId: $assetId, ')
|
||||||
|
..write('action: $action, ')
|
||||||
|
..write('parameters: $parameters, ')
|
||||||
|
..write('sequence: $sequence')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(id, assetId, action, parameters, sequence);
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
(other is i1.AssetEditEntityData &&
|
||||||
|
other.id == this.id &&
|
||||||
|
other.assetId == this.assetId &&
|
||||||
|
other.action == this.action &&
|
||||||
|
other.parameters == this.parameters &&
|
||||||
|
other.sequence == this.sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssetEditEntityCompanion
|
||||||
|
extends i0.UpdateCompanion<i1.AssetEditEntityData> {
|
||||||
|
final i0.Value<String> id;
|
||||||
|
final i0.Value<String> assetId;
|
||||||
|
final i0.Value<i2.AssetEditAction> action;
|
||||||
|
final i0.Value<Map<String, Object?>> parameters;
|
||||||
|
final i0.Value<int> sequence;
|
||||||
|
const AssetEditEntityCompanion({
|
||||||
|
this.id = const i0.Value.absent(),
|
||||||
|
this.assetId = const i0.Value.absent(),
|
||||||
|
this.action = const i0.Value.absent(),
|
||||||
|
this.parameters = const i0.Value.absent(),
|
||||||
|
this.sequence = const i0.Value.absent(),
|
||||||
|
});
|
||||||
|
AssetEditEntityCompanion.insert({
|
||||||
|
required String id,
|
||||||
|
required String assetId,
|
||||||
|
required i2.AssetEditAction action,
|
||||||
|
required Map<String, Object?> parameters,
|
||||||
|
required int sequence,
|
||||||
|
}) : id = i0.Value(id),
|
||||||
|
assetId = i0.Value(assetId),
|
||||||
|
action = i0.Value(action),
|
||||||
|
parameters = i0.Value(parameters),
|
||||||
|
sequence = i0.Value(sequence);
|
||||||
|
static i0.Insertable<i1.AssetEditEntityData> custom({
|
||||||
|
i0.Expression<String>? id,
|
||||||
|
i0.Expression<String>? assetId,
|
||||||
|
i0.Expression<int>? action,
|
||||||
|
i0.Expression<i3.Uint8List>? parameters,
|
||||||
|
i0.Expression<int>? sequence,
|
||||||
|
}) {
|
||||||
|
return i0.RawValuesInsertable({
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
if (assetId != null) 'asset_id': assetId,
|
||||||
|
if (action != null) 'action': action,
|
||||||
|
if (parameters != null) 'parameters': parameters,
|
||||||
|
if (sequence != null) 'sequence': sequence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.AssetEditEntityCompanion copyWith({
|
||||||
|
i0.Value<String>? id,
|
||||||
|
i0.Value<String>? assetId,
|
||||||
|
i0.Value<i2.AssetEditAction>? action,
|
||||||
|
i0.Value<Map<String, Object?>>? parameters,
|
||||||
|
i0.Value<int>? sequence,
|
||||||
|
}) {
|
||||||
|
return i1.AssetEditEntityCompanion(
|
||||||
|
id: id ?? this.id,
|
||||||
|
assetId: assetId ?? this.assetId,
|
||||||
|
action: action ?? this.action,
|
||||||
|
parameters: parameters ?? this.parameters,
|
||||||
|
sequence: sequence ?? this.sequence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, i0.Expression>{};
|
||||||
|
if (id.present) {
|
||||||
|
map['id'] = i0.Variable<String>(id.value);
|
||||||
|
}
|
||||||
|
if (assetId.present) {
|
||||||
|
map['asset_id'] = i0.Variable<String>(assetId.value);
|
||||||
|
}
|
||||||
|
if (action.present) {
|
||||||
|
map['action'] = i0.Variable<int>(
|
||||||
|
i1.$AssetEditEntityTable.$converteraction.toSql(action.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parameters.present) {
|
||||||
|
map['parameters'] = i0.Variable<i3.Uint8List>(
|
||||||
|
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sequence.present) {
|
||||||
|
map['sequence'] = i0.Variable<int>(sequence.value);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('AssetEditEntityCompanion(')
|
||||||
|
..write('id: $id, ')
|
||||||
|
..write('assetId: $assetId, ')
|
||||||
|
..write('action: $action, ')
|
||||||
|
..write('parameters: $parameters, ')
|
||||||
|
..write('sequence: $sequence')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,8 @@ part of 'image_request.dart';
|
|||||||
|
|
||||||
class RemoteImageRequest extends ImageRequest {
|
class RemoteImageRequest extends ImageRequest {
|
||||||
final String uri;
|
final String uri;
|
||||||
final Map<String, String> headers;
|
|
||||||
|
|
||||||
RemoteImageRequest({required this.uri, required this.headers});
|
RemoteImageRequest({required this.uri});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||||
@@ -12,7 +11,7 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false);
|
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
||||||
// Android always returns encoded data, so we need to check for both shapes of the response.
|
// Android always returns encoded data, so we need to check for both shapes of the response.
|
||||||
final frame = switch (info) {
|
final frame = switch (info) {
|
||||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||||
@@ -29,7 +28,7 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true);
|
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true);
|
||||||
if (info == null) return null;
|
if (info == null) return null;
|
||||||
|
|
||||||
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||||
@@ -24,11 +25,10 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
|
||||||
import 'package:isar/isar.dart' hide Index;
|
import 'package:isar/isar.dart' hide Index;
|
||||||
|
|
||||||
import 'db.repository.drift.dart';
|
|
||||||
|
|
||||||
// #zoneTxn is the symbol used by Isar to mark a transaction within the current zone
|
// #zoneTxn is the symbol used by Isar to mark a transaction within the current zone
|
||||||
// ref: isar/isar_common.dart
|
// ref: isar/isar_common.dart
|
||||||
const Symbol _kzoneTxn = #zoneTxn;
|
const Symbol _kzoneTxn = #zoneTxn;
|
||||||
@@ -66,6 +66,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
|
|||||||
AssetFaceEntity,
|
AssetFaceEntity,
|
||||||
StoreEntity,
|
StoreEntity,
|
||||||
TrashedLocalAssetEntity,
|
TrashedLocalAssetEntity,
|
||||||
|
AssetEditEntity,
|
||||||
],
|
],
|
||||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||||
)
|
)
|
||||||
@@ -97,7 +98,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 21;
|
int get schemaVersion => 22;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -234,6 +235,10 @@ class Drift extends $Drift implements IDatabaseRepository {
|
|||||||
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
|
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
|
||||||
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
|
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
|
||||||
},
|
},
|
||||||
|
from21To22: (m, v22) async {
|
||||||
|
await m.createTable(v22.assetEditEntity);
|
||||||
|
await m.createIndex(v22.idxAssetEditAssetId);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+19
-4
@@ -41,9 +41,11 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
|
|||||||
as i19;
|
as i19;
|
||||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
|
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
|
||||||
as i20;
|
as i20;
|
||||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
|
||||||
as i21;
|
as i21;
|
||||||
import 'package:drift/internal/modular.dart' as i22;
|
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||||
|
as i22;
|
||||||
|
import 'package:drift/internal/modular.dart' as i23;
|
||||||
|
|
||||||
abstract class $Drift extends i0.GeneratedDatabase {
|
abstract class $Drift extends i0.GeneratedDatabase {
|
||||||
$Drift(i0.QueryExecutor e) : super(e);
|
$Drift(i0.QueryExecutor e) : super(e);
|
||||||
@@ -85,9 +87,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
|
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
|
||||||
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
|
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
|
||||||
.$TrashedLocalAssetEntityTable(this);
|
.$TrashedLocalAssetEntityTable(this);
|
||||||
i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer(
|
late final i21.$AssetEditEntityTable assetEditEntity = i21
|
||||||
|
.$AssetEditEntityTable(this);
|
||||||
|
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
|
||||||
this,
|
this,
|
||||||
).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new);
|
).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
|
||||||
@override
|
@override
|
||||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||||
@@ -125,6 +129,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
assetFaceEntity,
|
assetFaceEntity,
|
||||||
storeEntity,
|
storeEntity,
|
||||||
trashedLocalAssetEntity,
|
trashedLocalAssetEntity,
|
||||||
|
assetEditEntity,
|
||||||
i10.idxPartnerSharedWithId,
|
i10.idxPartnerSharedWithId,
|
||||||
i11.idxLatLng,
|
i11.idxLatLng,
|
||||||
i12.idxRemoteAlbumAssetAlbumAsset,
|
i12.idxRemoteAlbumAssetAlbumAsset,
|
||||||
@@ -134,6 +139,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
i18.idxAssetFaceAssetId,
|
i18.idxAssetFaceAssetId,
|
||||||
i20.idxTrashedLocalAssetChecksum,
|
i20.idxTrashedLocalAssetChecksum,
|
||||||
i20.idxTrashedLocalAssetAlbum,
|
i20.idxTrashedLocalAssetAlbum,
|
||||||
|
i21.idxAssetEditAssetId,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
i0.StreamQueryUpdateRules
|
i0.StreamQueryUpdateRules
|
||||||
@@ -325,6 +331,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
),
|
),
|
||||||
result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)],
|
result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)],
|
||||||
),
|
),
|
||||||
|
i0.WritePropagation(
|
||||||
|
on: i0.TableUpdateQuery.onTableName(
|
||||||
|
'remote_asset_entity',
|
||||||
|
limitUpdateKind: i0.UpdateKind.delete,
|
||||||
|
),
|
||||||
|
result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)],
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
@override
|
@override
|
||||||
i0.DriftDatabaseOptions get options =>
|
i0.DriftDatabaseOptions get options =>
|
||||||
@@ -384,4 +397,6 @@ class $DriftManager {
|
|||||||
_db,
|
_db,
|
||||||
_db.trashedLocalAssetEntity,
|
_db.trashedLocalAssetEntity,
|
||||||
);
|
);
|
||||||
|
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
|
||||||
|
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9489,6 +9489,565 @@ class Shape31 extends i0.VersionedTable {
|
|||||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class Schema22 extends i0.VersionedSchema {
|
||||||
|
Schema22({required super.database}) : super(version: 22);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
userEntity,
|
||||||
|
remoteAssetEntity,
|
||||||
|
stackEntity,
|
||||||
|
localAssetEntity,
|
||||||
|
remoteAlbumEntity,
|
||||||
|
localAlbumEntity,
|
||||||
|
localAlbumAssetEntity,
|
||||||
|
idxLocalAlbumAssetAlbumAsset,
|
||||||
|
idxRemoteAlbumOwnerId,
|
||||||
|
idxLocalAssetChecksum,
|
||||||
|
idxLocalAssetCloudId,
|
||||||
|
idxStackPrimaryAssetId,
|
||||||
|
idxRemoteAssetOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerLibraryChecksum,
|
||||||
|
idxRemoteAssetChecksum,
|
||||||
|
idxRemoteAssetStackId,
|
||||||
|
idxRemoteAssetLocalDateTimeDay,
|
||||||
|
idxRemoteAssetLocalDateTimeMonth,
|
||||||
|
authUserEntity,
|
||||||
|
userMetadataEntity,
|
||||||
|
partnerEntity,
|
||||||
|
remoteExifEntity,
|
||||||
|
remoteAlbumAssetEntity,
|
||||||
|
remoteAlbumUserEntity,
|
||||||
|
remoteAssetCloudIdEntity,
|
||||||
|
memoryEntity,
|
||||||
|
memoryAssetEntity,
|
||||||
|
personEntity,
|
||||||
|
assetFaceEntity,
|
||||||
|
storeEntity,
|
||||||
|
trashedLocalAssetEntity,
|
||||||
|
assetEditEntity,
|
||||||
|
idxPartnerSharedWithId,
|
||||||
|
idxLatLng,
|
||||||
|
idxRemoteAlbumAssetAlbumAsset,
|
||||||
|
idxRemoteAssetCloudId,
|
||||||
|
idxPersonOwnerId,
|
||||||
|
idxAssetFacePersonId,
|
||||||
|
idxAssetFaceAssetId,
|
||||||
|
idxTrashedLocalAssetChecksum,
|
||||||
|
idxTrashedLocalAssetAlbum,
|
||||||
|
idxAssetEditAssetId,
|
||||||
|
];
|
||||||
|
late final Shape20 userEntity = Shape20(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_3,
|
||||||
|
_column_84,
|
||||||
|
_column_85,
|
||||||
|
_column_91,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape28 remoteAssetEntity = Shape28(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_13,
|
||||||
|
_column_14,
|
||||||
|
_column_15,
|
||||||
|
_column_16,
|
||||||
|
_column_17,
|
||||||
|
_column_18,
|
||||||
|
_column_19,
|
||||||
|
_column_20,
|
||||||
|
_column_21,
|
||||||
|
_column_86,
|
||||||
|
_column_101,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape3 stackEntity = Shape3(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'stack_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape30 localAssetEntity = Shape30(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_22,
|
||||||
|
_column_14,
|
||||||
|
_column_23,
|
||||||
|
_column_98,
|
||||||
|
_column_96,
|
||||||
|
_column_46,
|
||||||
|
_column_47,
|
||||||
|
_column_103,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape9 remoteAlbumEntity = Shape9(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_56,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_15,
|
||||||
|
_column_57,
|
||||||
|
_column_58,
|
||||||
|
_column_59,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape19 localAlbumEntity = Shape19(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_5,
|
||||||
|
_column_31,
|
||||||
|
_column_32,
|
||||||
|
_column_90,
|
||||||
|
_column_33,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_34, _column_35, _column_33],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||||
|
'idx_local_album_asset_album_asset',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
|
||||||
|
'idx_remote_album_owner_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||||
|
'idx_local_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||||
|
'idx_local_asset_cloud_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||||
|
'idx_stack_primary_asset_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||||
|
'idx_remote_asset_owner_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||||
|
'UQ_remote_assets_owner_checksum',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||||
|
);
|
||||||
|
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||||
|
'UQ_remote_assets_owner_library_checksum',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||||
|
'idx_remote_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||||
|
'idx_remote_asset_stack_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
|
||||||
|
'idx_remote_asset_local_date_time_day',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
|
||||||
|
'idx_remote_asset_local_date_time_month',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
|
||||||
|
);
|
||||||
|
late final Shape21 authUserEntity = Shape21(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'auth_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_3,
|
||||||
|
_column_2,
|
||||||
|
_column_84,
|
||||||
|
_column_85,
|
||||||
|
_column_92,
|
||||||
|
_column_93,
|
||||||
|
_column_7,
|
||||||
|
_column_94,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape4 userMetadataEntity = Shape4(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_metadata_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||||
|
columns: [_column_25, _column_26, _column_27],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape5 partnerEntity = Shape5(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'partner_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||||
|
columns: [_column_28, _column_29, _column_30],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape8 remoteExifEntity = Shape8(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_exif_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_36,
|
||||||
|
_column_37,
|
||||||
|
_column_38,
|
||||||
|
_column_39,
|
||||||
|
_column_40,
|
||||||
|
_column_41,
|
||||||
|
_column_11,
|
||||||
|
_column_10,
|
||||||
|
_column_42,
|
||||||
|
_column_43,
|
||||||
|
_column_44,
|
||||||
|
_column_45,
|
||||||
|
_column_46,
|
||||||
|
_column_47,
|
||||||
|
_column_48,
|
||||||
|
_column_49,
|
||||||
|
_column_50,
|
||||||
|
_column_51,
|
||||||
|
_column_52,
|
||||||
|
_column_53,
|
||||||
|
_column_54,
|
||||||
|
_column_55,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_36, _column_60],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||||
|
columns: [_column_60, _column_25, _column_61],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape27 remoteAssetCloudIdEntity = Shape27(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_asset_cloud_id_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_36,
|
||||||
|
_column_99,
|
||||||
|
_column_100,
|
||||||
|
_column_96,
|
||||||
|
_column_46,
|
||||||
|
_column_47,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape11 memoryEntity = Shape11(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_18,
|
||||||
|
_column_15,
|
||||||
|
_column_8,
|
||||||
|
_column_62,
|
||||||
|
_column_63,
|
||||||
|
_column_64,
|
||||||
|
_column_65,
|
||||||
|
_column_66,
|
||||||
|
_column_67,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape12 memoryAssetEntity = Shape12(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||||
|
columns: [_column_36, _column_68],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape14 personEntity = Shape14(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'person_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_15,
|
||||||
|
_column_1,
|
||||||
|
_column_69,
|
||||||
|
_column_71,
|
||||||
|
_column_72,
|
||||||
|
_column_73,
|
||||||
|
_column_74,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape29 assetFaceEntity = Shape29(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'asset_face_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_36,
|
||||||
|
_column_76,
|
||||||
|
_column_77,
|
||||||
|
_column_78,
|
||||||
|
_column_79,
|
||||||
|
_column_80,
|
||||||
|
_column_81,
|
||||||
|
_column_82,
|
||||||
|
_column_83,
|
||||||
|
_column_102,
|
||||||
|
_column_18,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape18 storeEntity = Shape18(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'store_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_87, _column_88, _column_89],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape31 trashedLocalAssetEntity = Shape31(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'trashed_local_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_95,
|
||||||
|
_column_22,
|
||||||
|
_column_14,
|
||||||
|
_column_23,
|
||||||
|
_column_97,
|
||||||
|
_column_103,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape32 assetEditEntity = Shape32(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'asset_edit_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_0, _column_36, _column_104, _column_105, _column_106],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||||
|
'idx_partner_shared_with_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxLatLng = i1.Index(
|
||||||
|
'idx_lat_lng',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||||
|
'idx_remote_album_asset_album_asset',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||||
|
'idx_remote_asset_cloud_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxPersonOwnerId = i1.Index(
|
||||||
|
'idx_person_owner_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||||
|
'idx_asset_face_person_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||||
|
'idx_asset_face_asset_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||||
|
'idx_trashed_local_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||||
|
'idx_trashed_local_asset_album',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||||
|
'idx_asset_edit_asset_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape32 extends i0.VersionedTable {
|
||||||
|
Shape32({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<String> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get assetId =>
|
||||||
|
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get action =>
|
||||||
|
columnsByName['action']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<i2.Uint8List> get parameters =>
|
||||||
|
columnsByName['parameters']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||||
|
i1.GeneratedColumn<int> get sequence =>
|
||||||
|
columnsByName['sequence']! as i1.GeneratedColumn<int>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<int> _column_104(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'action',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<i2.Uint8List> _column_105(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<i2.Uint8List>(
|
||||||
|
'parameters',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.blob,
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<int> _column_106(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'sequence',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
);
|
||||||
i0.MigrationStepWithVersion migrationSteps({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
@@ -9510,6 +10069,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
@@ -9613,6 +10173,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from20To21(migrator, schema);
|
await from20To21(migrator, schema);
|
||||||
return 21;
|
return 21;
|
||||||
|
case 21:
|
||||||
|
final schema = Schema22(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from21To22(migrator, schema);
|
||||||
|
return 22;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
@@ -9640,6 +10205,7 @@ i1.OnUpgrade stepByStep({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
@@ -9662,5 +10228,6 @@ i1.OnUpgrade stepByStep({
|
|||||||
from18To19: from18To19,
|
from18To19: from18To19,
|
||||||
from19To20: from19To20,
|
from19To20: from19To20,
|
||||||
from20To21: from20To21,
|
from20To21: from20To21,
|
||||||
|
from21To22: from21To22,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
|
||||||
import 'logger_db.repository.drift.dart';
|
|
||||||
|
|
||||||
@DriftDatabase(tables: [LogMessageEntity])
|
@DriftDatabase(tables: [LogMessageEntity])
|
||||||
class DriftLogger extends $DriftLogger implements IDatabaseRepository {
|
class DriftLogger extends $DriftLogger implements IDatabaseRepository {
|
||||||
|
|||||||
@@ -1,67 +1,55 @@
|
|||||||
|
import 'dart:ffi';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:cronet_http/cronet_http.dart';
|
|
||||||
import 'package:cupertino_http/cupertino_http.dart';
|
import 'package:cupertino_http/cupertino_http.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:immich_mobile/utils/user_agent.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:ok_http/ok_http.dart';
|
||||||
|
import 'package:web_socket/web_socket.dart';
|
||||||
|
|
||||||
class NetworkRepository {
|
class NetworkRepository {
|
||||||
static late Directory _cachePath;
|
static http.Client? _client;
|
||||||
static late String _userAgent;
|
static Pointer<Void>? _clientPointer;
|
||||||
static final _clients = <String, http.Client>{};
|
|
||||||
|
|
||||||
static Future<void> init() {
|
static Future<void> init() async {
|
||||||
return (
|
final clientPointer = Pointer<Void>.fromAddress(await networkApi.getClientPointer());
|
||||||
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath),
|
if (clientPointer == _clientPointer) {
|
||||||
getUserAgentString().then((userAgent) => _userAgent = userAgent),
|
return;
|
||||||
).wait;
|
}
|
||||||
|
_clientPointer = clientPointer;
|
||||||
|
_client?.close();
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
final session = URLSession.fromRawPointer(clientPointer.cast());
|
||||||
|
_client = CupertinoClient.fromSharedSession(session);
|
||||||
|
} else {
|
||||||
|
_client = OkHttpClient.fromJniGlobalRef(clientPointer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void reset() {
|
static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls) async {
|
||||||
Future.microtask(init);
|
await networkApi.setRequestHeaders(headers, serverUrls);
|
||||||
for (final client in _clients.values) {
|
if (Platform.isIOS) {
|
||||||
client.close();
|
await init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: avoid-unused-parameters
|
||||||
|
static Future<WebSocket> createWebSocket(Uri uri, {Map<String, String>? headers, Iterable<String>? protocols}) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
final session = URLSession.fromRawPointer(_clientPointer!.cast());
|
||||||
|
return CupertinoWebSocket.connectWithSession(session, uri, protocols: protocols);
|
||||||
|
} else {
|
||||||
|
return OkHttpWebSocket.connectFromJniGlobalRef(_clientPointer!, uri, protocols: protocols);
|
||||||
}
|
}
|
||||||
_clients.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NetworkRepository();
|
const NetworkRepository();
|
||||||
|
|
||||||
/// Note: when disk caching is enabled, only one client may use a given directory at a time.
|
/// Returns a shared HTTP client that uses native SSL configuration.
|
||||||
/// Different isolates or engines must use different directories.
|
///
|
||||||
http.Client getHttpClient(
|
/// On iOS: Uses SharedURLSessionManager's URLSession.
|
||||||
String directoryName, {
|
/// On Android: Uses SharedHttpClientManager's OkHttpClient.
|
||||||
CacheMode cacheMode = CacheMode.memory,
|
///
|
||||||
int diskCapacity = 0,
|
/// Must call [init] before using this method.
|
||||||
int maxConnections = 6,
|
static http.Client get client => _client!;
|
||||||
int memoryCapacity = 10 << 20,
|
|
||||||
}) {
|
|
||||||
final cachedClient = _clients[directoryName];
|
|
||||||
if (cachedClient != null) {
|
|
||||||
return cachedClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
final directory = Directory('${_cachePath.path}/$directoryName');
|
|
||||||
directory.createSync(recursive: true);
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
final engine = CronetEngine.build(
|
|
||||||
cacheMode: cacheMode,
|
|
||||||
cacheMaxSize: diskCapacity,
|
|
||||||
storagePath: directory.path,
|
|
||||||
userAgent: _userAgent,
|
|
||||||
);
|
|
||||||
return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
final config = URLSessionConfiguration.defaultSessionConfiguration()
|
|
||||||
..httpMaximumConnectionsPerHost = maxConnections
|
|
||||||
..cache = URLCache.withCapacity(
|
|
||||||
diskCapacity: diskCapacity,
|
|
||||||
memoryCapacity: memoryCapacity,
|
|
||||||
directory: directory.uri,
|
|
||||||
)
|
|
||||||
..httpAdditionalHeaders = {'User-Agent': _userAgent};
|
|
||||||
return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart';
|
|||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/utils/semver.dart';
|
import 'package:immich_mobile/utils/semver.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -32,15 +33,11 @@ class SyncApiRepository {
|
|||||||
http.Client? httpClient,
|
http.Client? httpClient,
|
||||||
}) async {
|
}) async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final client = httpClient ?? http.Client();
|
final client = httpClient ?? NetworkRepository.client;
|
||||||
final endpoint = "${_api.apiClient.basePath}/sync/stream";
|
final endpoint = "${_api.apiClient.basePath}/sync/stream";
|
||||||
|
|
||||||
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
|
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
|
||||||
|
|
||||||
final headerParams = <String, String>{};
|
|
||||||
await _api.applyToParams([], headerParams);
|
|
||||||
headers.addAll(headerParams);
|
|
||||||
|
|
||||||
final shouldReset = Store.get(StoreKey.shouldResetSync, false);
|
final shouldReset = Store.get(StoreKey.shouldResetSync, false);
|
||||||
final request = http.Request('POST', Uri.parse(endpoint));
|
final request = http.Request('POST', Uri.parse(endpoint));
|
||||||
request.headers.addAll(headers);
|
request.headers.addAll(headers);
|
||||||
@@ -51,6 +48,7 @@ class SyncApiRepository {
|
|||||||
SyncRequestType.usersV1,
|
SyncRequestType.usersV1,
|
||||||
SyncRequestType.assetsV1,
|
SyncRequestType.assetsV1,
|
||||||
SyncRequestType.assetExifsV1,
|
SyncRequestType.assetExifsV1,
|
||||||
|
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
|
||||||
SyncRequestType.assetMetadataV1,
|
SyncRequestType.assetMetadataV1,
|
||||||
SyncRequestType.partnersV1,
|
SyncRequestType.partnersV1,
|
||||||
SyncRequestType.partnerAssetsV1,
|
SyncRequestType.partnerAssetsV1,
|
||||||
@@ -119,8 +117,6 @@ class SyncApiRepository {
|
|||||||
}
|
}
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
return Future.error(error, stack);
|
return Future.error(error, stack);
|
||||||
} finally {
|
|
||||||
client.close();
|
|
||||||
}
|
}
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
|
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
|
||||||
@@ -156,6 +152,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
|
|||||||
SyncEntityType.assetV1: SyncAssetV1.fromJson,
|
SyncEntityType.assetV1: SyncAssetV1.fromJson,
|
||||||
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
|
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
|
||||||
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
|
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
|
||||||
|
SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson,
|
||||||
|
SyncEntityType.assetEditDeleteV1: SyncAssetEditDeleteV1.fromJson,
|
||||||
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
|
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
|
||||||
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
|
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
|
||||||
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
|
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||||
@@ -26,8 +28,8 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
|
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction;
|
||||||
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
|
import 'package:openapi/api.dart' hide AlbumUserRole, UserMetadataKey, AssetEditAction, AssetVisibility;
|
||||||
|
|
||||||
class SyncStreamRepository extends DriftDatabaseRepository {
|
class SyncStreamRepository extends DriftDatabaseRepository {
|
||||||
final Logger _logger = Logger('DriftSyncStreamRepository');
|
final Logger _logger = Logger('DriftSyncStreamRepository');
|
||||||
@@ -58,6 +60,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
await _db.userEntity.deleteAll();
|
await _db.userEntity.deleteAll();
|
||||||
await _db.userMetadataEntity.deleteAll();
|
await _db.userMetadataEntity.deleteAll();
|
||||||
await _db.remoteAssetCloudIdEntity.deleteAll();
|
await _db.remoteAssetCloudIdEntity.deleteAll();
|
||||||
|
await _db.assetEditEntity.deleteAll();
|
||||||
});
|
});
|
||||||
await _db.customStatement('PRAGMA foreign_keys = ON');
|
await _db.customStatement('PRAGMA foreign_keys = ON');
|
||||||
});
|
});
|
||||||
@@ -322,6 +325,63 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateAssetEditsV1(Iterable<SyncAssetEditV1> data, {String debugLabel = 'user'}) async {
|
||||||
|
try {
|
||||||
|
await _db.batch((batch) {
|
||||||
|
for (final edit in data) {
|
||||||
|
final companion = AssetEditEntityCompanion(
|
||||||
|
id: Value(edit.id),
|
||||||
|
assetId: Value(edit.assetId),
|
||||||
|
action: Value(edit.action.toAssetEditAction()),
|
||||||
|
parameters: Value(edit.parameters as Map<String, Object?>),
|
||||||
|
sequence: Value(edit.sequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
batch.insert(_db.assetEditEntity, companion, onConflict: DoUpdate((_) => companion));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error, stack) {
|
||||||
|
_logger.severe('Error: updateAssetEditsV1 - $debugLabel', error, stack);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceAssetEditsV1(String assetId, Iterable<SyncAssetEditV1> data, {String debugLabel = 'user'}) async {
|
||||||
|
try {
|
||||||
|
await _db.batch((batch) {
|
||||||
|
batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(assetId));
|
||||||
|
|
||||||
|
for (final edit in data) {
|
||||||
|
final companion = AssetEditEntityCompanion(
|
||||||
|
id: Value(edit.id),
|
||||||
|
assetId: Value(edit.assetId),
|
||||||
|
action: Value(edit.action.toAssetEditAction()),
|
||||||
|
parameters: Value(edit.parameters as Map<String, Object?>),
|
||||||
|
sequence: Value(edit.sequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
batch.insert(_db.assetEditEntity, companion);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error, stack) {
|
||||||
|
_logger.severe('Error: replaceAssetEditsV1 - $debugLabel', error, stack);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAssetEditsV1(Iterable<SyncAssetEditDeleteV1> data, {String debugLabel = 'user'}) async {
|
||||||
|
try {
|
||||||
|
await _db.batch((batch) {
|
||||||
|
for (final edit in data) {
|
||||||
|
batch.deleteWhere(_db.assetEditEntity, (row) => row.id.equals(edit.editId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error, stack) {
|
||||||
|
_logger.severe('Error: deleteAssetEditsV1 - $debugLabel', error, stack);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
|
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.batch((batch) {
|
await _db.batch((batch) {
|
||||||
@@ -798,3 +858,12 @@ extension on String {
|
|||||||
extension on UserAvatarColor {
|
extension on UserAvatarColor {
|
||||||
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value);
|
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension on api.AssetEditAction {
|
||||||
|
AssetEditAction toAssetEditAction() => switch (this) {
|
||||||
|
api.AssetEditAction.crop => AssetEditAction.crop,
|
||||||
|
api.AssetEditAction.rotate => AssetEditAction.rotate,
|
||||||
|
api.AssetEditAction.mirror => AssetEditAction.mirror,
|
||||||
|
_ => AssetEditAction.other,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -276,6 +276,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
origin: origin,
|
origin: origin,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
TimelineQuery fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin origin) =>
|
||||||
|
(
|
||||||
|
bucketSource: () async* {
|
||||||
|
yield _generateBuckets(getAssets().length);
|
||||||
|
yield* assetCount.map(_generateBuckets);
|
||||||
|
},
|
||||||
|
assetSource: (offset, count) {
|
||||||
|
final assets = getAssets();
|
||||||
|
return Future.value(assets.skip(offset).take(count).toList(growable: false));
|
||||||
|
},
|
||||||
|
origin: origin,
|
||||||
|
);
|
||||||
|
|
||||||
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
|
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
|
||||||
// Sort assets by date descending and group by day
|
// Sort assets by date descending and group by day
|
||||||
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import 'package:immich_mobile/theme/theme_data.dart';
|
|||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
|
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
|
||||||
import 'package:immich_mobile/utils/licenses.dart';
|
import 'package:immich_mobile/utils/licenses.dart';
|
||||||
import 'package:immich_mobile/utils/migration.dart';
|
import 'package:immich_mobile/utils/migration.dart';
|
||||||
import 'package:immich_mobile/wm_executor.dart';
|
import 'package:immich_mobile/wm_executor.dart';
|
||||||
@@ -60,7 +59,6 @@ void main() async {
|
|||||||
// Warm-up isolate pool for worker manager
|
// Warm-up isolate pool for worker manager
|
||||||
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
|
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
|
||||||
await migrateDatabaseIfNeeded(isar, drift);
|
await migrateDatabaseIfNeeded(isar, drift);
|
||||||
HttpSSLOptions.apply();
|
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
@@ -246,7 +244,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
|||||||
@override
|
@override
|
||||||
void reassemble() {
|
void reassemble() {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
NetworkRepository.reset();
|
NetworkRepository.init();
|
||||||
}
|
}
|
||||||
super.reassemble();
|
super.reassemble();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||||
|
|
||||||
import 'package:cancellation_token_http/http.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ class BackUpState {
|
|||||||
final DateTime progressInFileSpeedUpdateTime;
|
final DateTime progressInFileSpeedUpdateTime;
|
||||||
final int progressInFileSpeedUpdateSentBytes;
|
final int progressInFileSpeedUpdateSentBytes;
|
||||||
final double iCloudDownloadProgress;
|
final double iCloudDownloadProgress;
|
||||||
final CancellationToken cancelToken;
|
|
||||||
final ServerDiskInfo serverInfo;
|
final ServerDiskInfo serverInfo;
|
||||||
final bool autoBackup;
|
final bool autoBackup;
|
||||||
final bool backgroundBackup;
|
final bool backgroundBackup;
|
||||||
@@ -53,7 +51,6 @@ class BackUpState {
|
|||||||
required this.progressInFileSpeedUpdateTime,
|
required this.progressInFileSpeedUpdateTime,
|
||||||
required this.progressInFileSpeedUpdateSentBytes,
|
required this.progressInFileSpeedUpdateSentBytes,
|
||||||
required this.iCloudDownloadProgress,
|
required this.iCloudDownloadProgress,
|
||||||
required this.cancelToken,
|
|
||||||
required this.serverInfo,
|
required this.serverInfo,
|
||||||
required this.autoBackup,
|
required this.autoBackup,
|
||||||
required this.backgroundBackup,
|
required this.backgroundBackup,
|
||||||
@@ -78,7 +75,6 @@ class BackUpState {
|
|||||||
DateTime? progressInFileSpeedUpdateTime,
|
DateTime? progressInFileSpeedUpdateTime,
|
||||||
int? progressInFileSpeedUpdateSentBytes,
|
int? progressInFileSpeedUpdateSentBytes,
|
||||||
double? iCloudDownloadProgress,
|
double? iCloudDownloadProgress,
|
||||||
CancellationToken? cancelToken,
|
|
||||||
ServerDiskInfo? serverInfo,
|
ServerDiskInfo? serverInfo,
|
||||||
bool? autoBackup,
|
bool? autoBackup,
|
||||||
bool? backgroundBackup,
|
bool? backgroundBackup,
|
||||||
@@ -102,7 +98,6 @@ class BackUpState {
|
|||||||
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
|
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
|
||||||
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
|
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
|
||||||
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
|
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
|
||||||
cancelToken: cancelToken ?? this.cancelToken,
|
|
||||||
serverInfo: serverInfo ?? this.serverInfo,
|
serverInfo: serverInfo ?? this.serverInfo,
|
||||||
autoBackup: autoBackup ?? this.autoBackup,
|
autoBackup: autoBackup ?? this.autoBackup,
|
||||||
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
|
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
|
||||||
@@ -120,7 +115,7 @@ class BackUpState {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -137,7 +132,6 @@ class BackUpState {
|
|||||||
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
|
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
|
||||||
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
|
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
|
||||||
other.iCloudDownloadProgress == iCloudDownloadProgress &&
|
other.iCloudDownloadProgress == iCloudDownloadProgress &&
|
||||||
other.cancelToken == cancelToken &&
|
|
||||||
other.serverInfo == serverInfo &&
|
other.serverInfo == serverInfo &&
|
||||||
other.autoBackup == autoBackup &&
|
other.autoBackup == autoBackup &&
|
||||||
other.backgroundBackup == backgroundBackup &&
|
other.backgroundBackup == backgroundBackup &&
|
||||||
@@ -163,7 +157,6 @@ class BackUpState {
|
|||||||
progressInFileSpeedUpdateTime.hashCode ^
|
progressInFileSpeedUpdateTime.hashCode ^
|
||||||
progressInFileSpeedUpdateSentBytes.hashCode ^
|
progressInFileSpeedUpdateSentBytes.hashCode ^
|
||||||
iCloudDownloadProgress.hashCode ^
|
iCloudDownloadProgress.hashCode ^
|
||||||
cancelToken.hashCode ^
|
|
||||||
serverInfo.hashCode ^
|
serverInfo.hashCode ^
|
||||||
autoBackup.hashCode ^
|
autoBackup.hashCode ^
|
||||||
backgroundBackup.hashCode ^
|
backgroundBackup.hashCode ^
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import 'package:cancellation_token_http/http.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
|
|
||||||
class ManualUploadState {
|
class ManualUploadState {
|
||||||
final CancellationToken cancelToken;
|
|
||||||
|
|
||||||
// Current Backup Asset
|
// Current Backup Asset
|
||||||
final CurrentUploadAsset currentUploadAsset;
|
final CurrentUploadAsset currentUploadAsset;
|
||||||
final int currentAssetIndex;
|
final int currentAssetIndex;
|
||||||
@@ -29,7 +26,6 @@ class ManualUploadState {
|
|||||||
required this.progressInFileSpeeds,
|
required this.progressInFileSpeeds,
|
||||||
required this.progressInFileSpeedUpdateTime,
|
required this.progressInFileSpeedUpdateTime,
|
||||||
required this.progressInFileSpeedUpdateSentBytes,
|
required this.progressInFileSpeedUpdateSentBytes,
|
||||||
required this.cancelToken,
|
|
||||||
required this.currentUploadAsset,
|
required this.currentUploadAsset,
|
||||||
required this.totalAssetsToUpload,
|
required this.totalAssetsToUpload,
|
||||||
required this.currentAssetIndex,
|
required this.currentAssetIndex,
|
||||||
@@ -44,7 +40,6 @@ class ManualUploadState {
|
|||||||
List<double>? progressInFileSpeeds,
|
List<double>? progressInFileSpeeds,
|
||||||
DateTime? progressInFileSpeedUpdateTime,
|
DateTime? progressInFileSpeedUpdateTime,
|
||||||
int? progressInFileSpeedUpdateSentBytes,
|
int? progressInFileSpeedUpdateSentBytes,
|
||||||
CancellationToken? cancelToken,
|
|
||||||
CurrentUploadAsset? currentUploadAsset,
|
CurrentUploadAsset? currentUploadAsset,
|
||||||
int? totalAssetsToUpload,
|
int? totalAssetsToUpload,
|
||||||
int? successfulUploads,
|
int? successfulUploads,
|
||||||
@@ -58,7 +53,6 @@ class ManualUploadState {
|
|||||||
progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds,
|
progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds,
|
||||||
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
|
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
|
||||||
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
|
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
|
||||||
cancelToken: cancelToken ?? this.cancelToken,
|
|
||||||
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
|
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
|
||||||
totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload,
|
totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload,
|
||||||
currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex,
|
currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex,
|
||||||
@@ -69,7 +63,7 @@ class ManualUploadState {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)';
|
return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -84,7 +78,6 @@ class ManualUploadState {
|
|||||||
collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) &&
|
collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) &&
|
||||||
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
|
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
|
||||||
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
|
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
|
||||||
other.cancelToken == cancelToken &&
|
|
||||||
other.currentUploadAsset == currentUploadAsset &&
|
other.currentUploadAsset == currentUploadAsset &&
|
||||||
other.totalAssetsToUpload == totalAssetsToUpload &&
|
other.totalAssetsToUpload == totalAssetsToUpload &&
|
||||||
other.currentAssetIndex == currentAssetIndex &&
|
other.currentAssetIndex == currentAssetIndex &&
|
||||||
@@ -100,7 +93,6 @@ class ManualUploadState {
|
|||||||
progressInFileSpeeds.hashCode ^
|
progressInFileSpeeds.hashCode ^
|
||||||
progressInFileSpeedUpdateTime.hashCode ^
|
progressInFileSpeedUpdateTime.hashCode ^
|
||||||
progressInFileSpeedUpdateSentBytes.hashCode ^
|
progressInFileSpeedUpdateSentBytes.hashCode ^
|
||||||
cancelToken.hashCode ^
|
|
||||||
currentUploadAsset.hashCode ^
|
currentUploadAsset.hashCode ^
|
||||||
totalAssetsToUpload.hashCode ^
|
totalAssetsToUpload.hashCode ^
|
||||||
currentAssetIndex.hashCode ^
|
currentAssetIndex.hashCode ^
|
||||||
|
|||||||
@@ -96,10 +96,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
await backupNotifier.startForegroundBackup(currentUser.id);
|
await backupNotifier.startForegroundBackup(currentUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopBackup() async {
|
|
||||||
await backupNotifier.stopForegroundBackup();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -136,9 +132,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
BackupToggleButton(
|
BackupToggleButton(
|
||||||
onStart: () async => await startBackup(),
|
onStart: () async => await startBackup(),
|
||||||
onStop: () async {
|
onStop: () {
|
||||||
syncSuccess = null;
|
syncSuccess = null;
|
||||||
await stopBackup();
|
backupNotifier.stopForegroundBackup();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
switch (error) {
|
switch (error) {
|
||||||
|
|||||||
@@ -112,16 +112,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||||||
// Waits for hashing to be cancelled before starting a new one
|
// Waits for hashing to be cancelled before starting a new one
|
||||||
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
|
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
|
||||||
if (isBackupEnabled) {
|
if (isBackupEnabled) {
|
||||||
|
backupNotifier.stopForegroundBackup();
|
||||||
unawaited(
|
unawaited(
|
||||||
backupNotifier.stopForegroundBackup().whenComplete(
|
backgroundSync.syncRemote().then((success) {
|
||||||
() => backgroundSync.syncRemote().then((success) {
|
if (success) {
|
||||||
if (success) {
|
return backupNotifier.startForegroundBackup(user.id);
|
||||||
return backupNotifier.startForegroundBackup(user.id);
|
} else {
|
||||||
} else {
|
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
|
||||||
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
|
}
|
||||||
}
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,16 +59,15 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
|||||||
|
|
||||||
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
final backupNotifier = ref.read(driftBackupProvider.notifier);
|
||||||
final backgroundSync = ref.read(backgroundSyncProvider);
|
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||||
|
backupNotifier.stopForegroundBackup();
|
||||||
unawaited(
|
unawaited(
|
||||||
backupNotifier.stopForegroundBackup().whenComplete(
|
backgroundSync.syncRemote().then((success) {
|
||||||
() => backgroundSync.syncRemote().then((success) {
|
if (success) {
|
||||||
if (success) {
|
return backupNotifier.startForegroundBackup(currentUser.id);
|
||||||
return backupNotifier.startForegroundBackup(currentUser.id);
|
} else {
|
||||||
} else {
|
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
|
||||||
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
|
}
|
||||||
}
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.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/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/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
@@ -367,9 +366,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
stackIndex.value = 0;
|
stackIndex.value = 0;
|
||||||
|
|
||||||
ref.read(currentAssetProvider.notifier).set(newAsset);
|
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
|
// Wait for page change animation to finish, then precache the next image
|
||||||
Timer(const Duration(milliseconds: 400), () {
|
Timer(const Duration(milliseconds: 400), () {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/generated/translations.g.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
|
|
||||||
class SettingsHeader {
|
class SettingsHeader {
|
||||||
String key = "";
|
String key = "";
|
||||||
@@ -20,7 +21,6 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
// final apiService = ref.watch(apiServiceProvider);
|
|
||||||
final headers = useState<List<SettingsHeader>>([]);
|
final headers = useState<List<SettingsHeader>>([]);
|
||||||
final setInitialHeaders = useState(false);
|
final setInitialHeaders = useState(false);
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: PopScope(
|
body: PopScope(
|
||||||
onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value),
|
onPopInvokedWithResult: (didPop, _) => saveHeaders(ref, headers.value),
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
|
||||||
itemCount: list.length,
|
itemCount: list.length,
|
||||||
@@ -87,7 +87,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveHeaders(List<SettingsHeader> headers) {
|
saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async {
|
||||||
final headersMap = {};
|
final headersMap = {};
|
||||||
for (var header in headers) {
|
for (var header in headers) {
|
||||||
final key = header.key.trim();
|
final key = header.key.trim();
|
||||||
@@ -98,7 +98,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var encoded = jsonEncode(headersMap);
|
var encoded = jsonEncode(headersMap);
|
||||||
Store.put(StoreKey.customHeaders, encoded);
|
await Store.put(StoreKey.customHeaders, encoded);
|
||||||
|
await ref.read(apiServiceProvider).updateHeaders();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.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/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_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/cast.provider.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/asset.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:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:native_video_player/native_video_player.dart';
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class NativeVideoViewerPage extends HookConsumerWidget {
|
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
@@ -42,18 +38,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final videoId = asset.id.toString();
|
||||||
final controller = useState<NativeVideoPlayerController?>(null);
|
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);
|
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 currentAsset = useState(ref.read(currentAssetProvider));
|
||||||
final isCurrent = currentAsset.value == asset;
|
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 {
|
void onPlaybackReady() async {
|
||||||
final videoController = controller.value;
|
final videoController = controller.value;
|
||||||
if (videoController == null || !isCurrent || !context.mounted) {
|
if (videoController == null || !isCurrent || !context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
notifier.onNativePlaybackReady();
|
||||||
|
|
||||||
isVideoReady.value = true;
|
isVideoReady.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
|
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
|
||||||
if (autoPlayVideo) {
|
if (autoPlayVideo) {
|
||||||
await videoController.play();
|
await notifier.play();
|
||||||
}
|
}
|
||||||
await videoController.setVolume(0.9);
|
await notifier.setVolume(1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.severe('Error playing video: $error');
|
log.severe('Error playing video: $error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void onPlaybackStatusChanged() {
|
void onPlaybackStatusChanged() {
|
||||||
final videoController = controller.value;
|
if (!context.mounted) return;
|
||||||
if (videoController == null || !context.mounted) {
|
ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged();
|
||||||
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() {
|
void onPlaybackPositionChanged() {
|
||||||
// When seeking, these events sometimes move the slider to an older position
|
if (!context.mounted) return;
|
||||||
if (seekDebouncer.isActive) {
|
ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged();
|
||||||
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() {
|
void onPlaybackEnded() {
|
||||||
final videoController = controller.value;
|
if (!context.mounted) return;
|
||||||
if (videoController == null || !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(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo)) {
|
||||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||||
}
|
}
|
||||||
@@ -254,14 +160,15 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
if (controller.value != null || !context.mounted) {
|
if (controller.value != null || !context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ref.read(videoPlayerControlsProvider.notifier).reset();
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
|
|
||||||
final source = await videoSource;
|
final source = await videoSource;
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||||
|
notifier.attachController(nc);
|
||||||
|
|
||||||
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||||
@@ -273,10 +180,9 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||||
unawaited(nc.setLoop(loopVideo));
|
await notifier.setLoop(loopVideo);
|
||||||
|
|
||||||
controller.value = nc;
|
controller.value = nc;
|
||||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ref.listen(currentAssetProvider, (_, value) {
|
ref.listen(currentAssetProvider, (_, value) {
|
||||||
@@ -300,10 +206,6 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delay the video playback to avoid a stutter in the swipe animation
|
// 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(
|
Timer(
|
||||||
Platform.isIOS
|
Platform.isIOS
|
||||||
? Duration(milliseconds: 300 * playbackDelayFactor)
|
? Duration(milliseconds: 300 * playbackDelayFactor)
|
||||||
@@ -337,19 +239,18 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
playerController.stop().catchError((error) {
|
playerController.stop().catchError((error) {
|
||||||
log.fine('Error stopping video: $error');
|
log.fine('Error stopping video: $error');
|
||||||
});
|
});
|
||||||
|
|
||||||
WakelockPlus.disable();
|
|
||||||
};
|
};
|
||||||
}, const []);
|
}, const []);
|
||||||
|
|
||||||
useOnAppLifecycleStateChange((_, state) async {
|
useOnAppLifecycleStateChange((_, state) async {
|
||||||
|
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||||
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
||||||
await controller.value?.play();
|
await notifier.play();
|
||||||
} else if (state == AppLifecycleState.paused) {
|
} else if (state == AppLifecycleState.paused) {
|
||||||
final videoPlaying = await controller.value?.isPlaying();
|
final videoPlaying = await controller.value?.isPlaying();
|
||||||
if (videoPlaying ?? true) {
|
if (videoPlaying ?? true) {
|
||||||
shouldPlayOnForeground.value = true;
|
shouldPlayOnForeground.value = true;
|
||||||
await controller.value?.pause();
|
await notifier.pause();
|
||||||
} else {
|
} else {
|
||||||
shouldPlayOnForeground.value = false;
|
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/pages/editing/edit.page.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
|
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
|
||||||
|
|
||||||
import 'edit.page.dart';
|
|
||||||
|
|
||||||
/// A widget for cropping an image.
|
/// A widget for cropping an image.
|
||||||
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
|
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
|
||||||
/// users to crop an image and then navigate to the [EditImagePage] with the
|
/// users to crop an image and then navigate to the [EditImagePage] with the
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/models/memories/memory.model.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/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/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||||
import 'package:immich_mobile/widgets/memories/memory_bottom_info.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];
|
final asset = currentMemory.value.assets[otherIndex];
|
||||||
currentAsset.value = asset;
|
currentAsset.value = asset;
|
||||||
ref.read(currentAssetProvider.notifier).set(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
|
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||||
|
|||||||
Generated
+81
-7
@@ -179,7 +179,7 @@ class NetworkApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ClientCertData> selectCertificate(ClientCertPrompt promptText) async {
|
Future<void> selectCertificate(ClientCertPrompt promptText) async {
|
||||||
final String pigeonVar_channelName =
|
final String pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix';
|
||||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
@@ -197,13 +197,8 @@ class NetworkApi {
|
|||||||
message: pigeonVar_replyList[1] as String?,
|
message: pigeonVar_replyList[1] as String?,
|
||||||
details: pigeonVar_replyList[2],
|
details: pigeonVar_replyList[2],
|
||||||
);
|
);
|
||||||
} else if (pigeonVar_replyList[0] == null) {
|
|
||||||
throw PlatformException(
|
|
||||||
code: 'null-error',
|
|
||||||
message: 'Host platform returned null value for non-null return value.',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return (pigeonVar_replyList[0] as ClientCertData?)!;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,4 +224,83 @@ class NetworkApi {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> hasCertificate() async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else if (pigeonVar_replyList[0] == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (pigeonVar_replyList[0] as bool?)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> getClientPointer() async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else if (pigeonVar_replyList[0] == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (pigeonVar_replyList[0] as int?)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls) async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls]);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-12
@@ -49,12 +49,7 @@ class RemoteImageApi {
|
|||||||
|
|
||||||
final String pigeonVar_messageChannelSuffix;
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
Future<Map<String, int>?> requestImage(
|
Future<Map<String, int>?> requestImage(String url, {required int requestId, required bool preferEncoded}) async {
|
||||||
String url, {
|
|
||||||
required Map<String, String> headers,
|
|
||||||
required int requestId,
|
|
||||||
required bool preferEncoded,
|
|
||||||
}) async {
|
|
||||||
final String pigeonVar_channelName =
|
final String pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
||||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
@@ -62,12 +57,7 @@ class RemoteImageApi {
|
|||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, requestId, preferEncoded]);
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
requestId,
|
|
||||||
preferEncoded,
|
|
||||||
]);
|
|
||||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
if (pigeonVar_replyList == null) {
|
if (pigeonVar_replyList == null) {
|
||||||
throw _createConnectionError(pigeonVar_channelName);
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
|||||||
@@ -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/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.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/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.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/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/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_epilogue.dart';
|
||||||
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.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()
|
@RoutePage()
|
||||||
class DriftMemoryPage extends HookConsumerWidget {
|
class DriftMemoryPage extends HookConsumerWidget {
|
||||||
final List<DriftMemory> memories;
|
final List<DriftMemory> memories;
|
||||||
@@ -26,11 +25,7 @@ class DriftMemoryPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
static void setMemory(WidgetRef ref, DriftMemory memory) {
|
static void setMemory(WidgetRef ref, DriftMemory memory) {
|
||||||
if (memory.assets.isNotEmpty) {
|
if (memory.assets.isNotEmpty) {
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first);
|
ref.read(assetViewerProvider.notifier).setAsset(memory.assets.first);
|
||||||
|
|
||||||
if (memory.assets.first.isVideo) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +167,7 @@ class DriftMemoryPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
final asset = currentMemory.value.assets[otherIndex];
|
final asset = currentMemory.value.assets[otherIndex];
|
||||||
currentAsset.value = asset;
|
currentAsset.value = asset;
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||||
// if (asset.isVideo || asset.isMotionPhoto) {
|
|
||||||
if (asset.isVideo) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||||
@@ -273,7 +264,12 @@ class DriftMemoryPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
color: Colors.black,
|
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(
|
Positioned.fill(
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:cancellation_token_http/http.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -62,7 +61,7 @@ class DriftEditImagePage extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken());
|
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
durationInSecond: 6,
|
durationInSecond: 6,
|
||||||
|
|||||||
@@ -80,51 +80,28 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
||||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||||
|
|
||||||
final isSearching = useState(false);
|
|
||||||
|
|
||||||
final userPreferences = ref.watch(userMetadataPreferencesProvider);
|
final userPreferences = ref.watch(userMetadataPreferencesProvider);
|
||||||
|
|
||||||
SnackBar searchInfoSnackBar(String message) {
|
searchFilter(SearchFilter filter) {
|
||||||
return SnackBar(
|
|
||||||
content: Text(message, style: context.textTheme.labelLarge),
|
|
||||||
showCloseIcon: true,
|
|
||||||
behavior: SnackBarBehavior.fixed,
|
|
||||||
closeIconColor: context.colorScheme.onSurface,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
searchFilter(SearchFilter filter) async {
|
|
||||||
if (filter.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preFilter == null && filter == previousFilter.value) {
|
if (preFilter == null && filter == previousFilter.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSearching.value = true;
|
ref.read(paginatedSearchProvider.notifier).clear();
|
||||||
ref.watch(paginatedSearchProvider.notifier).clear();
|
|
||||||
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter);
|
|
||||||
|
|
||||||
if (!hasResult) {
|
if (filter.isEmpty) {
|
||||||
context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context)));
|
previousFilter.value = null;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
|
||||||
previousFilter.value = filter;
|
previousFilter.value = filter;
|
||||||
isSearching.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
search() => searchFilter(filter.value);
|
search() => searchFilter(filter.value);
|
||||||
|
|
||||||
loadMoreSearchResult() async {
|
loadMoreSearchResults() {
|
||||||
isSearching.value = true;
|
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value));
|
||||||
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
|
|
||||||
|
|
||||||
if (!hasResult) {
|
|
||||||
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context)));
|
|
||||||
}
|
|
||||||
|
|
||||||
isSearching.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
searchPreFilter() {
|
searchPreFilter() {
|
||||||
@@ -742,10 +719,10 @@ class DriftSearchPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isSearching.value)
|
if (filter.value.isEmpty)
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
|
const _SearchSuggestions()
|
||||||
else
|
else
|
||||||
_SearchResultGrid(onScrollEnd: loadMoreSearchResult),
|
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -757,45 +734,85 @@ class _SearchResultGrid extends ConsumerWidget {
|
|||||||
|
|
||||||
const _SearchResultGrid({required this.onScrollEnd});
|
const _SearchResultGrid({required this.onScrollEnd});
|
||||||
|
|
||||||
@override
|
bool _onScrollUpdateNotification(ScrollNotification notification) {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
final metrics = notification.metrics;
|
||||||
final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets));
|
|
||||||
|
|
||||||
if (assets.isEmpty) {
|
if (metrics.axis != Axis.vertical) return false;
|
||||||
return const _SearchEmptyContent();
|
|
||||||
|
final isBottomSheet = notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
|
||||||
|
final remaining = metrics.maxScrollExtent - metrics.pixels;
|
||||||
|
|
||||||
|
if (remaining < metrics.viewportDimension && !isBottomSheet) {
|
||||||
|
onScrollEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
return NotificationListener<ScrollEndNotification>(
|
return false;
|
||||||
onNotification: (notification) {
|
}
|
||||||
final isBottomSheetNotification =
|
|
||||||
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
|
|
||||||
|
|
||||||
final metrics = notification.metrics;
|
Widget? _bottomWidget(BuildContext context, WidgetRef ref) {
|
||||||
final isVerticalScroll = metrics.axis == Axis.vertical;
|
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
|
||||||
|
|
||||||
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) {
|
if (isLoading) {
|
||||||
onScrollEnd();
|
return const SliverFillRemaining(
|
||||||
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent);
|
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: SliverFillRemaining(
|
||||||
child: ProviderScope(
|
child: ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
timelineServiceProvider.overrideWith((ref) {
|
timelineServiceProvider.overrideWith((ref) {
|
||||||
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search);
|
final notifier = ref.read(paginatedSearchProvider.notifier);
|
||||||
ref.onDispose(timelineService.dispose);
|
final service = ref
|
||||||
return timelineService;
|
.watch(timelineFactoryProvider)
|
||||||
|
.fromAssetStream(
|
||||||
|
() => ref.read(paginatedSearchProvider).assets,
|
||||||
|
notifier.assetCount,
|
||||||
|
TimelineOrigin.search,
|
||||||
|
);
|
||||||
|
ref.onDispose(service.dispose);
|
||||||
|
return service;
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
child: Timeline(
|
child: Timeline(
|
||||||
key: ValueKey(assets.length),
|
|
||||||
groupBy: GroupAssetsBy.none,
|
groupBy: GroupAssetsBy.none,
|
||||||
appBar: null,
|
appBar: null,
|
||||||
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
|
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
|
||||||
snapToMonth: false,
|
snapToMonth: false,
|
||||||
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 {
|
class _SearchNoResults extends StatelessWidget {
|
||||||
const _SearchEmptyContent();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
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/domain/services/search.service.dart';
|
||||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
||||||
@@ -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)),
|
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
|
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
|
||||||
final SearchService _searchService;
|
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 {
|
Stream<int> get assetCount => _assetCountController.stream;
|
||||||
if (state.nextPage == null) {
|
|
||||||
return false;
|
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!);
|
final result = await _searchService.search(filter, state.nextPage!);
|
||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
return false;
|
state = SearchState(assets: state.assets, nextPage: state.nextPage);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = SearchResult(
|
final assets = [...state.assets, ...result.assets];
|
||||||
assets: [...state.assets, ...result.assets],
|
state = SearchState(assets: assets, nextPage: result.nextPage);
|
||||||
nextPage: result.nextPage,
|
|
||||||
scrollOffset: state.scrollOffset,
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
_assetCountController.add(assets.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setScrollOffset(double offset) {
|
void clear() {
|
||||||
state = state.copyWith(scrollOffset: offset);
|
state = const SearchState();
|
||||||
|
_assetCountController.add(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
@override
|
||||||
state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0);
|
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/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/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_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/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
@@ -49,7 +49,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildMenuChildren() {
|
List<Widget> _buildMenuChildren() {
|
||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||||
if (asset == null) return [];
|
if (asset == null) return [];
|
||||||
|
|
||||||
final user = ref.read(currentUserProvider);
|
final user = ref.read(currentUserProvider);
|
||||||
@@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openAlbumSelector() {
|
void _openAlbumSelector() {
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||||
if (currentAsset == null) {
|
if (currentAsset == null) {
|
||||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||||
return;
|
return;
|
||||||
@@ -133,7 +133,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
|
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
|
||||||
final latest = ref.read(currentAssetNotifier);
|
final latest = ref.read(assetViewerProvider).currentAsset;
|
||||||
|
|
||||||
if (latest == null) {
|
if (latest == null) {
|
||||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||||
@@ -169,7 +169,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return const SizedBox.shrink();
|
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/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.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/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';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class EditImageActionButton extends ConsumerWidget {
|
class EditImageActionButton extends ConsumerWidget {
|
||||||
@@ -12,7 +12,7 @@ class EditImageActionButton extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final currentAsset = ref.watch(currentAssetNotifier);
|
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||||
|
|
||||||
onPress() {
|
onPress() {
|
||||||
if (currentAsset == null) {
|
if (currentAsset == null) {
|
||||||
|
|||||||
+2
-2
@@ -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/models/activities/activity.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.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/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/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final album = ref.watch(currentRemoteAlbumProvider);
|
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 user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
|
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||||
|
|||||||
+1
-1
@@ -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/models/search/search_filter.model.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.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/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';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class SimilarPhotosActionButton extends ConsumerWidget {
|
class SimilarPhotosActionButton extends ConsumerWidget {
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ class _UploadProgressDialog extends ConsumerWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
ImmichTextButton(
|
ImmichTextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(manualUploadCancelTokenProvider)?.cancel();
|
ref.read(manualUploadCancelTokenProvider)?.complete();
|
||||||
|
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
labelText: 'cancel'.t(context: context),
|
labelText: 'cancel'.t(context: context),
|
||||||
|
|||||||
@@ -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/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.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.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/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
@@ -809,7 +809,7 @@ class CreateAlbumButton extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||||
|
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.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/appears_in_details.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_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';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
|
|
||||||
class AssetDetails extends ConsumerWidget {
|
class AssetDetails extends ConsumerWidget {
|
||||||
|
final BaseAsset asset;
|
||||||
final double minHeight;
|
final double minHeight;
|
||||||
|
|
||||||
const AssetDetails({required this.minHeight, super.key});
|
const AssetDetails({super.key, required this.asset, required this.minHeight});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
|
||||||
if (asset == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(minHeight: minHeight),
|
constraints: BoxConstraints(minHeight: minHeight),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.colorScheme.surface,
|
color: context.colorScheme.surface,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
top: false,
|
||||||
children: [
|
child: Column(
|
||||||
const DragHandle(),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const DateTimeDetails(),
|
children: [
|
||||||
const PeopleDetails(),
|
const DragHandle(),
|
||||||
const LocationDetails(),
|
DateTimeDetails(asset: asset, exifInfo: exifInfo),
|
||||||
const TechnicalDetails(),
|
PeopleDetails(asset: asset),
|
||||||
const RatingDetails(),
|
LocationDetails(asset: asset, exifInfo: exifInfo),
|
||||||
const AppearsInDetails(),
|
TechnicalDetails(asset: asset, exifInfo: exifInfo),
|
||||||
SizedBox(height: context.padding.bottom + 48),
|
RatingDetails(exifInfo: exifInfo),
|
||||||
],
|
AppearsInDetails(asset: asset),
|
||||||
|
SizedBox(height: context.padding.bottom + 48),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-11
@@ -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/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/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/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.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/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class AppearsInDetails extends ConsumerWidget {
|
class AppearsInDetails extends ConsumerWidget {
|
||||||
const AppearsInDetails({super.key});
|
final BaseAsset asset;
|
||||||
|
|
||||||
|
const AppearsInDetails({super.key, required this.asset});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
if (!asset.hasRemote) return const SizedBox.shrink();
|
||||||
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
String? remoteAssetId;
|
final remoteAssetId = switch (asset) {
|
||||||
if (asset is RemoteAsset) {
|
RemoteAsset(:final id) => id,
|
||||||
remoteAssetId = asset.id;
|
LocalAsset(:final remoteAssetId) => remoteAssetId,
|
||||||
} else if (asset is LocalAsset) {
|
};
|
||||||
remoteAssetId = asset.remoteAssetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remoteAssetId == null) return const SizedBox.shrink();
|
if (remoteAssetId == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
|||||||
+8
-10
@@ -10,7 +10,6 @@ import 'package:immich_mobile/extensions/duration_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/translate_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/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.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/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/utils/timezone.dart';
|
import 'package:immich_mobile/utils/timezone.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.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 = ' • ';
|
const _kSeparator = ' • ';
|
||||||
|
|
||||||
class DateTimeDetails extends ConsumerWidget {
|
class DateTimeDetails extends ConsumerWidget {
|
||||||
const DateTimeDetails({super.key});
|
final BaseAsset asset;
|
||||||
|
final ExifInfo? exifInfo;
|
||||||
|
|
||||||
|
const DateTimeDetails({super.key, required this.asset, this.exifInfo});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = this.asset;
|
||||||
if (asset == null) return const SizedBox.shrink();
|
final exifInfo = this.exifInfo;
|
||||||
|
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
|
||||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -106,9 +106,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
final currentDescription = widget.exif.description ?? '';
|
||||||
|
|
||||||
final currentDescription = currentExifInfo?.description ?? '';
|
|
||||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
||||||
context: context,
|
context: context,
|
||||||
);
|
);
|
||||||
@@ -134,7 +132,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
|||||||
errorBorder: InputBorder.none,
|
errorBorder: InputBorder.none,
|
||||||
focusedErrorBorder: InputBorder.none,
|
focusedErrorBorder: InputBorder.none,
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
onTapOutside: (_) => saveDescription(widget.exif.description),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-14
@@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/translate_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/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.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:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
class LocationDetails extends ConsumerStatefulWidget {
|
class LocationDetails extends ConsumerStatefulWidget {
|
||||||
const LocationDetails({super.key});
|
final BaseAsset asset;
|
||||||
|
final ExifInfo? exifInfo;
|
||||||
|
|
||||||
|
const LocationDetails({super.key, required this.asset, this.exifInfo});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _LocationDetailsState();
|
ConsumerState createState() => _LocationDetailsState();
|
||||||
@@ -40,17 +42,15 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
|||||||
_mapController = controller;
|
_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
|
@override
|
||||||
void initState() {
|
void didUpdateWidget(LocationDetails oldWidget) {
|
||||||
super.initState();
|
super.didUpdateWidget(oldWidget);
|
||||||
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
|
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 {
|
void editLocation() async {
|
||||||
@@ -59,8 +59,8 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = widget.asset;
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
final exifInfo = widget.exifInfo;
|
||||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||||
|
|
||||||
// Guard local assets
|
// Guard local assets
|
||||||
|
|||||||
+6
-10
@@ -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/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/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/infrastructure/people.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_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/image_url_builder.dart';
|
||||||
import 'package:immich_mobile/utils/people.utils.dart';
|
import 'package:immich_mobile/utils/people.utils.dart';
|
||||||
|
|
||||||
class PeopleDetails extends ConsumerStatefulWidget {
|
class PeopleDetails extends ConsumerWidget {
|
||||||
const PeopleDetails({super.key});
|
final BaseAsset asset;
|
||||||
|
|
||||||
|
const PeopleDetails({super.key, required this.asset});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _PeopleDetailsState();
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
}
|
final asset = this.asset;
|
||||||
|
|
||||||
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
|
||||||
if (asset is! RemoteAsset) {
|
if (asset is! RemoteAsset) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,16 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.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/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.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';
|
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||||
|
|
||||||
class RatingDetails extends ConsumerWidget {
|
class RatingDetails extends ConsumerWidget {
|
||||||
const RatingDetails({super.key});
|
final ExifInfo? exifInfo;
|
||||||
|
|
||||||
|
const RatingDetails({super.key, this.exifInfo});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -20,8 +22,6 @@ class RatingDetails extends ConsumerWidget {
|
|||||||
|
|
||||||
if (!isRatingEnabled) return const SizedBox.shrink();
|
if (!isRatingEnabled) return const SizedBox.shrink();
|
||||||
|
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
+5
-6
@@ -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/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/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/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
|
|
||||||
const _kSeparator = ' • ';
|
const _kSeparator = ' • ';
|
||||||
|
|
||||||
class TechnicalDetails extends ConsumerWidget {
|
class TechnicalDetails extends ConsumerWidget {
|
||||||
const TechnicalDetails({super.key});
|
final BaseAsset asset;
|
||||||
|
final ExifInfo? exifInfo;
|
||||||
|
|
||||||
|
const TechnicalDetails({super.key, required this.asset, this.exifInfo});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final exifInfo = this.exifInfo;
|
||||||
if (asset == null) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
|
||||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||||
|
|
||||||
|
|||||||
@@ -12,17 +12,16 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/scroll_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_details.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.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/asset_viewer/video_viewer.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.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/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.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/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_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/services/app_settings.service.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/common/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
|
|
||||||
@@ -51,9 +50,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
bool _showingDetails = false;
|
bool _showingDetails = false;
|
||||||
bool _isZoomed = false;
|
bool _isZoomed = false;
|
||||||
|
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = SnapScrollController();
|
||||||
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
|
||||||
|
|
||||||
double _snapOffset = 0.0;
|
double _snapOffset = 0.0;
|
||||||
|
|
||||||
DragStartDetails? _dragStart;
|
DragStartDetails? _dragStart;
|
||||||
@@ -65,17 +62,17 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || !_proxyScrollController.hasClients) return;
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
_scrollController.snapPosition.snapOffset = _snapOffset;
|
||||||
if (_showingDetails && _snapOffset > 0) {
|
if (_showingDetails && _snapOffset > 0) {
|
||||||
_proxyScrollController.jumpTo(_snapOffset);
|
_scrollController.jumpTo(_snapOffset);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_proxyScrollController.dispose();
|
_scrollController.dispose();
|
||||||
_scaleBoundarySub?.cancel();
|
_scaleBoundarySub?.cancel();
|
||||||
_eventSubscription?.cancel();
|
_eventSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -90,21 +87,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showDetails() {
|
void _showDetails() {
|
||||||
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
|
if (!_scrollController.hasClients || _snapOffset <= 0) return;
|
||||||
_viewer.setShowingDetails(true);
|
_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) {
|
bool _willClose(double scrollVelocity) =>
|
||||||
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false;
|
_scrollController.hasClients &&
|
||||||
|
_snapOffset > 0 &&
|
||||||
final position = _proxyScrollController.position;
|
_scrollController.position.pixels < _snapOffset &&
|
||||||
return _proxyScrollController.position.pixels < _snapOffset &&
|
SnapScrollPhysics.target(_scrollController.position, scrollVelocity, _snapOffset) <
|
||||||
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
|
SnapScrollPhysics.minSnapDistance;
|
||||||
}
|
|
||||||
|
|
||||||
void _syncShowingDetails() {
|
void _syncShowingDetails() {
|
||||||
final offset = _proxyScrollController.offset;
|
final offset = _scrollController.offset;
|
||||||
if (offset > SnapScrollPhysics.minSnapDistance) {
|
if (offset > SnapScrollPhysics.minSnapDistance) {
|
||||||
_viewer.setShowingDetails(true);
|
_viewer.setShowingDetails(true);
|
||||||
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
|
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
|
||||||
@@ -126,8 +122,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _startProxyDrag() {
|
void _startProxyDrag() {
|
||||||
if (_proxyScrollController.hasClients && _dragStart != null) {
|
if (_scrollController.hasClients && _dragStart != null) {
|
||||||
_drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null);
|
_drag = _scrollController.position.drag(_dragStart!, () => _drag = null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,14 +243,16 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||||
|
|
||||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||||
_isZoomed = switch (scaleState) {
|
_isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering;
|
||||||
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
_viewer.setZoomed(_isZoomed);
|
_viewer.setZoomed(_isZoomed);
|
||||||
|
|
||||||
if (scaleState != PhotoViewScaleState.initial) {
|
if (scaleState != PhotoViewScaleState.initial) {
|
||||||
if (_dragStart == null) _viewer.setControls(false);
|
if (_dragStart == null) _viewer.setControls(false);
|
||||||
|
|
||||||
|
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
|
||||||
|
if (heroTag != null) {
|
||||||
|
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,30 +287,26 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
_listenForScaleBoundaries(controller);
|
_listenForScaleBoundaries(controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPhotoView(
|
Widget _buildPhotoView({
|
||||||
BaseAsset displayAsset,
|
required BaseAsset asset,
|
||||||
BaseAsset asset, {
|
required PhotoViewHeroAttributes? heroAttributes,
|
||||||
required bool isCurrentPage,
|
required bool isCurrent,
|
||||||
required bool showingDetails,
|
|
||||||
required bool isPlayingMotionVideo,
|
required bool isPlayingMotionVideo,
|
||||||
required BoxDecoration backgroundDecoration,
|
|
||||||
}) {
|
}) {
|
||||||
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
|
final size = context.sizeData;
|
||||||
|
|
||||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
if (asset.isImage && !isPlayingMotionVideo) {
|
||||||
final size = context.sizeData;
|
|
||||||
return PhotoView(
|
return PhotoView(
|
||||||
key: Key(displayAsset.heroTag),
|
key: Key(asset.heroTag),
|
||||||
index: widget.index,
|
index: widget.index,
|
||||||
imageProvider: getFullImageProvider(displayAsset, size: size),
|
imageProvider: getFullImageProvider(asset, size: size),
|
||||||
heroAttributes: heroAttributes,
|
heroAttributes: heroAttributes,
|
||||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||||
backgroundDecoration: backgroundDecoration,
|
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
tightMode: true,
|
tightMode: true,
|
||||||
enablePanAlways: true,
|
enablePanAlways: true,
|
||||||
disableScaleGestures: showingDetails,
|
disableScaleGestures: _showingDetails,
|
||||||
scaleStateChangedCallback: _onScaleStateChanged,
|
scaleStateChangedCallback: _onScaleStateChanged,
|
||||||
onPageBuild: _onPageBuild,
|
onPageBuild: _onPageBuild,
|
||||||
onDragStart: _onDragStart,
|
onDragStart: _onDragStart,
|
||||||
@@ -320,49 +314,45 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
onDragEnd: _onDragEnd,
|
onDragEnd: _onDragEnd,
|
||||||
onDragCancel: _onDragCancel,
|
onDragCancel: _onDragCancel,
|
||||||
onTapUp: _onTapUp,
|
onTapUp: _onTapUp,
|
||||||
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null,
|
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
|
||||||
errorBuilder: (_, __, ___) => SizedBox(
|
errorBuilder: (_, __, ___) => SizedBox(
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain),
|
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Stack(
|
return PhotoView.customChild(
|
||||||
children: [
|
key: Key(asset.heroTag),
|
||||||
PhotoView.customChild(
|
childSize: asset.width != null && asset.height != null
|
||||||
key: Key(displayAsset.heroTag),
|
? Size(asset.width!.toDouble(), asset.height!.toDouble())
|
||||||
onDragStart: _onDragStart,
|
: null,
|
||||||
onDragUpdate: _onDragUpdate,
|
onDragStart: _onDragStart,
|
||||||
onDragEnd: _onDragEnd,
|
onDragUpdate: _onDragUpdate,
|
||||||
onDragCancel: _onDragCancel,
|
onDragEnd: _onDragEnd,
|
||||||
onTapUp: _onTapUp,
|
onDragCancel: _onDragCancel,
|
||||||
heroAttributes: heroAttributes,
|
onTapUp: _onTapUp,
|
||||||
basePosition: Alignment.center,
|
scaleStateChangedCallback: _onScaleStateChanged,
|
||||||
disableScaleGestures: showingDetails,
|
heroAttributes: heroAttributes,
|
||||||
scaleStateChangedCallback: _onScaleStateChanged,
|
filterQuality: FilterQuality.high,
|
||||||
onPageBuild: _onPageBuild,
|
basePosition: Alignment.center,
|
||||||
enablePanAlways: true,
|
disableScaleGestures: _showingDetails,
|
||||||
backgroundDecoration: backgroundDecoration,
|
minScale: PhotoViewComputedScale.contained,
|
||||||
child: SizedBox(
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
width: context.width,
|
tightMode: true,
|
||||||
height: context.height,
|
onPageBuild: _onPageBuild,
|
||||||
child: NativeVideoViewer(
|
enablePanAlways: true,
|
||||||
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
child: NativeVideoViewer(
|
||||||
asset: displayAsset,
|
key: _NativeVideoViewerKey(asset.heroTag),
|
||||||
image: Image(
|
asset: asset,
|
||||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
isCurrent: isCurrent,
|
||||||
fit: BoxFit.contain,
|
image: Image(
|
||||||
height: context.height,
|
image: getFullImageProvider(asset, size: size),
|
||||||
width: context.width,
|
fit: BoxFit.contain,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Center(child: VideoViewerControls()),
|
),
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +374,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
displayAsset = stackChildren.elementAt(stackIndex);
|
displayAsset = stackChildren.elementAt(stackIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isCurrent = currentHeroTag == displayAsset.heroTag;
|
||||||
|
|
||||||
final viewportWidth = MediaQuery.widthOf(context);
|
final viewportWidth = MediaQuery.widthOf(context);
|
||||||
final viewportHeight = MediaQuery.heightOf(context);
|
final viewportHeight = MediaQuery.heightOf(context);
|
||||||
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
|
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
|
||||||
@@ -393,43 +385,29 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
|
|
||||||
_snapOffset = detailsOffset - snapTarget;
|
_snapOffset = detailsOffset - snapTarget;
|
||||||
|
|
||||||
if (_proxyScrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
_scrollController.snapPosition.snapOffset = _snapOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProviderScope(
|
return Stack(
|
||||||
overrides: [
|
children: [
|
||||||
currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)),
|
SingleChildScrollView(
|
||||||
currentAssetExifProvider.overrideWith((ref) {
|
controller: _scrollController,
|
||||||
final a = ref.watch(currentAssetNotifier);
|
physics: const SnapScrollPhysics(),
|
||||||
if (a == null) return Future.value(null);
|
child: ColoredBox(
|
||||||
return ref.watch(assetServiceProvider).getExif(a);
|
color: _showingDetails ? Colors.black : Colors.transparent,
|
||||||
}),
|
|
||||||
],
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Offstage(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
controller: _proxyScrollController,
|
|
||||||
physics: const SnapScrollPhysics(),
|
|
||||||
child: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SingleChildScrollView(
|
|
||||||
controller: _scrollController,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: viewportWidth,
|
width: viewportWidth,
|
||||||
height: viewportHeight,
|
height: viewportHeight,
|
||||||
child: _buildPhotoView(
|
child: _buildPhotoView(
|
||||||
displayAsset,
|
asset: displayAsset,
|
||||||
asset,
|
heroAttributes: isCurrent
|
||||||
isCurrentPage: currentHeroTag == asset.heroTag,
|
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
|
||||||
showingDetails: _showingDetails,
|
: null,
|
||||||
|
isCurrent: isCurrent,
|
||||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||||
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IgnorePointer(
|
IgnorePointer(
|
||||||
@@ -445,7 +423,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: _showingDetails ? 1.0 : 0.0,
|
opacity: _showingDetails ? 1.0 : 0.0,
|
||||||
duration: Durations.short2,
|
duration: Durations.short2,
|
||||||
child: AssetDetails(minHeight: viewportHeight - snapTarget),
|
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -454,8 +432,15 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
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:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.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 {
|
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;
|
final List<RemoteAsset> stack;
|
||||||
|
|
||||||
const _StackList({required this.stack});
|
const AssetStackRow({super.key, required this.stack});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Center(
|
if (stack.isEmpty) {
|
||||||
child: SingleChildScrollView(
|
return const SizedBox.shrink();
|
||||||
scrollDirection: Axis.horizontal,
|
}
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
|
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
child: Row(
|
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
spacing: 5.0,
|
return IgnorePointer(
|
||||||
children: List.generate(stack.length, (i) {
|
ignoring: opacity < 1.0,
|
||||||
final asset = stack[i];
|
child: AnimatedOpacity(
|
||||||
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
|
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> {
|
class _StackItemState extends ConsumerState<_StackItem> {
|
||||||
void _onTap() {
|
void _onTap() {
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
|
final notifier = ref.read(assetViewerProvider.notifier);
|
||||||
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
|
notifier.setAsset(widget.asset);
|
||||||
|
notifier.setStackIndex(widget.index);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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_page.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.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_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/asset_viewer/viewer_bottom_app_bar.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.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/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.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/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/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.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) {
|
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||||
// Always holds the current asset from the timeline
|
|
||||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
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
|
// Hide controls by default for videos
|
||||||
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
|
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 _pageController = PageController(initialPage: widget.initialIndex);
|
||||||
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
||||||
|
|
||||||
|
late int _currentPage = widget.initialIndex;
|
||||||
|
|
||||||
StreamSubscription? _reloadSubscription;
|
StreamSubscription? _reloadSubscription;
|
||||||
KeepAliveLink? _stackChildrenKeepAlive;
|
KeepAliveLink? _stackChildrenKeepAlive;
|
||||||
|
|
||||||
@@ -102,7 +93,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
final target = page + direction;
|
final target = page + direction;
|
||||||
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
|
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
|
||||||
if (target >= 0 && target <= maxPage) {
|
if (target >= 0 && target <= maxPage) {
|
||||||
|
_currentPage = target;
|
||||||
_pageController.jumpToPage(target);
|
_pageController.jumpToPage(target);
|
||||||
|
_onAssetChanged(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +103,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.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");
|
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
|
||||||
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||||
|
|
||||||
@@ -134,6 +127,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
super.dispose();
|
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) {
|
void _onAssetInit(Duration timeStamp) {
|
||||||
_preloader.preload(widget.initialIndex, context.sizeData);
|
_preloader.preload(widget.initialIndex, context.sizeData);
|
||||||
_handleCasting();
|
_handleCasting();
|
||||||
@@ -153,7 +166,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
|
|
||||||
void _handleCasting() {
|
void _handleCasting() {
|
||||||
if (!ref.read(castProvider).isCasting) return;
|
if (!ref.read(castProvider).isCasting) return;
|
||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||||
if (asset == null) return;
|
if (asset == null) return;
|
||||||
|
|
||||||
if (asset is RemoteAsset) {
|
if (asset is RemoteAsset) {
|
||||||
@@ -195,17 +208,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var index = _pageController.page?.round() ?? 0;
|
var index = _pageController.page?.round() ?? 0;
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||||
if (currentAsset != null) {
|
if (currentAsset != null) {
|
||||||
final newIndex = timelineService.getIndex(currentAsset.heroTag);
|
final newIndex = timelineService.getIndex(currentAsset.heroTag);
|
||||||
if (newIndex != null && newIndex != index) {
|
if (newIndex != null && newIndex != index) {
|
||||||
index = newIndex;
|
index = newIndex;
|
||||||
|
_currentPage = index;
|
||||||
_pageController.jumpToPage(index);
|
_pageController.jumpToPage(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index >= totalAssets) {
|
if (index >= totalAssets) {
|
||||||
index = totalAssets - 1;
|
index = totalAssets - 1;
|
||||||
|
_currentPage = index;
|
||||||
_pageController.jumpToPage(index);
|
_pageController.jumpToPage(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +236,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
final newAsset = await timelineService.getAssetAsync(index);
|
final newAsset = await timelineService.getAssetAsync(index);
|
||||||
if (newAsset == null) return;
|
if (newAsset == null) return;
|
||||||
|
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||||
|
|
||||||
// Do not reload if the asset has not changed
|
// Do not reload if the asset has not changed
|
||||||
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
||||||
@@ -258,25 +273,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
_setSystemUIMode(controls, details);
|
_setSystemUIMode(controls, details);
|
||||||
});
|
});
|
||||||
|
|
||||||
return PopScope(
|
return Scaffold(
|
||||||
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
|
backgroundColor: backgroundColor,
|
||||||
child: Scaffold(
|
resizeToAvoidBottomInset: false,
|
||||||
backgroundColor: backgroundColor,
|
appBar: const ViewerTopAppBar(),
|
||||||
appBar: const ViewerTopAppBar(),
|
extendBody: true,
|
||||||
extendBody: true,
|
extendBodyBehindAppBar: true,
|
||||||
extendBodyBehindAppBar: true,
|
floatingActionButton: IgnorePointer(
|
||||||
floatingActionButton: IgnorePointer(
|
ignoring: !showingControls,
|
||||||
ignoring: !showingControls,
|
child: AnimatedOpacity(
|
||||||
child: AnimatedOpacity(
|
opacity: showingControls ? 1.0 : 0.0,
|
||||||
opacity: showingControls ? 1.0 : 0.0,
|
duration: Durations.short2,
|
||||||
duration: Durations.short2,
|
child: const DownloadStatusFloatingButton(),
|
||||||
child: const DownloadStatusFloatingButton(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
),
|
||||||
body: Stack(
|
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||||
children: [
|
body: Stack(
|
||||||
PhotoViewGestureDetectorScope(
|
children: [
|
||||||
|
NotificationListener<ScrollEndNotification>(
|
||||||
|
onNotification: _onScrollEnd,
|
||||||
|
child: PhotoViewGestureDetectorScope(
|
||||||
axis: Axis.horizontal,
|
axis: Axis.horizontal,
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
@@ -286,21 +302,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
? const FastScrollPhysics()
|
? const FastScrollPhysics()
|
||||||
: const FastClampingScrollPhysics(),
|
: const FastClampingScrollPhysics(),
|
||||||
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
||||||
onPageChanged: (index) => _onAssetChanged(index),
|
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) =>
|
||||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!CurrentPlatform.isIOS)
|
),
|
||||||
IgnorePointer(
|
if (!CurrentPlatform.isIOS)
|
||||||
child: AnimatedContainer(
|
IgnorePointer(
|
||||||
duration: Durations.short2,
|
child: AnimatedContainer(
|
||||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
duration: Durations.short2,
|
||||||
height: context.padding.top,
|
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/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/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_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/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.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/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -21,7 +20,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
@@ -65,9 +64,9 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
color: Colors.black.withAlpha(125),
|
color: Colors.black.withAlpha(125),
|
||||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (asset.isVideo) const VideoControls(),
|
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||||
if (!isReadonlyModeEnabled)
|
if (!isReadonlyModeEnabled)
|
||||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||||
],
|
],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user