mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d0760b4cd | |||
| 6ea7235e3c | |||
| 3467897113 | |||
| 872a6ae993 | |||
| 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
+33
-1
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
"source.organizeImports": "explicit",
|
"source.organizeImports": "explicit",
|
||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"[svelte]": {
|
"[svelte]": {
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
"source.organizeImports": "explicit",
|
"source.organizeImports": "explicit",
|
||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
"source.organizeImports": "explicit",
|
"source.organizeImports": "explicit",
|
||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
@@ -51,14 +54,43 @@
|
|||||||
"editor.tabSize": 2
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"cSpell.words": ["immich"],
|
"cSpell.words": ["immich"],
|
||||||
|
"css.lint.unknownAtRules": "ignore",
|
||||||
|
"editor.bracketPairColorization.enabled": true,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
"editor.guides.bracketPairs": "active",
|
||||||
|
"eslint.useFlatConfig": true,
|
||||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||||
|
"eslint.workingDirectories": [
|
||||||
|
{ "directory": "cli", "changeProcessCWD": true },
|
||||||
|
{ "directory": "e2e", "changeProcessCWD": true },
|
||||||
|
{ "directory": "server", "changeProcessCWD": true },
|
||||||
|
{ "directory": "web", "changeProcessCWD": true }
|
||||||
|
],
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/.jj/**": true,
|
||||||
|
"**/.git/**": true,
|
||||||
|
"**/node_modules/**": true,
|
||||||
|
"**/build/**": true,
|
||||||
|
"**/dist/**": true,
|
||||||
|
"**/.svelte-kit/**": true
|
||||||
|
},
|
||||||
"explorer.fileNesting.enabled": true,
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||||
},
|
},
|
||||||
|
"search.exclude": {
|
||||||
|
"**/node_modules": true,
|
||||||
|
"**/build": true,
|
||||||
|
"**/dist": true,
|
||||||
|
"**/.svelte-kit": true,
|
||||||
|
"**/open-api/typescript-sdk/src": true
|
||||||
|
},
|
||||||
"svelte.enable-ts-plugin": true,
|
"svelte.enable-ts-plugin": true,
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
"tailwindCSS.experimental.configFile": {
|
||||||
|
"web/src/app.css": "web/src/**"
|
||||||
|
},
|
||||||
|
"typescript.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]
|
||||||
```
|
```
|
||||||
|
|||||||
+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,10 +1,7 @@
|
|||||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||||
import { Page, expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { utils } from 'src/utils';
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
function imageLocator(page: Page) {
|
|
||||||
return page.getByAltText('Image taken').locator('visible=true');
|
|
||||||
}
|
|
||||||
test.describe('Photo Viewer', () => {
|
test.describe('Photo Viewer', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let asset: AssetMediaResponseDto;
|
let asset: AssetMediaResponseDto;
|
||||||
@@ -26,31 +23,42 @@ test.describe('Photo Viewer', () => {
|
|||||||
|
|
||||||
test('loads original photo when zoomed', async ({ page }) => {
|
test('loads original photo when zoomed', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
|
||||||
const box = await imageLocator(page).boundingBox();
|
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||||
expect(box).toBeTruthy();
|
const original = page.getByTestId('original').filter({ visible: true });
|
||||||
const { x, y, width, height } = box!;
|
|
||||||
await page.mouse.move(x + width / 2, y + height / 2);
|
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||||
|
|
||||||
|
const { width, height } = page.viewportSize()!;
|
||||||
|
await page.mouse.move(width / 2, height / 2);
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
await expect(original).toBeInViewport();
|
||||||
|
await expect(original).toHaveAttribute('src', /original/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||||
await page.goto(`/photos/${rawAsset.id}`);
|
await page.goto(`/photos/${rawAsset.id}`);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
|
||||||
const box = await imageLocator(page).boundingBox();
|
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||||
expect(box).toBeTruthy();
|
const original = page.getByTestId('original').filter({ visible: true });
|
||||||
const { x, y, width, height } = box!;
|
|
||||||
await page.mouse.move(x + width / 2, y + height / 2);
|
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||||
|
|
||||||
|
const { width, height } = page.viewportSize()!;
|
||||||
|
await page.mouse.move(width / 2, height / 2);
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reloads photo when checksum changes', async ({ page }) => {
|
test('reloads photo when checksum changes', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
|
||||||
const initialSrc = await imageLocator(page).getAttribute('src');
|
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||||
|
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||||
|
|
||||||
|
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||||
|
const initialSrc = await thumbnail.getAttribute('src');
|
||||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,13 +99,13 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail|fullsize)/;
|
||||||
const match = request.url().match(pattern);
|
const match = request.url().match(pattern);
|
||||||
if (!match?.groups) {
|
if (!match?.groups) {
|
||||||
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (match.groups.size === 'preview') {
|
if (match.groups.size === 'preview' || match.groups.size === 'fullsize') {
|
||||||
if (!route.request().serviceWorker()) {
|
if (!route.request().serviceWorker()) {
|
||||||
return route.continue();
|
return route.continue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
|
|||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
await page.waitForSelector('#immich-asset-viewer');
|
||||||
|
|
||||||
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
|
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]');
|
||||||
await expect(viewerBrokenAsset).toBeVisible();
|
await expect(viewerBrokenAsset).toBeVisible();
|
||||||
|
|
||||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
generateTimelineData,
|
generateTimelineData,
|
||||||
TimelineAssetConfig,
|
TimelineAssetConfig,
|
||||||
TimelineData,
|
TimelineData,
|
||||||
|
toAssetResponseDto,
|
||||||
} from 'src/ui/generators/timeline';
|
} from 'src/ui/generators/timeline';
|
||||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||||
@@ -53,7 +54,7 @@ test.describe('search gallery-viewer', () => {
|
|||||||
assets: {
|
assets: {
|
||||||
total: searchAssets.length,
|
total: searchAssets.length,
|
||||||
count: searchAssets.length,
|
count: searchAssets.length,
|
||||||
items: searchAssets,
|
items: searchAssets.map((asset) => toAssetResponseDto(asset)),
|
||||||
facets: [],
|
facets: [],
|
||||||
nextPage: null,
|
nextPage: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -163,13 +163,11 @@ export const assetViewerUtils = {
|
|||||||
return page.locator('#immich-asset-viewer');
|
return page.locator('#immich-asset-viewer');
|
||||||
},
|
},
|
||||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||||
|
const previewUrl = `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true`;
|
||||||
await page
|
await page
|
||||||
.locator(
|
.getByTestId('preview')
|
||||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
.and(page.locator(`[src="${previewUrl}"]`))
|
||||||
)
|
.or(page.locator(`video[poster="${previewUrl}"]`))
|
||||||
.or(
|
|
||||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
|
||||||
)
|
|
||||||
.waitFor();
|
.waitFor();
|
||||||
},
|
},
|
||||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||||
|
|||||||
+1
-1
@@ -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()],
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-4
@@ -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",
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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), () {
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,22 +287,20 @@ 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,
|
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,
|
backgroundDecoration: backgroundDecoration,
|
||||||
@@ -312,7 +308,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
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 +316,46 @@ 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),
|
backgroundDecoration: backgroundDecoration,
|
||||||
asset: displayAsset,
|
child: NativeVideoViewer(
|
||||||
image: Image(
|
key: _NativeVideoViewerKey(asset.heroTag),
|
||||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
asset: asset,
|
||||||
fit: BoxFit.contain,
|
isCurrent: isCurrent,
|
||||||
height: context.height,
|
image: Image(
|
||||||
width: context.width,
|
image: getFullImageProvider(asset, size: size),
|
||||||
alignment: Alignment.center,
|
fit: BoxFit.contain,
|
||||||
),
|
alignment: Alignment.center,
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Center(child: VideoViewerControls()),
|
),
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +377,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,69 +388,60 @@ 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: Stack(
|
||||||
return ref.watch(assetServiceProvider).getExif(a);
|
children: [
|
||||||
}),
|
SizedBox(
|
||||||
],
|
width: viewportWidth,
|
||||||
child: Stack(
|
height: viewportHeight,
|
||||||
children: [
|
child: _buildPhotoView(
|
||||||
Offstage(
|
asset: displayAsset,
|
||||||
child: SingleChildScrollView(
|
heroAttributes: isCurrent
|
||||||
controller: _proxyScrollController,
|
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
|
||||||
physics: const SnapScrollPhysics(),
|
: null,
|
||||||
child: const SizedBox.shrink(),
|
isCurrent: isCurrent,
|
||||||
),
|
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||||
),
|
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
||||||
SingleChildScrollView(
|
|
||||||
controller: _scrollController,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: viewportWidth,
|
|
||||||
height: viewportHeight,
|
|
||||||
child: _buildPhotoView(
|
|
||||||
displayAsset,
|
|
||||||
asset,
|
|
||||||
isCurrentPage: currentHeroTag == asset.heroTag,
|
|
||||||
showingDetails: _showingDetails,
|
|
||||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
|
||||||
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
IgnorePointer(
|
),
|
||||||
ignoring: !_showingDetails,
|
IgnorePointer(
|
||||||
child: Column(
|
ignoring: !_showingDetails,
|
||||||
children: [
|
child: Column(
|
||||||
SizedBox(height: detailsOffset),
|
children: [
|
||||||
GestureDetector(
|
SizedBox(height: detailsOffset),
|
||||||
onVerticalDragStart: _beginDrag,
|
GestureDetector(
|
||||||
onVerticalDragUpdate: _updateDrag,
|
onVerticalDragStart: _beginDrag,
|
||||||
onVerticalDragEnd: _endDrag,
|
onVerticalDragUpdate: _updateDrag,
|
||||||
onVerticalDragCancel: _onDragCancel,
|
onVerticalDragEnd: _endDrag,
|
||||||
child: AnimatedOpacity(
|
onVerticalDragCancel: _onDragCancel,
|
||||||
opacity: _showingDetails ? 1.0 : 0.0,
|
child: AnimatedOpacity(
|
||||||
duration: Durations.short2,
|
opacity: _showingDetails ? 1.0 : 0.0,
|
||||||
child: AssetDetails(minHeight: viewportHeight - snapTarget),
|
duration: Durations.short2,
|
||||||
),
|
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
if (stackChildren != null && stackChildren.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: context.padding.bottom,
|
||||||
|
child: AssetStackRow(stack: stackChildren),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,42 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package: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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
|
||||||
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/domain/models/setting.model.dart';
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
@@ -11,423 +9,225 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.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/video_viewer_controls.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/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/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/infrastructure/setting.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/setting.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/utils/debounce.dart';
|
|
||||||
import 'package:immich_mobile/utils/hooks/interval_hook.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';
|
|
||||||
|
|
||||||
bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
|
class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||||
if (asset is RemoteAsset) {
|
|
||||||
return switch (currentAsset) {
|
|
||||||
RemoteAsset remoteAsset => remoteAsset.id == asset.id,
|
|
||||||
LocalAsset localAsset => localAsset.remoteId == asset.id,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
} else if (asset is LocalAsset) {
|
|
||||||
return switch (currentAsset) {
|
|
||||||
RemoteAsset remoteAsset => remoteAsset.localId == asset.id,
|
|
||||||
LocalAsset localAsset => localAsset.id == asset.id,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
class NativeVideoViewer extends HookConsumerWidget {
|
|
||||||
static final log = Logger('NativeVideoViewer');
|
|
||||||
final BaseAsset asset;
|
final BaseAsset asset;
|
||||||
final int playbackDelayFactor;
|
final bool isCurrent;
|
||||||
|
final bool showControls;
|
||||||
final Widget image;
|
final Widget image;
|
||||||
|
|
||||||
const NativeVideoViewer({super.key, required this.asset, required this.image, this.playbackDelayFactor = 1});
|
const NativeVideoViewer({
|
||||||
|
super.key,
|
||||||
|
required this.asset,
|
||||||
|
required this.image,
|
||||||
|
this.isCurrent = false,
|
||||||
|
this.showControls = true,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<NativeVideoViewer> createState() => _NativeVideoViewerState();
|
||||||
final controller = useState<NativeVideoPlayerController?>(null);
|
}
|
||||||
final lastVideoPosition = useRef(-1);
|
|
||||||
final isBuffering = useRef(false);
|
|
||||||
|
|
||||||
// Used to track whether the video should play when the app
|
class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
|
||||||
// is brought back to the foreground
|
static final _log = Logger('NativeVideoViewer');
|
||||||
final shouldPlayOnForeground = useRef(true);
|
|
||||||
|
|
||||||
// When a video is opened through the timeline, `isCurrent` will immediately be true.
|
NativeVideoPlayerController? _controller;
|
||||||
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
|
late final Future<VideoSource?> _videoSource;
|
||||||
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
|
Timer? _loadTimer;
|
||||||
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
|
bool _isVideoReady = false;
|
||||||
final currentAsset = useState(ref.read(currentAssetNotifier));
|
bool _shouldPlayOnForeground = true;
|
||||||
final isCurrent = _isCurrentAsset(asset, currentAsset.value);
|
|
||||||
|
|
||||||
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
|
VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
|
||||||
final isVisible = useState(Platform.isIOS && asset.hasLocal);
|
|
||||||
|
|
||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
@override
|
||||||
|
void initState() {
|
||||||
Future<VideoSource?> createSource() async {
|
super.initState();
|
||||||
if (!context.mounted) {
|
WidgetsBinding.instance.addObserver(this);
|
||||||
return null;
|
_videoSource = _createSource();
|
||||||
}
|
|
||||||
|
|
||||||
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset;
|
|
||||||
if (!context.mounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
|
||||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
|
||||||
final file = await StorageRepository().getFileForAsset(id);
|
|
||||||
if (!context.mounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file == null) {
|
|
||||||
throw Exception('No file found for the video');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass a file:// URI so Android's Uri.parse doesn't
|
|
||||||
// interpret characters like '#' as fragment identifiers.
|
|
||||||
final source = await VideoSource.init(
|
|
||||||
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
|
||||||
type: VideoSourceType.file,
|
|
||||||
);
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
|
|
||||||
final remoteId = (videoAsset as RemoteAsset).id;
|
|
||||||
|
|
||||||
// Use a network URL for the video player controller
|
|
||||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
|
||||||
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
|
||||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
|
||||||
final String videoUrl = videoAsset.livePhotoVideoId != null
|
|
||||||
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
|
||||||
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
|
||||||
|
|
||||||
final source = await VideoSource.init(
|
|
||||||
path: videoUrl,
|
|
||||||
type: VideoSourceType.network,
|
|
||||||
headers: ApiService.getRequestHeaders(),
|
|
||||||
);
|
|
||||||
return source;
|
|
||||||
} catch (error) {
|
|
||||||
log.severe('Error creating video source for asset ${videoAsset.name}: $error');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
|
||||||
final aspectRatio = useState<double?>(null);
|
|
||||||
useMemoized(() async {
|
|
||||||
if (!context.mounted || aspectRatio.value != null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset);
|
|
||||||
} catch (error) {
|
|
||||||
log.severe('Error getting aspect ratio for asset ${asset.name}: $error');
|
|
||||||
}
|
|
||||||
}, [asset.heroTag]);
|
|
||||||
|
|
||||||
void checkIfBuffering() {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoPlayback = ref.read(videoPlaybackValueProvider);
|
|
||||||
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
|
|
||||||
videoPlayback.state != VideoPlaybackState.buffering) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
|
|
||||||
state: VideoPlaybackState.buffering,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timer to mark videos as buffering if the position does not change
|
|
||||||
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
|
||||||
|
|
||||||
// When the position changes, seek to the position
|
|
||||||
// Debounce the seek to avoid seeking too often
|
|
||||||
// But also don't delay the seek too much to maintain visual feedback
|
|
||||||
final seekDebouncer = useDebouncer(
|
|
||||||
interval: const Duration(milliseconds: 100),
|
|
||||||
maxWaitTime: const Duration(milliseconds: 200),
|
|
||||||
);
|
|
||||||
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
|
|
||||||
final playerController = controller.value;
|
|
||||||
if (playerController == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final playbackInfo = playerController.playbackInfo;
|
|
||||||
if (playbackInfo == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final oldSeek = oldControls?.position.inMilliseconds;
|
|
||||||
final newSeek = newControls.position.inMilliseconds;
|
|
||||||
if (oldSeek != newSeek || newControls.restarted) {
|
|
||||||
seekDebouncer.run(() => playerController.seekTo(newSeek));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldControls?.pause != newControls.pause || newControls.restarted) {
|
|
||||||
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
void onPlaybackReady() async {
|
|
||||||
final videoController = controller.value;
|
|
||||||
if (videoController == null || !isCurrent || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
|
||||||
|
|
||||||
if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
|
|
||||||
if (autoPlayVideo) {
|
|
||||||
await videoController.play();
|
|
||||||
}
|
|
||||||
await videoController.setVolume(0.9);
|
|
||||||
} catch (error) {
|
|
||||||
log.severe('Error playing video: $error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void onPlaybackStatusChanged() {
|
|
||||||
final videoController = controller.value;
|
|
||||||
if (videoController == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
|
||||||
if (videoPlayback.state == VideoPlaybackState.playing) {
|
|
||||||
// Sync with the controls playing
|
|
||||||
WakelockPlus.enable();
|
|
||||||
} else {
|
|
||||||
// Sync with the controls pause
|
|
||||||
WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
void onPlaybackPositionChanged() {
|
|
||||||
// When seeking, these events sometimes move the slider to an older position
|
|
||||||
if (seekDebouncer.isActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoController = controller.value;
|
|
||||||
if (videoController == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final playbackInfo = videoController.playbackInfo;
|
|
||||||
if (playbackInfo == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
|
|
||||||
|
|
||||||
// Check if the video is buffering
|
|
||||||
if (playbackInfo.status == PlaybackStatus.playing) {
|
|
||||||
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
|
|
||||||
lastVideoPosition.value = playbackInfo.position;
|
|
||||||
} else {
|
|
||||||
isBuffering.value = false;
|
|
||||||
lastVideoPosition.value = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void onPlaybackEnded() {
|
|
||||||
final videoController = controller.value;
|
|
||||||
if (videoController == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoController.playbackInfo?.status == PlaybackStatus.stopped) {
|
|
||||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void removeListeners(NativeVideoPlayerController controller) {
|
|
||||||
controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged);
|
|
||||||
controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged);
|
|
||||||
controller.onPlaybackReady.removeListener(onPlaybackReady);
|
|
||||||
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
|
|
||||||
}
|
|
||||||
|
|
||||||
void initController(NativeVideoPlayerController nc) async {
|
|
||||||
if (controller.value != null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).reset();
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
|
|
||||||
final source = await videoSource;
|
|
||||||
if (source == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
|
||||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
|
||||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
|
||||||
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
|
||||||
|
|
||||||
unawaited(
|
|
||||||
nc.loadVideoSource(source).catchError((error) {
|
|
||||||
log.severe('Error loading video source: $error');
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
|
||||||
unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo));
|
|
||||||
|
|
||||||
controller.value = nc;
|
|
||||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.listen(currentAssetNotifier, (_, value) {
|
|
||||||
final playerController = controller.value;
|
|
||||||
if (playerController != null && value != asset) {
|
|
||||||
removeListeners(playerController);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value != null) {
|
|
||||||
isVisible.value = _isCurrentAsset(value, asset);
|
|
||||||
}
|
|
||||||
final curAsset = currentAsset.value;
|
|
||||||
if (curAsset == asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final imageToVideo = curAsset != null && !curAsset.isVideo;
|
|
||||||
|
|
||||||
// No need to delay video playback when swiping from an image to a video
|
|
||||||
if (imageToVideo && Platform.isIOS) {
|
|
||||||
currentAsset.value = value;
|
|
||||||
onPlaybackReady();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay the video playback to avoid a stutter in the swipe animation
|
|
||||||
// Note, in some circumstances a longer delay is needed (eg: memories),
|
|
||||||
// the playbackDelayFactor can be used for this
|
|
||||||
// This delay seems like a hacky way to resolve underlying bugs in video
|
|
||||||
// playback, but other resolutions failed thus far
|
|
||||||
Timer(
|
|
||||||
Platform.isIOS
|
|
||||||
? Duration(milliseconds: 300 * playbackDelayFactor)
|
|
||||||
: imageToVideo
|
|
||||||
? Duration(milliseconds: 200 * playbackDelayFactor)
|
|
||||||
: Duration(milliseconds: 400 * playbackDelayFactor),
|
|
||||||
() {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentAsset.value = value;
|
|
||||||
if (currentAsset.value == asset) {
|
|
||||||
onPlaybackReady();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
|
|
||||||
final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true);
|
|
||||||
|
|
||||||
return () {
|
|
||||||
timer?.cancel();
|
|
||||||
final playerController = controller.value;
|
|
||||||
if (playerController == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
removeListeners(playerController);
|
|
||||||
playerController.stop().catchError((error) {
|
|
||||||
log.fine('Error stopping video: $error');
|
|
||||||
});
|
|
||||||
|
|
||||||
WakelockPlus.disable();
|
|
||||||
};
|
|
||||||
}, const []);
|
|
||||||
|
|
||||||
useOnAppLifecycleStateChange((_, state) async {
|
|
||||||
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
|
||||||
await controller.value?.play();
|
|
||||||
} else if (state == AppLifecycleState.paused) {
|
|
||||||
final videoPlaying = await controller.value?.isPlaying();
|
|
||||||
if (videoPlaying ?? true) {
|
|
||||||
shouldPlayOnForeground.value = true;
|
|
||||||
await controller.value?.pause();
|
|
||||||
} else {
|
|
||||||
shouldPlayOnForeground.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
// This remains under the video to avoid flickering
|
|
||||||
// For motion videos, this is the image portion of the asset
|
|
||||||
Center(child: image),
|
|
||||||
if (aspectRatio.value != null && !isCasting)
|
|
||||||
Visibility.maintain(
|
|
||||||
visible: isVisible.value,
|
|
||||||
child: Center(
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: aspectRatio.value!,
|
|
||||||
child: isCurrent ? NativeVideoPlayerView(onViewReady: initController) : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onPauseChange(
|
@override
|
||||||
BuildContext context,
|
void didUpdateWidget(NativeVideoViewer oldWidget) {
|
||||||
NativeVideoPlayerController controller,
|
super.didUpdateWidget(oldWidget);
|
||||||
Debouncer seekDebouncer,
|
|
||||||
bool isPaused,
|
if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return;
|
||||||
) async {
|
|
||||||
if (!context.mounted) {
|
if (!widget.isCurrent) {
|
||||||
|
_loadTimer?.cancel();
|
||||||
|
_notifier.pause();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure the last seek is complete before pausing or playing
|
// Prevent unnecessary loading when swiping between assets.
|
||||||
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
|
||||||
if (seekDebouncer.isActive) {
|
}
|
||||||
await seekDebouncer.drain();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
@override
|
||||||
if (isPaused) {
|
void dispose() {
|
||||||
await controller.pause();
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
} else {
|
_loadTimer?.cancel();
|
||||||
await controller.play();
|
_removeListeners();
|
||||||
}
|
super.dispose();
|
||||||
} catch (error) {
|
}
|
||||||
log.severe('Error pausing or playing video: $error');
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) async {
|
||||||
|
switch (state) {
|
||||||
|
case AppLifecycleState.resumed:
|
||||||
|
if (_shouldPlayOnForeground) await _notifier.play();
|
||||||
|
case AppLifecycleState.paused:
|
||||||
|
_shouldPlayOnForeground = await _controller?.isPlaying() ?? true;
|
||||||
|
if (_shouldPlayOnForeground) await _notifier.pause();
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<VideoSource?> _createSource() async {
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
final videoAsset = await ref.read(assetServiceProvider).getAsset(widget.asset) ?? widget.asset;
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||||
|
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||||
|
final file = await StorageRepository().getFileForAsset(id);
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
if (file == null) {
|
||||||
|
throw Exception('No file found for the video');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass a file:// URI so Android's Uri.parse doesn't
|
||||||
|
// interpret characters like '#' as fragment identifiers.
|
||||||
|
return VideoSource.init(
|
||||||
|
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
||||||
|
type: VideoSourceType.file,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final remoteId = (videoAsset as RemoteAsset).id;
|
||||||
|
|
||||||
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||||
|
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||||
|
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||||
|
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||||
|
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||||
|
|
||||||
|
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
|
||||||
|
} catch (error) {
|
||||||
|
_log.severe('Error creating video source for asset ${videoAsset.name}: $error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPlaybackReady() async {
|
||||||
|
if (!mounted || !widget.isCurrent) return;
|
||||||
|
|
||||||
|
_notifier.onNativePlaybackReady();
|
||||||
|
|
||||||
|
// onPlaybackReady may be called multiple times, usually when more data
|
||||||
|
// loads. If this is not the first time that the player has become ready, we
|
||||||
|
// should not autoplay.
|
||||||
|
if (_isVideoReady) return;
|
||||||
|
|
||||||
|
setState(() => _isVideoReady = true);
|
||||||
|
|
||||||
|
if (ref.read(assetViewerProvider).showingDetails) return;
|
||||||
|
|
||||||
|
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
|
||||||
|
if (autoPlayVideo) await _notifier.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPlaybackEnded() {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
_notifier.onNativePlaybackEnded();
|
||||||
|
|
||||||
|
if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) {
|
||||||
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPlaybackPositionChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
_notifier.onNativePositionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPlaybackStatusChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
_notifier.onNativeStatusChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeListeners() {
|
||||||
|
_controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged);
|
||||||
|
_controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged);
|
||||||
|
_controller?.onPlaybackReady.removeListener(_onPlaybackReady);
|
||||||
|
_controller?.onPlaybackEnded.removeListener(_onPlaybackEnded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadVideo() async {
|
||||||
|
final nc = _controller;
|
||||||
|
if (nc == null || nc.videoSource != null || !mounted) return;
|
||||||
|
|
||||||
|
final source = await _videoSource;
|
||||||
|
if (source == null || !mounted) return;
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
nc.loadVideoSource(source).catchError((error) {
|
||||||
|
_log.severe('Error loading video source: $error');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||||
|
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
||||||
|
await _notifier.setVolume(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initController(NativeVideoPlayerController nc) {
|
||||||
|
if (_controller != null || !mounted) return;
|
||||||
|
|
||||||
|
_notifier.attachController(nc);
|
||||||
|
|
||||||
|
nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged);
|
||||||
|
nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged);
|
||||||
|
nc.onPlaybackReady.addListener(_onPlaybackReady);
|
||||||
|
nc.onPlaybackEnded.addListener(_onPlaybackEnded);
|
||||||
|
|
||||||
|
_controller = nc;
|
||||||
|
|
||||||
|
if (widget.isCurrent) _loadVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Prevent the provider from being disposed whilst the widget is alive.
|
||||||
|
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
|
||||||
|
|
||||||
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Center(child: widget.image),
|
||||||
|
if (!isCasting)
|
||||||
|
Visibility.maintain(
|
||||||
|
visible: _isVideoReady,
|
||||||
|
child: NativeVideoPlayerView(onViewReady: _initController),
|
||||||
|
),
|
||||||
|
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package: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/models/cast/cast_manager_state.dart';
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||||
|
|
||||||
class VideoViewerControls extends HookConsumerWidget {
|
class VideoViewerControls extends HookConsumerWidget {
|
||||||
|
final BaseAsset asset;
|
||||||
final Duration hideTimerDuration;
|
final Duration hideTimerDuration;
|
||||||
|
|
||||||
const VideoViewerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
|
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
|
final videoPlayerName = asset.heroTag;
|
||||||
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
final assetIsVideo = asset.isVideo;
|
||||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
|
||||||
if (showingDetails) {
|
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
|
||||||
showControls = false;
|
|
||||||
}
|
|
||||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
|
||||||
|
|
||||||
final cast = ref.watch(castProvider);
|
final cast = ref.watch(castProvider);
|
||||||
|
|
||||||
@@ -32,14 +29,14 @@ class VideoViewerControls extends HookConsumerWidget {
|
|||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||||
|
|
||||||
// Do not hide on paused
|
// Do not hide on paused
|
||||||
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
|
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
|
||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
|
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||||
|
|
||||||
/// Shows the controls and starts the timer to hide them
|
/// Shows the controls and starts the timer to hide them
|
||||||
void showControlsAndStartHideTimer() {
|
void showControlsAndStartHideTimer() {
|
||||||
@@ -47,9 +44,11 @@ class VideoViewerControls extends HookConsumerWidget {
|
|||||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When we change position, show or hide timer
|
// When playback starts, reset the hide timer
|
||||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
|
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
|
||||||
showControlsAndStartHideTimer();
|
if (next == VideoPlaybackStatus.playing) {
|
||||||
|
hideTimer.reset();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Toggles between playing and pausing depending on the state of the video
|
/// Toggles between playing and pausing depending on the state of the video
|
||||||
@@ -57,34 +56,30 @@ class VideoViewerControls extends HookConsumerWidget {
|
|||||||
showControlsAndStartHideTimer();
|
showControlsAndStartHideTimer();
|
||||||
|
|
||||||
if (cast.isCasting) {
|
if (cast.isCasting) {
|
||||||
if (cast.castState == CastState.playing) {
|
switch (cast.castState) {
|
||||||
ref.read(castProvider.notifier).pause();
|
case CastState.playing:
|
||||||
} else if (cast.castState == CastState.paused) {
|
ref.read(castProvider.notifier).pause();
|
||||||
ref.read(castProvider.notifier).play();
|
case CastState.paused:
|
||||||
} else if (cast.castState == CastState.idle) {
|
ref.read(castProvider.notifier).play();
|
||||||
// resend the play command since its finished
|
default:
|
||||||
final asset = ref.read(currentAssetNotifier);
|
|
||||||
if (asset == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ref.read(castProvider.notifier).loadMedia(asset, true);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state == VideoPlaybackState.playing) {
|
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
switch (status) {
|
||||||
} else if (state == VideoPlaybackState.completed) {
|
case VideoPlaybackStatus.playing:
|
||||||
ref.read(videoPlayerControlsProvider.notifier).restart();
|
notifier.pause();
|
||||||
} else {
|
case VideoPlaybackStatus.completed:
|
||||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
notifier.restart();
|
||||||
|
default:
|
||||||
|
notifier.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleControlsVisibility() {
|
void toggleControlsVisibility() {
|
||||||
if (showBuffering) {
|
if (showBuffering) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (showControls) {
|
if (showControls) {
|
||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
} else {
|
} else {
|
||||||
@@ -105,9 +100,9 @@ class VideoViewerControls extends HookConsumerWidget {
|
|||||||
CenterPlayButton(
|
CenterPlayButton(
|
||||||
backgroundColor: Colors.black54,
|
backgroundColor: Colors.black54,
|
||||||
iconColor: Colors.white,
|
iconColor: Colors.white,
|
||||||
isFinished: state == VideoPlaybackState.completed,
|
isFinished: status == VideoPlaybackStatus.completed,
|
||||||
isPlaying:
|
isPlaying:
|
||||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||||
show: assetIsVideo && showControls,
|
show: assetIsVideo && showControls,
|
||||||
onPressed: togglePlay,
|
onPressed: togglePlay,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +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/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/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||||
|
|
||||||
class ViewerBottomAppBar extends ConsumerWidget {
|
class ViewerBottomAppBar extends ConsumerWidget {
|
||||||
@@ -9,24 +8,12 @@ class ViewerBottomAppBar extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||||
|
|
||||||
if (!showControls) {
|
|
||||||
opacity = 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: opacity < 1.0,
|
ignoring: opacity < 1.0,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const ViewerBottomBar()),
|
||||||
opacity: opacity,
|
|
||||||
duration: Durations.short2,
|
|
||||||
child: const Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [AssetStackRow(), ViewerBottomBar()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.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/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/infrastructure/setting.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
@@ -21,7 +21,7 @@ class ViewerKebabMenu 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.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/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
@@ -22,7 +21,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
|
|
||||||
@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();
|
||||||
}
|
}
|
||||||
@@ -35,16 +34,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
|
||||||
|
|
||||||
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
||||||
ref.watch(albumActivityProvider(album.id, asset.id));
|
ref.watch(albumActivityProvider(album.id, asset.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!showControls) {
|
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
opacity = 0.0;
|
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||||
}
|
|
||||||
|
|
||||||
final originalTheme = context.themeData;
|
final originalTheme = context.themeData;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ class DriftMemoryCard extends StatelessWidget {
|
|||||||
final RemoteAsset asset;
|
final RemoteAsset asset;
|
||||||
final String title;
|
final String title;
|
||||||
final bool showTitle;
|
final bool showTitle;
|
||||||
|
final bool isCurrent;
|
||||||
final Function()? onVideoEnded;
|
final Function()? onVideoEnded;
|
||||||
|
|
||||||
const DriftMemoryCard({
|
const DriftMemoryCard({
|
||||||
required this.asset,
|
required this.asset,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.showTitle,
|
required this.showTitle,
|
||||||
|
this.isCurrent = false,
|
||||||
this.onVideoEnded,
|
this.onVideoEnded,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
@@ -37,32 +39,35 @@ class DriftMemoryCard extends StatelessWidget {
|
|||||||
SizedBox.expand(child: _BlurredBackdrop(asset: asset)),
|
SizedBox.expand(child: _BlurredBackdrop(asset: asset)),
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
final r = asset.width != null && asset.height != null
|
||||||
|
? asset.width! / asset.height!
|
||||||
|
: constraints.maxWidth / constraints.maxHeight;
|
||||||
|
|
||||||
// Determine the fit using the aspect ratio
|
// Determine the fit using the aspect ratio
|
||||||
BoxFit fit = BoxFit.contain;
|
BoxFit fit = BoxFit.contain;
|
||||||
if (asset.width != null && asset.height != null) {
|
if (asset.width != null && asset.height != null) {
|
||||||
final aspectRatio = asset.width! / asset.height!;
|
|
||||||
final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight;
|
final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight;
|
||||||
// Look for a 25% difference in either direction
|
// Look for a 25% difference in either direction
|
||||||
if (phoneAspectRatio * .75 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) {
|
if (phoneAspectRatio * .75 < r && phoneAspectRatio * 1.25 > r) {
|
||||||
// Cover to look nice if we have nearly the same aspect ratio
|
// Cover to look nice if we have nearly the same aspect ratio
|
||||||
fit = BoxFit.cover;
|
fit = BoxFit.cover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.isImage) {
|
if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
|
||||||
return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
|
|
||||||
} else {
|
return Center(
|
||||||
return SizedBox(
|
child: AspectRatio(
|
||||||
width: context.width,
|
aspectRatio: r,
|
||||||
height: context.height,
|
|
||||||
child: NativeVideoViewer(
|
child: NativeVideoViewer(
|
||||||
key: ValueKey(asset.id),
|
key: ValueKey(asset.id),
|
||||||
asset: asset,
|
asset: asset,
|
||||||
playbackDelayFactor: 2,
|
isCurrent: isCurrent,
|
||||||
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
|
showControls: false,
|
||||||
|
image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (showTitle)
|
if (showTitle)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const Size kTimelineFixedTileExtent = Size.square(256);
|
|||||||
const double kTimelineSpacing = 2.0;
|
const double kTimelineSpacing = 2.0;
|
||||||
const int kTimelineColumnCount = 3;
|
const int kTimelineColumnCount = 3;
|
||||||
|
|
||||||
|
const double kScrubberThumbHeight = 48.0;
|
||||||
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
||||||
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
||||||
|
|
||||||
|
|||||||
@@ -530,12 +530,14 @@ class _CircularThumb extends StatelessWidget {
|
|||||||
elevation: 4.0,
|
elevation: 4.0,
|
||||||
color: backgroundColor,
|
color: backgroundColor,
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(48.0),
|
topLeft: Radius.circular(kScrubberThumbHeight),
|
||||||
bottomLeft: Radius.circular(48.0),
|
bottomLeft: Radius.circular(kScrubberThumbHeight),
|
||||||
topRight: Radius.circular(4.0),
|
topRight: Radius.circular(4.0),
|
||||||
bottomRight: Radius.circular(4.0),
|
bottomRight: Radius.circular(4.0),
|
||||||
),
|
),
|
||||||
child: Container(constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0))),
|
child: Container(
|
||||||
|
constraints: BoxConstraints.tight(const Size(kScrubberThumbHeight * 0.6, kScrubberThumbHeight)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||||
@@ -34,6 +35,7 @@ class Timeline extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
this.topSliverWidget,
|
this.topSliverWidget,
|
||||||
this.topSliverWidgetHeight,
|
this.topSliverWidgetHeight,
|
||||||
|
this.bottomSliverWidget,
|
||||||
this.showStorageIndicator = false,
|
this.showStorageIndicator = false,
|
||||||
this.withStack = false,
|
this.withStack = false,
|
||||||
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
|
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
|
||||||
@@ -41,13 +43,14 @@ class Timeline extends StatelessWidget {
|
|||||||
this.groupBy,
|
this.groupBy,
|
||||||
this.withScrubber = true,
|
this.withScrubber = true,
|
||||||
this.snapToMonth = true,
|
this.snapToMonth = true,
|
||||||
this.initialScrollOffset,
|
|
||||||
this.readOnly = false,
|
this.readOnly = false,
|
||||||
this.persistentBottomBar = false,
|
this.persistentBottomBar = false,
|
||||||
|
this.loadingWidget,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
final double? topSliverWidgetHeight;
|
final double? topSliverWidgetHeight;
|
||||||
|
final Widget? bottomSliverWidget;
|
||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
final Widget? appBar;
|
final Widget? appBar;
|
||||||
final Widget? bottomSheet;
|
final Widget? bottomSheet;
|
||||||
@@ -55,9 +58,9 @@ class Timeline extends StatelessWidget {
|
|||||||
final GroupAssetsBy? groupBy;
|
final GroupAssetsBy? groupBy;
|
||||||
final bool withScrubber;
|
final bool withScrubber;
|
||||||
final bool snapToMonth;
|
final bool snapToMonth;
|
||||||
final double? initialScrollOffset;
|
|
||||||
final bool readOnly;
|
final bool readOnly;
|
||||||
final bool persistentBottomBar;
|
final bool persistentBottomBar;
|
||||||
|
final Widget? loadingWidget;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -82,13 +85,14 @@ class Timeline extends StatelessWidget {
|
|||||||
child: _SliverTimeline(
|
child: _SliverTimeline(
|
||||||
topSliverWidget: topSliverWidget,
|
topSliverWidget: topSliverWidget,
|
||||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||||
|
bottomSliverWidget: bottomSliverWidget,
|
||||||
appBar: appBar,
|
appBar: appBar,
|
||||||
bottomSheet: bottomSheet,
|
bottomSheet: bottomSheet,
|
||||||
withScrubber: withScrubber,
|
withScrubber: withScrubber,
|
||||||
persistentBottomBar: persistentBottomBar,
|
persistentBottomBar: persistentBottomBar,
|
||||||
snapToMonth: snapToMonth,
|
snapToMonth: snapToMonth,
|
||||||
initialScrollOffset: initialScrollOffset,
|
|
||||||
maxWidth: constraints.maxWidth,
|
maxWidth: constraints.maxWidth,
|
||||||
|
loadingWidget: loadingWidget,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -111,24 +115,26 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
|||||||
const _SliverTimeline({
|
const _SliverTimeline({
|
||||||
this.topSliverWidget,
|
this.topSliverWidget,
|
||||||
this.topSliverWidgetHeight,
|
this.topSliverWidgetHeight,
|
||||||
|
this.bottomSliverWidget,
|
||||||
this.appBar,
|
this.appBar,
|
||||||
this.bottomSheet,
|
this.bottomSheet,
|
||||||
this.withScrubber = true,
|
this.withScrubber = true,
|
||||||
this.persistentBottomBar = false,
|
this.persistentBottomBar = false,
|
||||||
this.snapToMonth = true,
|
this.snapToMonth = true,
|
||||||
this.initialScrollOffset,
|
|
||||||
this.maxWidth,
|
this.maxWidth,
|
||||||
|
this.loadingWidget,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
final double? topSliverWidgetHeight;
|
final double? topSliverWidgetHeight;
|
||||||
|
final Widget? bottomSliverWidget;
|
||||||
final Widget? appBar;
|
final Widget? appBar;
|
||||||
final Widget? bottomSheet;
|
final Widget? bottomSheet;
|
||||||
final bool withScrubber;
|
final bool withScrubber;
|
||||||
final bool persistentBottomBar;
|
final bool persistentBottomBar;
|
||||||
final bool snapToMonth;
|
final bool snapToMonth;
|
||||||
final double? initialScrollOffset;
|
|
||||||
final double? maxWidth;
|
final double? maxWidth;
|
||||||
|
final Widget? loadingWidget;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _SliverTimelineState();
|
ConsumerState createState() => _SliverTimelineState();
|
||||||
@@ -152,10 +158,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollController = ScrollController(
|
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||||
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
|
|
||||||
onAttach: _restoreAssetPosition,
|
|
||||||
);
|
|
||||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||||
|
|
||||||
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
|
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
|
||||||
@@ -373,6 +376,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: asyncSegments.widgetWhen(
|
child: asyncSegments.widgetWhen(
|
||||||
|
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
|
||||||
onData: (segments) {
|
onData: (segments) {
|
||||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||||
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
||||||
@@ -380,12 +384,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
: 0;
|
: 0;
|
||||||
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
||||||
|
|
||||||
const scrubberBottomPadding = 100.0;
|
|
||||||
const bottomSheetOpenModifier = 120.0;
|
const bottomSheetOpenModifier = 120.0;
|
||||||
final bottomPadding =
|
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
||||||
context.padding.bottom +
|
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
|
||||||
(widget.appBar == null ? 0 : scrubberBottomPadding) +
|
|
||||||
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
|
||||||
|
|
||||||
final grid = CustomScrollView(
|
final grid = CustomScrollView(
|
||||||
primary: true,
|
primary: true,
|
||||||
@@ -408,7 +409,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
addRepaintBoundaries: false,
|
addRepaintBoundaries: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
|
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
|
||||||
|
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -419,7 +421,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
layoutSegments: segments,
|
layoutSegments: segments,
|
||||||
timelineHeight: maxHeight,
|
timelineHeight: maxHeight,
|
||||||
topPadding: topPadding,
|
topPadding: topPadding,
|
||||||
bottomPadding: bottomPadding,
|
bottomPadding: scrubberBottomPadding,
|
||||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||||
hasAppBar: widget.appBar != null,
|
hasAppBar: widget.appBar != null,
|
||||||
child: grid,
|
child: grid,
|
||||||
|
|||||||
+22
-6
@@ -1,5 +1,7 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
class AssetViewerState {
|
class AssetViewerState {
|
||||||
@@ -68,6 +70,12 @@ class AssetViewerState {
|
|||||||
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||||
@override
|
@override
|
||||||
AssetViewerState build() {
|
AssetViewerState build() {
|
||||||
|
ref.listen(_watchedCurrentAssetProvider, (_, next) {
|
||||||
|
final updated = next.valueOrNull;
|
||||||
|
if (updated != null) {
|
||||||
|
state = state.copyWith(currentAsset: updated);
|
||||||
|
}
|
||||||
|
});
|
||||||
return const AssetViewerState();
|
return const AssetViewerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,10 +83,8 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
state = const AssetViewerState();
|
state = const AssetViewerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setAsset(BaseAsset? asset) {
|
void setAsset(BaseAsset asset) {
|
||||||
if (asset == state.currentAsset) {
|
if (asset == state.currentAsset) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +101,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
}
|
}
|
||||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||||
if (showing) {
|
if (showing) {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
final heroTag = state.currentAsset?.heroTag;
|
||||||
|
if (heroTag != null) {
|
||||||
|
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,3 +135,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||||
|
|
||||||
|
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
|
||||||
|
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||||
|
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||||
|
if (asset == null) return const Stream.empty();
|
||||||
|
return ref.read(assetServiceProvider).watchAsset(asset);
|
||||||
|
});
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
|
|
||||||
class VideoPlaybackControls {
|
|
||||||
const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false});
|
|
||||||
|
|
||||||
final Duration position;
|
|
||||||
final bool pause;
|
|
||||||
final bool restarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoPlayerControlsProvider = StateNotifierProvider<VideoPlayerControls, VideoPlaybackControls>((ref) {
|
|
||||||
return VideoPlayerControls(ref);
|
|
||||||
});
|
|
||||||
|
|
||||||
const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false);
|
|
||||||
|
|
||||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|
||||||
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
|
||||||
|
|
||||||
final Ref ref;
|
|
||||||
|
|
||||||
VideoPlaybackControls get value => state;
|
|
||||||
|
|
||||||
set value(VideoPlaybackControls value) {
|
|
||||||
state = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
state = videoPlayerControlsDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
Duration get position => state.position;
|
|
||||||
bool get paused => state.pause;
|
|
||||||
|
|
||||||
set position(Duration value) {
|
|
||||||
if (state.position == value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = VideoPlaybackControls(position: value, pause: state.pause);
|
|
||||||
}
|
|
||||||
|
|
||||||
void pause() {
|
|
||||||
if (state.pause) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = VideoPlaybackControls(position: state.position, pause: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
void play() {
|
|
||||||
if (!state.pause) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = VideoPlaybackControls(position: state.position, pause: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
void togglePlay() {
|
|
||||||
state = VideoPlaybackControls(position: state.position, pause: !state.pause);
|
|
||||||
}
|
|
||||||
|
|
||||||
void restart() {
|
|
||||||
state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true);
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = ref
|
|
||||||
.read(videoPlaybackValueProvider.notifier)
|
|
||||||
.value
|
|
||||||
.copyWith(state: VideoPlaybackState.playing, position: Duration.zero);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
|
enum VideoPlaybackStatus { paused, playing, buffering, completed }
|
||||||
|
|
||||||
|
class VideoPlayerState {
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
final VideoPlaybackStatus status;
|
||||||
|
|
||||||
|
const VideoPlayerState({required this.position, required this.duration, required this.status});
|
||||||
|
|
||||||
|
VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) {
|
||||||
|
return VideoPlayerState(
|
||||||
|
position: position ?? this.position,
|
||||||
|
duration: duration ?? this.duration,
|
||||||
|
status: status ?? this.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _defaultState = VideoPlayerState(
|
||||||
|
position: Duration.zero,
|
||||||
|
duration: Duration.zero,
|
||||||
|
status: VideoPlaybackStatus.paused,
|
||||||
|
);
|
||||||
|
|
||||||
|
final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayerNotifier, VideoPlayerState, String>((
|
||||||
|
ref,
|
||||||
|
name,
|
||||||
|
) {
|
||||||
|
return VideoPlayerNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
|
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||||
|
static final _log = Logger('VideoPlayerNotifier');
|
||||||
|
|
||||||
|
VideoPlayerNotifier() : super(_defaultState);
|
||||||
|
|
||||||
|
NativeVideoPlayerController? _controller;
|
||||||
|
Timer? _bufferingTimer;
|
||||||
|
Timer? _seekTimer;
|
||||||
|
|
||||||
|
void attachController(NativeVideoPlayerController controller) {
|
||||||
|
_controller = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_bufferingTimer?.cancel();
|
||||||
|
_seekTimer?.cancel();
|
||||||
|
WakelockPlus.disable();
|
||||||
|
_controller = null;
|
||||||
|
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pause() async {
|
||||||
|
if (_controller == null) return;
|
||||||
|
|
||||||
|
_bufferingTimer?.cancel();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _controller!.pause();
|
||||||
|
await _flushSeek();
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Error pausing video: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> play() async {
|
||||||
|
if (_controller == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _flushSeek();
|
||||||
|
await _controller!.play();
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Error playing video: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
_startBufferingTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flushSeek() async {
|
||||||
|
final timer = _seekTimer;
|
||||||
|
if (timer == null || !timer.isActive) return;
|
||||||
|
|
||||||
|
timer.cancel();
|
||||||
|
await _controller?.seekTo(state.position.inMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
void seekTo(Duration position) {
|
||||||
|
if (_controller == null) return;
|
||||||
|
|
||||||
|
state = state.copyWith(position: position);
|
||||||
|
|
||||||
|
_seekTimer?.cancel();
|
||||||
|
_seekTimer = Timer(const Duration(milliseconds: 100), () {
|
||||||
|
_controller?.seekTo(position.inMilliseconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> restart() async {
|
||||||
|
seekTo(Duration.zero);
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setVolume(double volume) async {
|
||||||
|
try {
|
||||||
|
await _controller?.setVolume(volume);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Error setting volume: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLoop(bool loop) async {
|
||||||
|
try {
|
||||||
|
await _controller?.setLoop(loop);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Error setting loop: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNativePlaybackReady() {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final playbackInfo = _controller?.playbackInfo;
|
||||||
|
final videoInfo = _controller?.videoInfo;
|
||||||
|
|
||||||
|
if (playbackInfo == null || videoInfo == null) return;
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
position: Duration(milliseconds: playbackInfo.position),
|
||||||
|
duration: Duration(milliseconds: videoInfo.duration),
|
||||||
|
status: _mapStatus(playbackInfo.status),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNativePositionChanged() {
|
||||||
|
if (!mounted || (_seekTimer?.isActive ?? false)) return;
|
||||||
|
|
||||||
|
final playbackInfo = _controller?.playbackInfo;
|
||||||
|
if (playbackInfo == null) return;
|
||||||
|
|
||||||
|
final position = Duration(milliseconds: playbackInfo.position);
|
||||||
|
if (state.position == position) return;
|
||||||
|
|
||||||
|
if (state.status == VideoPlaybackStatus.buffering) {
|
||||||
|
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(position: position);
|
||||||
|
}
|
||||||
|
|
||||||
|
_startBufferingTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNativeStatusChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final playbackInfo = _controller?.playbackInfo;
|
||||||
|
if (playbackInfo == null) return;
|
||||||
|
|
||||||
|
final newStatus = _mapStatus(playbackInfo.status);
|
||||||
|
switch (newStatus) {
|
||||||
|
case VideoPlaybackStatus.playing:
|
||||||
|
WakelockPlus.enable();
|
||||||
|
_startBufferingTimer();
|
||||||
|
default:
|
||||||
|
onNativePlaybackEnded();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status != newStatus) {
|
||||||
|
state = state.copyWith(status: newStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNativePlaybackEnded() {
|
||||||
|
WakelockPlus.disable();
|
||||||
|
_bufferingTimer?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startBufferingTimer() {
|
||||||
|
_bufferingTimer?.cancel();
|
||||||
|
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
||||||
|
if (mounted && state.status == VideoPlaybackStatus.playing) {
|
||||||
|
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) {
|
||||||
|
PlaybackStatus.playing => VideoPlaybackStatus.playing,
|
||||||
|
PlaybackStatus.paused => VideoPlaybackStatus.paused,
|
||||||
|
PlaybackStatus.stopped => VideoPlaybackStatus.completed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:native_video_player/native_video_player.dart';
|
|
||||||
|
|
||||||
enum VideoPlaybackState { initializing, paused, playing, buffering, completed }
|
|
||||||
|
|
||||||
class VideoPlaybackValue {
|
|
||||||
/// The current position of the video
|
|
||||||
final Duration position;
|
|
||||||
|
|
||||||
/// The total duration of the video
|
|
||||||
final Duration duration;
|
|
||||||
|
|
||||||
/// The current state of the video playback
|
|
||||||
final VideoPlaybackState state;
|
|
||||||
|
|
||||||
/// The volume of the video
|
|
||||||
final double volume;
|
|
||||||
|
|
||||||
const VideoPlaybackValue({required this.position, required this.duration, required this.state, required this.volume});
|
|
||||||
|
|
||||||
factory VideoPlaybackValue.fromNativeController(NativeVideoPlayerController controller) {
|
|
||||||
final playbackInfo = controller.playbackInfo;
|
|
||||||
final videoInfo = controller.videoInfo;
|
|
||||||
|
|
||||||
if (playbackInfo == null || videoInfo == null) {
|
|
||||||
return videoPlaybackValueDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
final VideoPlaybackState status = switch (playbackInfo.status) {
|
|
||||||
PlaybackStatus.playing => VideoPlaybackState.playing,
|
|
||||||
PlaybackStatus.paused => VideoPlaybackState.paused,
|
|
||||||
PlaybackStatus.stopped => VideoPlaybackState.completed,
|
|
||||||
};
|
|
||||||
|
|
||||||
return VideoPlaybackValue(
|
|
||||||
position: Duration(milliseconds: playbackInfo.position),
|
|
||||||
duration: Duration(milliseconds: videoInfo.duration),
|
|
||||||
state: status,
|
|
||||||
volume: playbackInfo.volume,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
VideoPlaybackValue copyWith({Duration? position, Duration? duration, VideoPlaybackState? state, double? volume}) {
|
|
||||||
return VideoPlaybackValue(
|
|
||||||
position: position ?? this.position,
|
|
||||||
duration: duration ?? this.duration,
|
|
||||||
state: state ?? this.state,
|
|
||||||
volume: volume ?? this.volume,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
|
|
||||||
position: Duration.zero,
|
|
||||||
duration: Duration.zero,
|
|
||||||
state: VideoPlaybackState.initializing,
|
|
||||||
volume: 0.0,
|
|
||||||
);
|
|
||||||
|
|
||||||
final videoPlaybackValueProvider = StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
|
||||||
return VideoPlaybackValueState(ref);
|
|
||||||
});
|
|
||||||
|
|
||||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|
||||||
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
|
|
||||||
|
|
||||||
final Ref ref;
|
|
||||||
|
|
||||||
VideoPlaybackValue get value => state;
|
|
||||||
|
|
||||||
set value(VideoPlaybackValue value) {
|
|
||||||
state = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
set position(Duration value) {
|
|
||||||
if (state.position == value) return;
|
|
||||||
state = VideoPlaybackValue(position: value, duration: state.duration, state: state.state, volume: state.volume);
|
|
||||||
}
|
|
||||||
|
|
||||||
set status(VideoPlaybackState value) {
|
|
||||||
if (state.state == value) return;
|
|
||||||
state = VideoPlaybackValue(position: state.position, duration: state.duration, state: value, volume: state.volume);
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
state = videoPlaybackValueDefault;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,9 @@ import 'package:immich_mobile/constants/enums.dart';
|
|||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/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';
|
||||||
@@ -123,7 +123,7 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
Set<BaseAsset> _getAssets(ActionSource source) {
|
Set<BaseAsset> _getAssets(ActionSource source) {
|
||||||
return switch (source) {
|
return switch (source) {
|
||||||
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
||||||
ActionSource.viewer => switch (ref.read(currentAssetNotifier)) {
|
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
|
||||||
BaseAsset asset => {asset},
|
BaseAsset asset => {asset},
|
||||||
null => const {},
|
null => const {},
|
||||||
},
|
},
|
||||||
@@ -307,7 +307,10 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
// does not update the currentAsset which means
|
// does not update the currentAsset which means
|
||||||
// the exif provider will not be refreshed automatically
|
// the exif provider will not be refreshed automatically
|
||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
ref.invalidate(currentAssetExifProvider);
|
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||||
|
if (currentAsset != null) {
|
||||||
|
ref.invalidate(assetExifProvider(currentAsset));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
return ActionResult(count: ids.length, success: true);
|
||||||
@@ -409,7 +412,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
|
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
|
||||||
if (updatedParent != null) {
|
if (updatedParent != null) {
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
|
|
||||||
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
|
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,8 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package: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/exif.model.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
|
||||||
final currentAssetNotifier = AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset?>(CurrentAssetNotifier.new);
|
final assetExifProvider = FutureProvider.autoDispose.family<ExifInfo?, BaseAsset>((ref, asset) {
|
||||||
|
return ref.watch(assetServiceProvider).getExif(asset);
|
||||||
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
|
|
||||||
KeepAliveLink? _keepAliveLink;
|
|
||||||
StreamSubscription<BaseAsset?>? _assetSubscription;
|
|
||||||
|
|
||||||
@override
|
|
||||||
BaseAsset? build() => null;
|
|
||||||
|
|
||||||
void setAsset(BaseAsset asset) {
|
|
||||||
_keepAliveLink?.close();
|
|
||||||
_assetSubscription?.cancel();
|
|
||||||
state = asset;
|
|
||||||
_assetSubscription = ref.watch(assetServiceProvider).watchAsset(asset).listen((updatedAsset) {
|
|
||||||
if (updatedAsset != null) {
|
|
||||||
state = updatedAsset;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_keepAliveLink = ref.keepAlive();
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
_keepAliveLink?.close();
|
|
||||||
_assetSubscription?.cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ScopedAssetNotifier extends CurrentAssetNotifier {
|
|
||||||
final BaseAsset _asset;
|
|
||||||
|
|
||||||
ScopedAssetNotifier(this._asset);
|
|
||||||
|
|
||||||
@override
|
|
||||||
BaseAsset? build() {
|
|
||||||
setAsset(_asset);
|
|
||||||
return _asset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final currentAssetExifProvider = FutureProvider.autoDispose((ref) {
|
|
||||||
final currentAsset = ref.watch(currentAssetNotifier);
|
|
||||||
if (currentAsset == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return ref.watch(assetServiceProvider).getExif(currentAsset);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
|
|
||||||
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
|
|
||||||
void useInterval(Duration delay, VoidCallback callback) {
|
|
||||||
final savedCallback = useRef(callback);
|
|
||||||
savedCallback.value = callback;
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
final timer = Timer.periodic(delay, (_) => savedCallback.value());
|
|
||||||
return timer.cancel;
|
|
||||||
}, [delay]);
|
|
||||||
}
|
|
||||||
@@ -333,7 +333,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.only(top: 40.0),
|
padding: const EdgeInsets.only(top: 40.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (asset.isVideo) const VideoControls(),
|
if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()),
|
||||||
BottomNavigationBar(
|
BottomNavigationBar(
|
||||||
elevation: 0.0,
|
elevation: 0.0,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
|
|||||||
@@ -3,23 +3,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
import 'package:immich_mobile/models/cast/cast_manager_state.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/show_controls.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_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/utils/hooks/timer_hook.dart';
|
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||||
|
|
||||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||||
|
final String videoId;
|
||||||
final Duration hideTimerDuration;
|
final Duration hideTimerDuration;
|
||||||
|
|
||||||
const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
|
const CustomVideoPlayerControls({
|
||||||
|
super.key,
|
||||||
|
required this.videoId,
|
||||||
|
this.hideTimerDuration = const Duration(seconds: 5),
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
|
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
|
||||||
final showControls = ref.watch(showControlsProvider);
|
final showControls = ref.watch(showControlsProvider);
|
||||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status));
|
||||||
|
|
||||||
final cast = ref.watch(castProvider);
|
final cast = ref.watch(castProvider);
|
||||||
|
|
||||||
@@ -28,14 +32,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
final s = ref.read(videoPlayerProvider(videoId)).status;
|
||||||
|
|
||||||
// Do not hide on paused
|
// Do not hide on paused
|
||||||
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
|
if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) {
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
|
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||||
|
|
||||||
/// Shows the controls and starts the timer to hide them
|
/// Shows the controls and starts the timer to hide them
|
||||||
void showControlsAndStartHideTimer() {
|
void showControlsAndStartHideTimer() {
|
||||||
@@ -43,9 +47,11 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
ref.read(showControlsProvider.notifier).show = true;
|
ref.read(showControlsProvider.notifier).show = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When we change position, show or hide timer
|
// When playback starts, reset the hide timer
|
||||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
|
ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) {
|
||||||
showControlsAndStartHideTimer();
|
if (next == VideoPlaybackStatus.playing) {
|
||||||
|
hideTimer.reset();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Toggles between playing and pausing depending on the state of the video
|
/// Toggles between playing and pausing depending on the state of the video
|
||||||
@@ -68,12 +74,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state == VideoPlaybackState.playing) {
|
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
if (status == VideoPlaybackStatus.playing) {
|
||||||
} else if (state == VideoPlaybackState.completed) {
|
notifier.pause();
|
||||||
ref.read(videoPlayerControlsProvider.notifier).restart();
|
} else if (status == VideoPlaybackStatus.completed) {
|
||||||
|
notifier.restart();
|
||||||
} else {
|
} else {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
notifier.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +99,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
child: CenterPlayButton(
|
child: CenterPlayButton(
|
||||||
backgroundColor: Colors.black54,
|
backgroundColor: Colors.black54,
|
||||||
iconColor: Colors.white,
|
iconColor: Colors.white,
|
||||||
isFinished: state == VideoPlaybackState.completed,
|
isFinished: status == VideoPlaybackStatus.completed,
|
||||||
isPlaying:
|
isPlaying:
|
||||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||||
show: assetIsVideo && showControls,
|
show: assetIsVideo && showControls,
|
||||||
onPressed: togglePlay,
|
onPressed: togglePlay,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
||||||
|
|
||||||
/// The video controls for the [videoPlayerControlsProvider]
|
/// The video controls for the [videoPlayerProvider]
|
||||||
class VideoControls extends ConsumerWidget {
|
class VideoControls extends ConsumerWidget {
|
||||||
const VideoControls({super.key});
|
final String videoPlayerName;
|
||||||
|
|
||||||
|
const VideoControls({super.key, required this.videoPlayerName});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isPortrait = context.orientation == Orientation.portrait;
|
final isPortrait = context.orientation == Orientation.portrait;
|
||||||
return isPortrait
|
return isPortrait
|
||||||
? const VideoPosition()
|
? VideoPosition(videoPlayerName: videoPlayerName)
|
||||||
: const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition());
|
: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 60.0),
|
||||||
|
child: VideoPosition(videoPlayerName: videoPlayerName),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/colors.dart';
|
import 'package:immich_mobile/constants/colors.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_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/widgets/asset_viewer/formatted_duration.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
||||||
|
|
||||||
class VideoPosition extends HookConsumerWidget {
|
class VideoPosition extends HookConsumerWidget {
|
||||||
const VideoPosition({super.key});
|
final String videoPlayerName;
|
||||||
|
|
||||||
|
const VideoPosition({super.key, required this.videoPlayerName});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -18,7 +19,7 @@ class VideoPosition extends HookConsumerWidget {
|
|||||||
|
|
||||||
final (position, duration) = isCasting
|
final (position, duration) = isCasting
|
||||||
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
||||||
: ref.watch(videoPlaybackValueProvider.select((v) => (v.position, v.duration)));
|
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
|
||||||
|
|
||||||
final wasPlaying = useRef<bool>(true);
|
final wasPlaying = useRef<bool>(true);
|
||||||
return duration == Duration.zero
|
return duration == Duration.zero
|
||||||
@@ -44,13 +45,13 @@ class VideoPosition extends HookConsumerWidget {
|
|||||||
activeColor: Colors.white,
|
activeColor: Colors.white,
|
||||||
inactiveColor: whiteOpacity75,
|
inactiveColor: whiteOpacity75,
|
||||||
onChangeStart: (value) {
|
onChangeStart: (value) {
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||||
wasPlaying.value = state != VideoPlaybackState.paused;
|
wasPlaying.value = status != VideoPlaybackStatus.paused;
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
|
||||||
},
|
},
|
||||||
onChangeEnd: (value) {
|
onChangeEnd: (value) {
|
||||||
if (wasPlaying.value) {
|
if (wasPlaying.value) {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@@ -61,10 +62,7 @@ class VideoPosition extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration;
|
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
|
||||||
|
|
||||||
// This immediately updates the slider position without waiting for the video to update
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration;
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/memory.provider.dart';
|
import 'package:immich_mobile/providers/memory.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
@@ -34,9 +33,6 @@ class MemoryLane extends HookConsumerWidget {
|
|||||||
if (memories[memoryIndex].assets.isNotEmpty) {
|
if (memories[memoryIndex].assets.isNotEmpty) {
|
||||||
final asset = memories[memoryIndex].assets[0];
|
final asset = memories[memoryIndex].assets[0];
|
||||||
ref.read(currentAssetProvider.notifier).set(asset);
|
ref.read(currentAssetProvider.notifier).set(asset);
|
||||||
if (asset.isVideo || asset.isMotionPhoto) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
|
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -420,7 +420,11 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
|
|
||||||
Widget _buildChild() {
|
Widget _buildChild() {
|
||||||
return widget.hasCustomChild
|
return widget.hasCustomChild
|
||||||
? widget.customChild!
|
? SizedBox(
|
||||||
|
width: scaleBoundaries.childSize.width * scale,
|
||||||
|
height: scaleBoundaries.childSize.height * scale,
|
||||||
|
child: widget.customChild!,
|
||||||
|
)
|
||||||
: Image(
|
: Image(
|
||||||
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
|
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
|
||||||
image: widget.imageProvider!,
|
image: widget.imageProvider!,
|
||||||
@@ -428,7 +432,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||||
filterQuality: widget.filterQuality,
|
filterQuality: widget.filterQuality,
|
||||||
width: scaleBoundaries.childSize.width * scale,
|
width: scaleBoundaries.childSize.width * scale,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.contain,
|
||||||
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1217,10 +1217,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.16.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1910,10 +1910,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.6"
|
||||||
thumbhash:
|
thumbhash:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.14",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"description": "Monorepo for Immich",
|
"description": "Monorepo for Immich",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
|
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||||
"engines": {
|
"engines": {
|
||||||
"pnpm": ">=10.0.0"
|
"pnpm": ">=10.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1128
-984
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS builder
|
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp \
|
COREPACK_HOME=/tmp \
|
||||||
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||||
cd plugins && mise run build
|
cd plugins && mise run build
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:202601131104@sha256:c649c5838b6348836d27db6d49cadbbc6157feae7a1a237180c3dec03577ba8f
|
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# dev build
|
# dev build
|
||||||
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS dev
|
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev
|
||||||
|
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp \
|
COREPACK_HOME=/tmp \
|
||||||
PNPM_HOME=/buildcache/pnpm-store
|
PNPM_HOME=/buildcache/pnpm-store
|
||||||
|
|
||||||
RUN npm install --global corepack@latest && \
|
RUN npm install --global corepack@latest && \
|
||||||
|
|||||||
+3
-3
@@ -7,8 +7,8 @@
|
|||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --cache --check .",
|
||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --cache --write --list-different .",
|
||||||
"start": "pnpm run start:dev",
|
"start": "pnpm run start:dev",
|
||||||
"nest": "nest",
|
"nest": "nest",
|
||||||
"start:dev": "nest start --watch --",
|
"start:dev": "nest start --watch --",
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
"@types/luxon": "^3.6.2",
|
"@types/luxon": "^3.6.2",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.14",
|
||||||
"@types/nodemailer": "^7.0.0",
|
"@types/nodemailer": "^7.0.0",
|
||||||
"@types/picomatch": "^4.0.0",
|
"@types/picomatch": "^4.0.0",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte
|
|||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||||
import { UploadFiles } from 'src/types';
|
import { UploadFiles } from 'src/types';
|
||||||
import { ImmichFileResponse, sendFile } from 'src/utils/file';
|
import { ImmichFileResponse, sendFile, sendFileThrottled } from 'src/utils/file';
|
||||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
@ApiTags(ApiTag.Assets)
|
@ApiTags(ApiTag.Assets)
|
||||||
@@ -109,7 +109,7 @@ export class AssetMediaController {
|
|||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@Next() next: NextFunction,
|
@Next() next: NextFunction,
|
||||||
) {
|
) {
|
||||||
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
|
await sendFileThrottled(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/original')
|
@Put(':id/original')
|
||||||
@@ -162,7 +162,14 @@ export class AssetMediaController {
|
|||||||
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
|
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
|
||||||
|
|
||||||
if (viewThumbnailRes instanceof ImmichFileResponse) {
|
if (viewThumbnailRes instanceof ImmichFileResponse) {
|
||||||
await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
|
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
|
||||||
|
let durationSeconds = 1;
|
||||||
|
if (size === AssetMediaSize.PREVIEW) {
|
||||||
|
durationSeconds = 4;
|
||||||
|
} else if (size === AssetMediaSize.FULLSIZE) {
|
||||||
|
durationSeconds = 4;
|
||||||
|
}
|
||||||
|
await sendFileThrottled(res, next, () => Promise.resolve(viewThumbnailRes), this.logger, durationSeconds);
|
||||||
} else {
|
} else {
|
||||||
// viewThumbnailRes is a AssetMediaRedirectResponse
|
// viewThumbnailRes is a AssetMediaRedirectResponse
|
||||||
// which redirects to the original asset or a specific size to make better use of caching
|
// which redirects to the original asset or a specific size to make better use of caching
|
||||||
|
|||||||
@@ -566,6 +566,8 @@ describe(AssetService.name, () => {
|
|||||||
.file({ type: AssetFileType.Thumbnail })
|
.file({ type: AssetFileType.Thumbnail })
|
||||||
.file({ type: AssetFileType.Preview })
|
.file({ type: AssetFileType.Preview })
|
||||||
.file({ type: AssetFileType.FullSize })
|
.file({ type: AssetFileType.FullSize })
|
||||||
|
.file({ type: AssetFileType.Preview, isEdited: true })
|
||||||
|
.file({ type: AssetFileType.Thumbnail, isEdited: true })
|
||||||
.build();
|
.build();
|
||||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const getAssetFiles = (files: AssetFile[]) => ({
|
|||||||
|
|
||||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
||||||
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const addAssets = async (
|
export const addAssets = async (
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
|||||||
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
|
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
|
||||||
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
|
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
|
||||||
)
|
)
|
||||||
|
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||||
.$if(!!options.withExif, withExifInner)
|
.$if(!!options.withExif, withExifInner)
|
||||||
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
||||||
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
||||||
|
|||||||
@@ -1,13 +1,41 @@
|
|||||||
import { HttpException, StreamableFile } from '@nestjs/common';
|
import { HttpException, StreamableFile } from '@nestjs/common';
|
||||||
import { NextFunction, Response } from 'express';
|
import { NextFunction, Response } from 'express';
|
||||||
import { access, constants } from 'node:fs/promises';
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { access, constants, stat } from 'node:fs/promises';
|
||||||
import { basename, extname } from 'node:path';
|
import { basename, extname } from 'node:path';
|
||||||
|
import { Transform, TransformCallback } from 'node:stream';
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
import sharp from 'sharp';
|
||||||
import { CacheControl } from 'src/enum';
|
import { CacheControl } from 'src/enum';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { ImmichReadStream } from 'src/repositories/storage.repository';
|
import { ImmichReadStream } from 'src/repositories/storage.repository';
|
||||||
import { isConnectionAborted } from 'src/utils/misc';
|
import { isConnectionAborted } from 'src/utils/misc';
|
||||||
|
|
||||||
|
class ThrottleTransform extends Transform {
|
||||||
|
private bytesPerSecond: number;
|
||||||
|
private startTime: number;
|
||||||
|
private bytesSent: number;
|
||||||
|
|
||||||
|
constructor(bytesPerSecond: number) {
|
||||||
|
super();
|
||||||
|
this.bytesPerSecond = bytesPerSecond;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.bytesSent = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
|
||||||
|
this.bytesSent += chunk.length;
|
||||||
|
const targetTime = (this.bytesSent / this.bytesPerSecond) * 1000;
|
||||||
|
const elapsed = Date.now() - this.startTime;
|
||||||
|
const delay = Math.max(0, targetTime - elapsed);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
callback(null, chunk);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getFileNameWithoutExtension(path: string): string {
|
export function getFileNameWithoutExtension(path: string): string {
|
||||||
return basename(path, extname(path));
|
return basename(path, extname(path));
|
||||||
}
|
}
|
||||||
@@ -20,6 +48,11 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string
|
|||||||
return getFileNameWithoutExtension(stillName) + extname(motionName);
|
return getFileNameWithoutExtension(stillName) + extname(motionName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hasAlphaChannel(input: string | Buffer): Promise<boolean> {
|
||||||
|
const metadata = await sharp(input).metadata();
|
||||||
|
return metadata.hasAlpha === true;
|
||||||
|
}
|
||||||
|
|
||||||
export class ImmichFileResponse {
|
export class ImmichFileResponse {
|
||||||
public readonly path!: string;
|
public readonly path!: string;
|
||||||
public readonly contentType!: string;
|
public readonly contentType!: string;
|
||||||
@@ -85,3 +118,57 @@ export const sendFile = async (
|
|||||||
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
|
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
|
||||||
return new StreamableFile(stream, { type, length });
|
return new StreamableFile(stream, { type, length });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const THROTTLE_TRANSFER_DURATION_SECONDS = 6;
|
||||||
|
const THROTTLE_INITIAL_DELAY_MS = 500;
|
||||||
|
|
||||||
|
export const sendFileThrottled = async (
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
handler: () => Promise<ImmichFileResponse> | ImmichFileResponse,
|
||||||
|
logger: LoggingRepository,
|
||||||
|
durationSeconds: number = THROTTLE_TRANSFER_DURATION_SECONDS,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const file = await handler();
|
||||||
|
const cacheControlHeader = cacheControlHeaders[file.cacheControl];
|
||||||
|
if (cacheControlHeader) {
|
||||||
|
res.set('Cache-Control', cacheControlHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.header('Content-Type', file.contentType);
|
||||||
|
if (file.fileName) {
|
||||||
|
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStat = await stat(file.path);
|
||||||
|
res.header('Content-Length', fileStat.size.toString());
|
||||||
|
|
||||||
|
await sleep(THROTTLE_INITIAL_DELAY_MS);
|
||||||
|
|
||||||
|
const bytesPerSecond = fileStat.size / durationSeconds;
|
||||||
|
const readStream = createReadStream(file.path);
|
||||||
|
const throttle = new ThrottleTransform(bytesPerSecond);
|
||||||
|
|
||||||
|
readStream.pipe(throttle).pipe(res);
|
||||||
|
|
||||||
|
readStream.on('error', (error) => {
|
||||||
|
if (!isConnectionAborted(error) && !res.headersSent) {
|
||||||
|
logger.error(`Unable to send file: ${error}`, (error as Error).stack);
|
||||||
|
res.header('Cache-Control', 'none');
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
if (isConnectionAborted(error) || res.headersSent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof HttpException === false) {
|
||||||
|
logger.error(`Unable to send file: ${error}`, error.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.header('Cache-Control', 'none');
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Insertable, Kysely } from 'kysely';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
import { AssetFace } from 'src/database';
|
import { AssetFace } from 'src/database';
|
||||||
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
|
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
|
||||||
@@ -78,6 +79,9 @@ import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
|
|||||||
import { automock, wait } from 'test/utils';
|
import { automock, wait } from 'test/utils';
|
||||||
import { Mocked } from 'vitest';
|
import { Mocked } from 'vitest';
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/prefer-module
|
||||||
|
export const testAssetsDir = resolve(__dirname, '../../e2e/test-assets');
|
||||||
|
|
||||||
interface ClassConstructor<T = any> extends Function {
|
interface ClassConstructor<T = any> extends Function {
|
||||||
new (...args: any[]): T;
|
new (...args: any[]): T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Kysely } from 'kysely';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { ExifTestContext } from 'test/medium.factory';
|
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
|
||||||
import { getKyselyDB } from 'test/utils';
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
let database: Kysely<DB>;
|
let database: Kysely<DB>;
|
||||||
@@ -11,7 +11,7 @@ const setup = async (testAssetPath: string) => {
|
|||||||
const ctx = new ExifTestContext(database);
|
const ctx = new ExifTestContext(database);
|
||||||
|
|
||||||
const { user } = await ctx.newUser();
|
const { user } = await ctx.newUser();
|
||||||
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
|
const originalPath = resolve(testAssetsDir, testAssetPath);
|
||||||
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
|
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
|
||||||
|
|
||||||
return { ctx, sut: ctx.sut, asset };
|
return { ctx, sut: ctx.sut, asset };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { ExifTestContext } from 'test/medium.factory';
|
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
|
||||||
import { getKyselyDB } from 'test/utils';
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
let database: Kysely<DB>;
|
let database: Kysely<DB>;
|
||||||
@@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => {
|
|||||||
const ctx = new ExifTestContext(database);
|
const ctx = new ExifTestContext(database);
|
||||||
|
|
||||||
const { user } = await ctx.newUser();
|
const { user } = await ctx.newUser();
|
||||||
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
|
const originalPath = resolve(testAssetsDir, testAssetPath);
|
||||||
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
|
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
|
||||||
|
|
||||||
return { ctx, sut: ctx.sut, asset };
|
return { ctx, sut: ctx.sut, asset };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { ExifTestContext } from 'test/medium.factory';
|
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
|
||||||
import { getKyselyDB } from 'test/utils';
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
let database: Kysely<DB>;
|
let database: Kysely<DB>;
|
||||||
@@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => {
|
|||||||
const ctx = new ExifTestContext(database);
|
const ctx = new ExifTestContext(database);
|
||||||
|
|
||||||
const { user } = await ctx.newUser();
|
const { user } = await ctx.newUser();
|
||||||
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
|
const originalPath = resolve(testAssetsDir, testAssetPath);
|
||||||
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
|
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
|
||||||
|
|
||||||
return { ctx, sut: ctx.sut, asset };
|
return { ctx, sut: ctx.sut, asset };
|
||||||
|
|||||||
@@ -88,4 +88,24 @@ describe(SearchService.name, () => {
|
|||||||
expect(result).toEqual({ total: 0 });
|
expect(result).toEqual({ total: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('withStacked option', () => {
|
||||||
|
it('should exclude stacked assets when withStacked is false', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
|
||||||
|
const { asset: primaryAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
const { asset: stackedAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
const { asset: unstackedAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
|
||||||
|
await ctx.newStack({ ownerId: user.id }, [primaryAsset.id, stackedAsset.id]);
|
||||||
|
|
||||||
|
const auth = factory.auth({ user: { id: user.id } });
|
||||||
|
|
||||||
|
const response = await sut.searchMetadata(auth, { withStacked: false });
|
||||||
|
|
||||||
|
expect(response.assets.items.length).toBe(1);
|
||||||
|
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import swc from 'unplugin-swc';
|
import swc from 'unplugin-swc';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
root: './',
|
name: 'server:medium',
|
||||||
|
root: serverRoot,
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ['test/medium/**/*.spec.ts'],
|
include: ['test/medium/**/*.spec.ts'],
|
||||||
globalSetup: ['test/medium/globalSetup.ts'],
|
globalSetup: ['test/medium/globalSetup.ts'],
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import swc from 'unplugin-swc';
|
import swc from 'unplugin-swc';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
root: './',
|
name: 'server:unit',
|
||||||
|
root: serverRoot,
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ['src/**/*.spec.ts'],
|
include: ['src/**/*.spec.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
+4
-4
@@ -16,8 +16,8 @@
|
|||||||
"check:all": "pnpm run check:code && pnpm run test:cov",
|
"check:all": "pnpm run check:code && pnpm run test:cov",
|
||||||
"lint": "eslint . --max-warnings 0 --concurrency 4",
|
"lint": "eslint . --max-warnings 0 --concurrency 4",
|
||||||
"lint:fix": "pnpm run lint --fix",
|
"lint:fix": "pnpm run lint --fix",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --cache --check .",
|
||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --cache --write --list-different .",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:cov": "vitest --coverage",
|
"test:cov": "vitest --coverage",
|
||||||
"test:watch": "vitest dev",
|
"test:watch": "vitest dev",
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^4.0.0",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^17.0.0",
|
||||||
"eslint": "^10.0.0",
|
"eslint": "^10.0.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.45.0",
|
"typescript-eslint": "^8.45.0",
|
||||||
"vite": "^7.1.2",
|
"vite": "^7.1.2",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^4.0.0"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.1"
|
"node": "24.13.1"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { tick } from 'svelte';
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
export const getAnimateMock = () =>
|
export const getAnimateMock = () =>
|
||||||
vi.fn().mockImplementation(() => {
|
vi.fn().mockImplementation(function () {
|
||||||
let onfinish: (() => void) | null = null;
|
let onfinish: (() => void) | null = null;
|
||||||
void tick().then(() => onfinish?.());
|
void tick().then(() => onfinish?.());
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user