Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d414c2789a | |||
| b15e01e2b6 | |||
| fa3156e6ee | |||
| 2ece3d3822 | |||
| 128f19efa5 | |||
| 20b1572b8e | |||
| 2ab1bbabac | |||
| 7e74b8d1bb | |||
| dec514bd6d | |||
| 0a77a65044 | |||
| 4d1d902773 | |||
| 60715059f7 | |||
| aa890c0858 | |||
| 03211c43f9 | |||
| 381fe5c2fd | |||
| 937fb4e30e | |||
| 71e058af2e | |||
| 6c8f7b7e6d | |||
| b0a2a6ac13 | |||
| d1c7ed5464 | |||
| 3b9a3d4037 | |||
| 613ce513cd | |||
| caee381721 | |||
| e59912e16e | |||
| 0010eda67f | |||
| 190dbb0042 | |||
| dbca16e352 | |||
| 64e23a3b5c | |||
| 5766551447 | |||
| 7d5294f7ef | |||
| 973b146d06 | |||
| 3da28a4685 | |||
| de61abb3aa | |||
| bef9a1eae7 | |||
| 49c4d7cff9 | |||
| 3272ad4a7b | |||
| 46a8e9084a | |||
| 5e8ee6dc5f | |||
| 9c470def18 | |||
| 5ebac69647 | |||
| 6f3ceb58b8 | |||
| a346a37743 | |||
| a8994ffb22 | |||
| 4b5657c21e | |||
| f5c4af73aa | |||
| 24ae4ecff1 | |||
| 64a7baec8c | |||
| caf6c0996d | |||
| 6729782c3f | |||
| a60209db3e | |||
| d1169e3b2f | |||
| df972ef711 | |||
| 33263cf9f3 | |||
| 1b5811d992 | |||
| 1fa0122eda | |||
| d1085e8a02 | |||
| d6a70bc7e5 | |||
| d3fe238eef | |||
| 35f24270fe | |||
| 1f1a4ab1a3 | |||
| 0b3742cf13 | |||
| 9203a61709 | |||
| 11403abfbc | |||
| 5a2af558fb | |||
| de993289ad | |||
| c58bd307ce | |||
| 333ca8827e | |||
| 3dad19883d | |||
| 4ca27a3e7f | |||
| b0bb11f9e0 | |||
| ecb8349085 | |||
| e1feba2198 | |||
| 53a7ac3868 | |||
| f2e950d89c | |||
| 8ba2c99b08 | |||
| 93346496fc | |||
| a9525de356 |
@@ -0,0 +1,2 @@
|
|||||||
|
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22
|
||||||
|
FROM ${BASEIMAGE}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Immich devcontainers",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"args": {
|
||||||
|
"BASEIMAGE": "mcr.microsoft.com/devcontainers/typescript-node:22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"svelte.svelte-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forwardPorts": [],
|
||||||
|
"postCreateCommand": "make install-all",
|
||||||
|
"remoteUser": "node"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: PR Conventional Commit Validation
|
- name: PR Conventional Commit Validation
|
||||||
uses: ytanikin/PRConventionalCommits@1.2.0
|
uses: ytanikin/PRConventionalCommits@1.3.0
|
||||||
with:
|
with:
|
||||||
task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]'
|
task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]'
|
||||||
add_label: 'false'
|
add_label: 'false'
|
||||||
|
|||||||
@@ -41,4 +41,4 @@
|
|||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
|
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ attach-server:
|
|||||||
renovate:
|
renovate:
|
||||||
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
||||||
|
|
||||||
MODULES = e2e server web cli sdk
|
MODULES = e2e server web cli sdk docs
|
||||||
|
|
||||||
audit-%:
|
audit-%:
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
|
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
|
||||||
@@ -48,11 +48,9 @@ install-%:
|
|||||||
build-cli: build-sdk
|
build-cli: build-sdk
|
||||||
build-web: build-sdk
|
build-web: build-sdk
|
||||||
build-%: install-%
|
build-%: install-%
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'build' >/dev/null \
|
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
|
||||||
&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build || true
|
|
||||||
format-%:
|
format-%:
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'format:fix' >/dev/null \
|
npm --prefix $* run format:fix
|
||||||
&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run format:fix || true
|
|
||||||
lint-%:
|
lint-%:
|
||||||
npm --prefix $* run lint:fix
|
npm --prefix $* run lint:fix
|
||||||
check-%:
|
check-%:
|
||||||
@@ -79,14 +77,14 @@ test-medium:
|
|||||||
test-medium-dev:
|
test-medium-dev:
|
||||||
docker exec -it immich_server /bin/sh -c "npm run test:medium"
|
docker exec -it immich_server /bin/sh -c "npm run test:medium"
|
||||||
|
|
||||||
build-all: $(foreach M,$(MODULES),build-$M) ;
|
build-all: $(foreach M,$(filter-out e2e,$(MODULES)),build-$M) ;
|
||||||
install-all: $(foreach M,$(MODULES),install-$M) ;
|
install-all: $(foreach M,$(MODULES),install-$M) ;
|
||||||
check-all: $(foreach M,$(MODULES),check-$M) ;
|
check-all: $(foreach M,$(filter-out sdk cli docs,$(MODULES)),check-$M) ;
|
||||||
lint-all: $(foreach M,$(MODULES),lint-$M) ;
|
lint-all: $(foreach M,$(filter-out sdk docs,$(MODULES)),lint-$M) ;
|
||||||
format-all: $(foreach M,$(MODULES),format-$M) ;
|
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
|
||||||
audit-all: $(foreach M,$(MODULES),audit-$M) ;
|
audit-all: $(foreach M,$(MODULES),audit-$M) ;
|
||||||
hygiene-all: lint-all format-all check-all sql audit-all;
|
hygiene-all: lint-all format-all check-all sql audit-all;
|
||||||
test-all: $(foreach M,$(MODULES),test-$M) ;
|
test-all: $(foreach M,$(filter-out sdk docs,$(MODULES)),test-$M) ;
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Action,
|
Action,
|
||||||
|
AssetBulkUploadCheckItem,
|
||||||
AssetBulkUploadCheckResult,
|
AssetBulkUploadCheckResult,
|
||||||
AssetMediaResponseDto,
|
AssetMediaResponseDto,
|
||||||
AssetMediaStatus,
|
AssetMediaStatus,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
getSupportedMediaTypes,
|
getSupportedMediaTypes,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import byteSize from 'byte-size';
|
import byteSize from 'byte-size';
|
||||||
import { Presets, SingleBar } from 'cli-progress';
|
import { MultiBar, Presets, SingleBar } from 'cli-progress';
|
||||||
import { chunk } from 'lodash-es';
|
import { chunk } from 'lodash-es';
|
||||||
import { Stats, createReadStream } from 'node:fs';
|
import { Stats, createReadStream } from 'node:fs';
|
||||||
import { stat, unlink } from 'node:fs/promises';
|
import { stat, unlink } from 'node:fs/promises';
|
||||||
@@ -90,23 +91,23 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
|||||||
return { newFiles: files, duplicates: [] };
|
return { newFiles: files, duplicates: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressBar = new SingleBar(
|
const multiBar = new MultiBar(
|
||||||
{ format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
{ format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||||
Presets.shades_classic,
|
Presets.shades_classic,
|
||||||
);
|
);
|
||||||
|
|
||||||
progressBar.start(files.length, 0);
|
const hashProgressBar = multiBar.create(files.length, 0, { message: 'Hashing files ' });
|
||||||
|
const checkProgressBar = multiBar.create(files.length, 0, { message: 'Checking for duplicates' });
|
||||||
|
|
||||||
const newFiles: string[] = [];
|
const newFiles: string[] = [];
|
||||||
const duplicates: Asset[] = [];
|
const duplicates: Asset[] = [];
|
||||||
|
|
||||||
const queue = new Queue<string[], AssetBulkUploadCheckResults>(
|
const checkBulkUploadQueue = new Queue<AssetBulkUploadCheckItem[], void>(
|
||||||
async (filepaths: string[]) => {
|
async (assets: AssetBulkUploadCheckItem[]) => {
|
||||||
const dto = await Promise.all(
|
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets } });
|
||||||
filepaths.map(async (filepath) => ({ id: filepath, checksum: await sha1(filepath) })),
|
|
||||||
);
|
|
||||||
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: dto } });
|
|
||||||
const results = response.results as AssetBulkUploadCheckResults;
|
const results = response.results as AssetBulkUploadCheckResults;
|
||||||
|
|
||||||
for (const { id: filepath, assetId, action } of results) {
|
for (const { id: filepath, assetId, action } of results) {
|
||||||
if (action === Action.Accept) {
|
if (action === Action.Accept) {
|
||||||
newFiles.push(filepath);
|
newFiles.push(filepath);
|
||||||
@@ -115,19 +116,46 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
|||||||
duplicates.push({ id: assetId as string, filepath });
|
duplicates.push({ id: assetId as string, filepath });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
progressBar.increment(filepaths.length);
|
|
||||||
|
checkProgressBar.increment(assets.length);
|
||||||
|
},
|
||||||
|
{ concurrency, retry: 3 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: { id: string; checksum: string }[] = [];
|
||||||
|
let checkBulkUploadRequests: AssetBulkUploadCheckItem[] = [];
|
||||||
|
|
||||||
|
const queue = new Queue<string, AssetBulkUploadCheckItem[]>(
|
||||||
|
async (filepath: string): Promise<AssetBulkUploadCheckItem[]> => {
|
||||||
|
const dto = { id: filepath, checksum: await sha1(filepath) };
|
||||||
|
|
||||||
|
results.push(dto);
|
||||||
|
checkBulkUploadRequests.push(dto);
|
||||||
|
if (checkBulkUploadRequests.length === 5000) {
|
||||||
|
const batch = checkBulkUploadRequests;
|
||||||
|
checkBulkUploadRequests = [];
|
||||||
|
void checkBulkUploadQueue.push(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
hashProgressBar.increment();
|
||||||
return results;
|
return results;
|
||||||
},
|
},
|
||||||
{ concurrency, retry: 3 },
|
{ concurrency, retry: 3 },
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const items of chunk(files, concurrency)) {
|
for (const item of files) {
|
||||||
await queue.push(items);
|
void queue.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
await queue.drained();
|
await queue.drained();
|
||||||
|
|
||||||
progressBar.stop();
|
if (checkBulkUploadRequests.length > 0) {
|
||||||
|
void checkBulkUploadQueue.push(checkBulkUploadRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkBulkUploadQueue.drained();
|
||||||
|
|
||||||
|
multiBar.stop();
|
||||||
|
|
||||||
console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`);
|
console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`);
|
||||||
|
|
||||||
@@ -201,8 +229,8 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo
|
|||||||
{ concurrency, retry: 3 },
|
{ concurrency, retry: 3 },
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const filepath of files) {
|
for (const item of files) {
|
||||||
await queue.push(filepath);
|
void queue.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
await queue.drained();
|
await queue.drained();
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ export class Queue<T, R> {
|
|||||||
* @returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker.
|
* @returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker.
|
||||||
* This promise could be ignored as it will not lead to a `unhandledRejection`.
|
* This promise could be ignored as it will not lead to a `unhandledRejection`.
|
||||||
*/
|
*/
|
||||||
async drained(): Promise<void> {
|
drained(): Promise<void> {
|
||||||
await this.queue.drain();
|
return this.queue.drained();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -94,7 +94,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:378f4e03703557d1c6419e6caccf922f96e6d88a530f7431d66a4c4f4b1000fe
|
image: prom/prometheus@sha256:3b9b2a15d376334da8c286d995777d3b9315aa666d2311170ada6059a517b74f
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: docker.io/redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ docker compose up -d # Start remainder of Immich apps
|
|||||||
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
|
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
|
||||||
|
|
||||||
```powershell title='Backup'
|
```powershell title='Backup'
|
||||||
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | Set-Content -Encoding utf8 "C:\path\to\backup\dump.sql"
|
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres))
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell title='Restore'
|
```powershell title='Restore'
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# PR Checklist
|
# PR Checklist
|
||||||
|
|
||||||
|
A minimal devcontainer is supplied with this repository. All commands can be executed directly inside this container to avoid tedious installation of the environment.
|
||||||
|
:::warning
|
||||||
|
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`make dev`, ....). Feel free to contribute!
|
||||||
|
:::
|
||||||
When contributing code through a pull request, please check the following:
|
When contributing code through a pull request, please check the following:
|
||||||
|
|
||||||
## Web Checks
|
## Web Checks
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ SELECT * FROM "move_history";
|
|||||||
SELECT * FROM "users";
|
SELECT * FROM "users";
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```sql title="Get owner info from asset ID"
|
||||||
|
SELECT "users".* FROM "users" JOIN "assets" ON "users"."id" = "assets"."ownerId" WHERE "assets"."id" = 'fa310b01-2f26-4b7a-9042-d578226e021f';
|
||||||
|
```
|
||||||
|
|
||||||
## System Config
|
## System Config
|
||||||
|
|
||||||
```sql title="Custom settings"
|
```sql title="Custom settings"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -7,7 +7,9 @@ sidebar_position: 80
|
|||||||
:::note
|
:::note
|
||||||
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
|
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
|
||||||
|
|
||||||
**Please report issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
|
Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/).
|
||||||
|
|
||||||
|
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Immich can easily be installed on TrueNAS SCALE via the **Community** train application.
|
Immich can easily be installed on TrueNAS SCALE via the **Community** train application.
|
||||||
@@ -20,18 +22,26 @@ TrueNAS SCALE makes installing and updating Immich easy, but you must use the Im
|
|||||||
The Immich app in TrueNAS SCALE installs, completes the initial configuration, then starts the Immich web portal.
|
The Immich app in TrueNAS SCALE installs, completes the initial configuration, then starts the Immich web portal.
|
||||||
When updates become available, SCALE alerts and provides easy updates.
|
When updates become available, SCALE alerts and provides easy updates.
|
||||||
|
|
||||||
Before installing the Immich app in SCALE, review the [Environment Variables](/docs/install/environment-variables.md) documentation to see if you want to configure any during installation.
|
Before installing the Immich app in SCALE, review the [Environment Variables](#environment-variables) documentation to see if you want to configure any during installation.
|
||||||
You can configure environment variables at any time after deploying the application.
|
You may also configure environment variables at any time after deploying the application.
|
||||||
|
|
||||||
You can allow SCALE to create the datasets Immich requires automatically during app installation.
|
### Setting up Storage Datasets
|
||||||
Or before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation.
|
|
||||||
Immich requires seven datasets: **library**, **pgBackup**, **pgData**, **profile**, **thumbs**, **uploads**, and **video**.
|
Before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation.
|
||||||
You can organize these as one parent with seven child datasets, for example `mnt/tank/immich/library`, `mnt/tank/immich/pgBackup`, and so on.
|
Immich requires seven datasets: `library`, `upload`, `thumbs`, `profile`, `video`, `backups`, and `pgData`.
|
||||||
|
You can organize these as one parent with seven child datasets, for example `/mnt/tank/immich/library`, `/mnt/tank/immich/upload`, and so on.
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas12.png').default}
|
||||||
|
width="30%"
|
||||||
|
alt="Immich App Widget"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
:::info Permissions
|
:::info Permissions
|
||||||
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
|
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
|
||||||
|
|
||||||
The **library** dataset must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **uploads** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command.
|
If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Installing the Immich Application
|
## Installing the Immich Application
|
||||||
@@ -47,6 +57,8 @@ className="border rounded-xl"
|
|||||||
|
|
||||||
Click on the widget to open the **Immich** application details screen.
|
Click on the widget to open the **Immich** application details screen.
|
||||||
|
|
||||||
|
<br/><br/>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={require('./img/truenas02.png').default}
|
src={require('./img/truenas02.png').default}
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -56,9 +68,13 @@ className="border rounded-xl"
|
|||||||
|
|
||||||
Click **Install** to open the Immich application configuration screen.
|
Click **Install** to open the Immich application configuration screen.
|
||||||
|
|
||||||
|
<br/><br/>
|
||||||
|
|
||||||
Application configuration settings are presented in several sections, each explained below.
|
Application configuration settings are presented in several sections, each explained below.
|
||||||
To find specific fields click in the **Search Input Fields** search field, scroll down to a particular section or click on the section heading on the navigation area in the upper-right corner.
|
To find specific fields click in the **Search Input Fields** search field, scroll down to a particular section or click on the section heading on the navigation area in the upper-right corner.
|
||||||
|
|
||||||
|
### Application Name and Version
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={require('./img/truenas03.png').default}
|
src={require('./img/truenas03.png').default}
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -66,21 +82,123 @@ alt="Install Immich Screen"
|
|||||||
className="border rounded-xl"
|
className="border rounded-xl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
Accept the default values in **Application Name** and **Version**.
|
Accept the default value or enter a name in **Application Name** field.
|
||||||
|
In most cases use the default name, but if adding a second deployment of the application you must change this name.
|
||||||
|
|
||||||
|
Accept the default version number in **Version**.
|
||||||
|
When a new version becomes available, the application has an update badge.
|
||||||
|
The **Installed Applications** screen shows the option to update applications.
|
||||||
|
|
||||||
|
### Immich Configuration
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas05.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Configuration Settings"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
Accept the default value in **Timezone** or change to match your local timezone.
|
Accept the default value in **Timezone** or change to match your local timezone.
|
||||||
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
|
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
|
||||||
|
|
||||||
Accept the default port in **Web Port**.
|
Untick **Enable Machine Learning** if you will not use face recognition, image search, and smart duplicate detection.
|
||||||
|
|
||||||
|
Accept the default option or select the **Machine Learning Image Type** for your hardware based on the [Hardware-Accelerated Machine Learning Supported Backends](/docs/features/ml-hardware-acceleration.md#supported-backends).
|
||||||
|
|
||||||
|
Immich's default is `postgres` but you should consider setting the **Database Password** to a custom value using only the characters `A-Za-z0-9`.
|
||||||
|
|
||||||
|
The **Redis Password** should be set to a custom value using only the characters `A-Za-z0-9`.
|
||||||
|
|
||||||
|
Accept the **Log Level** default of **Log**.
|
||||||
|
|
||||||
|
Leave **Hugging Face Endpoint** blank. (This is for downloading ML models from a different source.)
|
||||||
|
|
||||||
|
Leave **Additional Environment Variables** blank or see [Environment Variables](#environment-variables) to set before installing.
|
||||||
|
|
||||||
|
### Network Configuration
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas06.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Networking Settings"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
Accept the default port `30041` in **WebUI Port** or enter a custom port number.
|
||||||
|
:::info Allowed Port Numbers
|
||||||
|
Only numbers within the range 9000-65535 may be used on SCALE versions below TrueNAS Scale 24.10 Electric Eel.
|
||||||
|
|
||||||
|
Regardless of version, to avoid port conflicts, don't use [ports on this list](https://www.truenas.com/docs/references/defaultports/).
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Storage Configuration
|
||||||
|
|
||||||
Immich requires seven storage datasets.
|
Immich requires seven storage datasets.
|
||||||
You can allow SCALE to create them for you, or use the dataset(s) created in [First Steps](#first-steps).
|
|
||||||
Select the storage options you want to use for **Immich Uploads Storage**, **Immich Library Storage**, **Immich Thumbs Storage**, **Immich Profile Storage**, **Immich Video Storage**, **Immich Postgres Data Storage**, **Immich Postgres Backup Storage**.
|
|
||||||
Select **ixVolume (dataset created automatically by the system)** in **Type** to let SCALE create the dataset or select **Host Path** to use the existing datasets created on the system.
|
|
||||||
|
|
||||||
Accept the defaults in Resources or change the CPU and memory limits to suit your use case.
|
<img
|
||||||
|
src={require('./img/truenas07.png').default}
|
||||||
|
width="20%"
|
||||||
|
alt="Configure Storage ixVolumes"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
Click **Install**.
|
:::note Default Setting (Not recommended)
|
||||||
|
The default setting for datasets is **ixVolume (dataset created automatically by the system)** but this results in your data being harder to access manually and can result in data loss if you delete the immich app. (Not recommended)
|
||||||
|
:::
|
||||||
|
|
||||||
|
For each Storage option select **Host Path (Path that already exists on the system)** and then select the matching dataset [created before installing the app](#setting-up-storage-datasets): **Immich Library Storage**: `library`, **Immich Uploads Storage**: `upload`, **Immich Thumbs Storage**: `thumbs`, **Immich Profile Storage**: `profile`, **Immich Video Storage**: `video`, **Immich Backups Storage**: `backups`, **Postgres Data Storage**: `pgData`.
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas08.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Configure Storage Host Paths"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
The image above has example values.
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
### Additional Storage [(External Libraries)](/docs/features/libraries)
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas10.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Configure Storage Host Paths"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**.
|
||||||
|
The **Mount Path** is the loaction you will need to copy and paste into the External Library settings within Immich.
|
||||||
|
The **Host Path** is the location on the TrueNAS SCALE server where your external library is located.
|
||||||
|
|
||||||
|
<!-- A section for Labels would go here but I don't know what they do. -->
|
||||||
|
|
||||||
|
### Resources Configuration
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas09.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Resource Limits"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
Accept the default **CPU** limit of `2` threads or specify the number of threads (CPUs with Multi-/Hyper-threading have 2 threads per core).
|
||||||
|
|
||||||
|
Accept the default **Memory** limit of `4096` MB or specify the number of MB of RAM. If you're using Machine Learning you should probably set this above 8000 MB.
|
||||||
|
|
||||||
|
:::info Older SCALE Versions
|
||||||
|
Before TrueNAS SCALE version 24.10 Electric Eel:
|
||||||
|
|
||||||
|
The **CPU** value was specified in a different format with a default of `4000m` which is 4 threads.
|
||||||
|
|
||||||
|
The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000`
|
||||||
|
:::
|
||||||
|
|
||||||
|
Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passtrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough)
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
Finally, click **Install**.
|
||||||
The system opens the **Installed Applications** screen with the Immich app in the **Deploying** state.
|
The system opens the **Installed Applications** screen with the Immich app in the **Deploying** state.
|
||||||
When the installation completes it changes to **Running**.
|
When the installation completes it changes to **Running**.
|
||||||
|
|
||||||
@@ -97,102 +215,41 @@ Click **Web Portal** on the **Application Info** widget to open the Immich web i
|
|||||||
For more information on how to use the application once installed, please refer to the [Post Install](/docs/install/post-install.mdx) guide.
|
For more information on how to use the application once installed, please refer to the [Post Install](/docs/install/post-install.mdx) guide.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Editing Environment Variables
|
## Edit App Settings
|
||||||
|
|
||||||
Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
||||||
Click **Edit** on the **Application Info** widget to open the **Edit Immich** screen.
|
- Click **Edit** on the **Application Info** widget to open the **Edit Immich** screen.
|
||||||
The settings on the edit screen are the same as on the install screen.
|
- Change any settings you would like to change.
|
||||||
You cannot edit **Storage Configuration** paths after the initial app install.
|
- The settings on the edit screen are the same as on the install screen.
|
||||||
|
- Click **Update** at the very bottom of the page to save changes.
|
||||||
|
- TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated settings.
|
||||||
|
|
||||||
Click **Update** to save changes.
|
## Environment Variables
|
||||||
TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated environment variables.
|
|
||||||
|
You can set [Environment Variables](/docs/install/environment-variables) by clicking **Add** on the **Additional Environment Variables** option and filling in the **Name** and **Value**.
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas11.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Environment Variables"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Some Environment Variables are not available for the TrueNAS SCALE app. This is mainly because they can be configured through GUI options in the [Edit Immich screen](#edit-app-settings).
|
||||||
|
|
||||||
|
Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`, `IMMICH_LOG_LEVEL`, `DB_PASSWORD`, `REDIS_PASSWORD`.
|
||||||
|
:::
|
||||||
|
|
||||||
## Updating the App
|
## Updating the App
|
||||||
|
|
||||||
When updates become available, SCALE alerts and provides easy updates.
|
When updates become available, SCALE alerts and provides easy updates.
|
||||||
To update the app to the latest version, click **Update** on the **Application Info** widget from the **Installed Applications** screen.
|
To update the app to the latest version:
|
||||||
|
|
||||||
Update opens an update window for the application that includes two selectable options, Images (to be updated) and Changelog. Click on the down arrow to see the options available for each.
|
- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
||||||
|
- Click **Update** on the **Application Info** widget from the **Installed Applications** screen.
|
||||||
Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress. When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date.
|
- This opens an update window with some options
|
||||||
|
- You may select an Image update too.
|
||||||
## Understanding Immich Settings in TrueNAS SCALE
|
- You may view the Changelog.
|
||||||
|
- Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress.
|
||||||
Accept the default value or enter a name in **Application Name** field.
|
- When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date.
|
||||||
In most cases use the default name, but if adding a second deployment of the application you must change this name.
|
|
||||||
|
|
||||||
Accept the default version number in **Version**.
|
|
||||||
When a new version becomes available, the application has an update badge.
|
|
||||||
The **Installed Applications** screen shows the option to update applications.
|
|
||||||
|
|
||||||
### Immich Configuration Settings
|
|
||||||
|
|
||||||
You can accept the defaults in the **Immich Configuration** settings, or enter the settings you want to use.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas05.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Configuration Settings"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
Accept the default setting in **Timezone** or change to match your local timezone.
|
|
||||||
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
|
|
||||||
|
|
||||||
You can enter a **Public Login Message** to display on the login page, or leave it blank.
|
|
||||||
|
|
||||||
### Networking Settings
|
|
||||||
|
|
||||||
Accept the default port numbers in **Web Port**.
|
|
||||||
The SCALE Immich app listens on port **30041**.
|
|
||||||
|
|
||||||
Refer to the TrueNAS [default port list](https://www.truenas.com/docs/references/defaultports/) for a list of assigned port numbers.
|
|
||||||
To change the port numbers, enter a number within the range 9000-65535.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas06.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Networking Settings"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Storage Settings
|
|
||||||
|
|
||||||
You can install Immich using the default setting **ixVolume (dataset created automatically by the system)** or use the host path option with datasets [created before installing the app](#first-steps).
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas07.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Configure Storage ixVolumes"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
Select **Host Path (Path that already exists on the system)** to browse to and select the datasets.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas08.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Configure Storage Host Paths"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Resource Configuration Settings
|
|
||||||
|
|
||||||
Accept the default values in **Resources Configuration** or enter new CPU and memory values
|
|
||||||
By default, this application is limited to use no more than 4 CPU cores and 8 Gigabytes available memory. The application might use considerably less system resources.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas09.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Resource Limits"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
To customize the CPU and memory allocated to the container Immich uses, enter new CPU values as a plain integer value followed by the suffix m (milli).
|
|
||||||
Default is 4000m.
|
|
||||||
|
|
||||||
Accept the default value 8Gi allocated memory or enter a new limit in bytes.
|
|
||||||
Enter a plain integer followed by the measurement suffix, for example 129M or 123Mi.
|
|
||||||
|
|
||||||
Systems with compatible GPU(s) display devices in **GPU Configuration**.
|
|
||||||
See [Managing GPUs](https://www.truenas.com/docs/scale/scaletutorials/systemsettings/advanced/managegpuscale/) for more information about allocating isolated GPU devices in TrueNAS SCALE.
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ services:
|
|||||||
- 2285:2285
|
- 2285:2285
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||||
|
|||||||
@@ -163,11 +163,15 @@ describe('/server', () => {
|
|||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
photos: 0,
|
photos: 0,
|
||||||
usage: 0,
|
usage: 0,
|
||||||
|
usagePhotos: 0,
|
||||||
|
usageVideos: 0,
|
||||||
usageByUser: [
|
usageByUser: [
|
||||||
{
|
{
|
||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
photos: 0,
|
photos: 0,
|
||||||
usage: 0,
|
usage: 0,
|
||||||
|
usagePhotos: 0,
|
||||||
|
usageVideos: 0,
|
||||||
userName: 'Immich Admin',
|
userName: 'Immich Admin',
|
||||||
userId: admin.userId,
|
userId: admin.userId,
|
||||||
videos: 0,
|
videos: 0,
|
||||||
@@ -176,6 +180,8 @@ describe('/server', () => {
|
|||||||
quotaSizeInBytes: null,
|
quotaSizeInBytes: null,
|
||||||
photos: 0,
|
photos: 0,
|
||||||
usage: 0,
|
usage: 0,
|
||||||
|
usagePhotos: 0,
|
||||||
|
usageVideos: 0,
|
||||||
userName: 'User 1',
|
userName: 'User 1',
|
||||||
userId: nonAdmin.userId,
|
userId: nonAdmin.userId,
|
||||||
videos: 0,
|
videos: 0,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ describe(`immich upload`, () => {
|
|||||||
describe(`immich upload /path/to/file.jpg`, () => {
|
describe(`immich upload /path/to/file.jpg`, () => {
|
||||||
it('should upload a single file', async () => {
|
it('should upload a single file', async () => {
|
||||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||||
);
|
);
|
||||||
@@ -126,7 +126,7 @@ describe(`immich upload`, () => {
|
|||||||
const expectedCount = Object.entries(files).filter((entry) => entry[1]).length;
|
const expectedCount = Object.entries(files).filter((entry) => entry[1]).length;
|
||||||
|
|
||||||
const { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]);
|
const { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining(`Successfully uploaded ${expectedCount} new asset`)]),
|
expect.arrayContaining([expect.stringContaining(`Successfully uploaded ${expectedCount} new asset`)]),
|
||||||
);
|
);
|
||||||
@@ -154,7 +154,7 @@ describe(`immich upload`, () => {
|
|||||||
cpSync(`${testAssetDir}/albums/nature/silver_fir.jpg`, testPaths[1]);
|
cpSync(`${testAssetDir}/albums/nature/silver_fir.jpg`, testPaths[1]);
|
||||||
|
|
||||||
const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]);
|
const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]),
|
||||||
);
|
);
|
||||||
@@ -169,7 +169,7 @@ describe(`immich upload`, () => {
|
|||||||
|
|
||||||
it('should skip a duplicate file', async () => {
|
it('should skip a duplicate file', async () => {
|
||||||
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||||
expect(first.stderr).toBe('');
|
expect(first.stderr).toContain('{message}');
|
||||||
expect(first.stdout.split('\n')).toEqual(
|
expect(first.stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||||
);
|
);
|
||||||
@@ -179,7 +179,7 @@ describe(`immich upload`, () => {
|
|||||||
expect(assets.total).toBe(1);
|
expect(assets.total).toBe(1);
|
||||||
|
|
||||||
const second = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
const second = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||||
expect(second.stderr).toBe('');
|
expect(second.stderr).toContain('{message}');
|
||||||
expect(second.stdout.split('\n')).toEqual(
|
expect(second.stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.stringContaining('Found 0 new files and 1 duplicate'),
|
expect.stringContaining('Found 0 new files and 1 duplicate'),
|
||||||
@@ -205,7 +205,7 @@ describe(`immich upload`, () => {
|
|||||||
`${testAssetDir}/albums/nature/silver_fir.jpg`,
|
`${testAssetDir}/albums/nature/silver_fir.jpg`,
|
||||||
'--dry-run',
|
'--dry-run',
|
||||||
]);
|
]);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Would have uploaded 1 asset')]),
|
expect.arrayContaining([expect.stringContaining('Would have uploaded 1 asset')]),
|
||||||
);
|
);
|
||||||
@@ -217,7 +217,7 @@ describe(`immich upload`, () => {
|
|||||||
|
|
||||||
it('dry run should handle duplicates', async () => {
|
it('dry run should handle duplicates', async () => {
|
||||||
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||||
expect(first.stderr).toBe('');
|
expect(first.stderr).toContain('{message}');
|
||||||
expect(first.stdout.split('\n')).toEqual(
|
expect(first.stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
|
||||||
);
|
);
|
||||||
@@ -227,7 +227,7 @@ describe(`immich upload`, () => {
|
|||||||
expect(assets.total).toBe(1);
|
expect(assets.total).toBe(1);
|
||||||
|
|
||||||
const second = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--dry-run']);
|
const second = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--dry-run']);
|
||||||
expect(second.stderr).toBe('');
|
expect(second.stderr).toContain('{message}');
|
||||||
expect(second.stdout.split('\n')).toEqual(
|
expect(second.stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.stringContaining('Found 8 new files and 1 duplicate'),
|
expect.stringContaining('Found 8 new files and 1 duplicate'),
|
||||||
@@ -241,7 +241,7 @@ describe(`immich upload`, () => {
|
|||||||
describe('immich upload --recursive', () => {
|
describe('immich upload --recursive', () => {
|
||||||
it('should upload a folder recursively', async () => {
|
it('should upload a folder recursively', async () => {
|
||||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
|
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
|
||||||
);
|
);
|
||||||
@@ -267,7 +267,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Successfully updated 9 assets'),
|
expect.stringContaining('Successfully updated 9 assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -283,7 +283,7 @@ describe(`immich upload`, () => {
|
|||||||
expect(response1.stdout.split('\n')).toEqual(
|
expect(response1.stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
|
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
|
||||||
);
|
);
|
||||||
expect(response1.stderr).toBe('');
|
expect(response1.stderr).toContain('{message}');
|
||||||
expect(response1.exitCode).toBe(0);
|
expect(response1.exitCode).toBe(0);
|
||||||
|
|
||||||
const assets1 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets1 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -299,7 +299,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Successfully updated 9 assets'),
|
expect.stringContaining('Successfully updated 9 assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(response2.stderr).toBe('');
|
expect(response2.stderr).toContain('{message}');
|
||||||
expect(response2.exitCode).toBe(0);
|
expect(response2.exitCode).toBe(0);
|
||||||
|
|
||||||
const assets2 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets2 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -325,7 +325,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Would have updated albums of 9 assets'),
|
expect.stringContaining('Would have updated albums of 9 assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -351,7 +351,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Successfully updated 9 assets'),
|
expect.stringContaining('Successfully updated 9 assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -377,7 +377,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Would have updated albums of 9 assets'),
|
expect.stringContaining('Would have updated albums of 9 assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -408,7 +408,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Deleting assets that have been uploaded'),
|
expect.stringContaining('Deleting assets that have been uploaded'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -434,7 +434,7 @@ describe(`immich upload`, () => {
|
|||||||
expect.stringContaining('Would have deleted 9 local assets'),
|
expect.stringContaining('Would have deleted 9 local assets'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(exitCode).toBe(0);
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
@@ -493,7 +493,7 @@ describe(`immich upload`, () => {
|
|||||||
'2',
|
'2',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'Found 9 new files and 0 duplicates',
|
'Found 9 new files and 0 duplicates',
|
||||||
@@ -534,7 +534,7 @@ describe(`immich upload`, () => {
|
|||||||
'silver_fir.jpg',
|
'silver_fir.jpg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'Found 8 new files and 0 duplicates',
|
'Found 8 new files and 0 duplicates',
|
||||||
@@ -555,7 +555,7 @@ describe(`immich upload`, () => {
|
|||||||
'!(*_*_*).jpg',
|
'!(*_*_*).jpg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'Found 1 new files and 0 duplicates',
|
'Found 1 new files and 0 duplicates',
|
||||||
@@ -577,7 +577,7 @@ describe(`immich upload`, () => {
|
|||||||
'--dry-run',
|
'--dry-run',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(stderr).toBe('');
|
expect(stderr).toContain('{message}');
|
||||||
expect(stdout.split('\n')).toEqual(
|
expect(stdout.split('\n')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'Found 8 new files and 0 duplicates',
|
'Found 8 new files and 0 duplicates',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 34
|
compileSdkVersion 35
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
@@ -47,7 +47,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "app.alextran.immich"
|
applicationId "app.alextran.immich"
|
||||||
minSdkVersion 26
|
minSdkVersion 26
|
||||||
targetSdkVersion 34
|
targetSdkVersion 35
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
##---------------Begin: proguard configuration for Gson ----------
|
||||||
|
# Gson uses generic type information stored in a class file when working with fields. Proguard
|
||||||
|
# removes such information by default, so configure it to keep all of it.
|
||||||
|
-keepattributes Signature
|
||||||
|
|
||||||
|
# For using GSON @Expose annotation
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
|
||||||
|
# Gson specific classes
|
||||||
|
-dontwarn sun.misc.**
|
||||||
|
#-keep class com.google.gson.stream.** { *; }
|
||||||
|
|
||||||
|
# Application classes that will be serialized/deserialized over Gson
|
||||||
|
-keep class com.google.gson.examples.android.model.** { <fields>; }
|
||||||
|
|
||||||
|
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
|
||||||
|
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
|
||||||
|
-keep class * extends com.google.gson.TypeAdapter
|
||||||
|
-keep class * implements com.google.gson.TypeAdapterFactory
|
||||||
|
-keep class * implements com.google.gson.JsonSerializer
|
||||||
|
-keep class * implements com.google.gson.JsonDeserializer
|
||||||
|
|
||||||
|
# Prevent R8 from leaving Data object members always null
|
||||||
|
-keepclassmembers,allowobfuscation class * {
|
||||||
|
@com.google.gson.annotations.SerializedName <fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
|
||||||
|
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
|
||||||
|
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
|
||||||
|
|
||||||
|
##---------------End: proguard configuration for Gson ----------
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||||
android:value="false" />
|
android:value="true" />
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ subprojects {
|
|||||||
if (project.plugins.hasPlugin("com.android.application") ||
|
if (project.plugins.hasPlugin("com.android.application") ||
|
||||||
project.plugins.hasPlugin("com.android.library")) {
|
project.plugins.hasPlugin("com.android.library")) {
|
||||||
project.android {
|
project.android {
|
||||||
compileSdkVersion 34
|
compileSdkVersion 35
|
||||||
buildToolsVersion "34.0.0"
|
buildToolsVersion "35.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4096M
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
android.nonTransitiveRClass=false
|
android.nonTransitiveRClass=false
|
||||||
android.nonFinalResIds=false
|
android.nonFinalResIds=false
|
||||||
@@ -65,6 +65,8 @@ PODS:
|
|||||||
- maplibre_gl (0.0.1):
|
- maplibre_gl (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- MapLibre (= 5.14.0-pre3)
|
- MapLibre (= 5.14.0-pre3)
|
||||||
|
- native_video_player (1.0.0):
|
||||||
|
- Flutter
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
@@ -115,6 +117,7 @@ DEPENDENCIES:
|
|||||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||||
|
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||||
@@ -168,6 +171,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
||||||
maplibre_gl:
|
maplibre_gl:
|
||||||
:path: ".symlinks/plugins/maplibre_gl/ios"
|
:path: ".symlinks/plugins/maplibre_gl/ios"
|
||||||
|
native_video_player:
|
||||||
|
:path: ".symlinks/plugins/native_video_player/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
@@ -194,7 +199,7 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
|
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
|
||||||
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
|
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
|
||||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||||
@@ -210,14 +215,15 @@ SPEC CHECKSUMS:
|
|||||||
isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
|
isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
|
||||||
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
||||||
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
||||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
|
||||||
|
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||||
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
|
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
|
||||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||||
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
|
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
|
||||||
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
@@ -228,4 +234,4 @@ SPEC CHECKSUMS:
|
|||||||
|
|
||||||
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
|
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
|
||||||
|
|
||||||
COCOAPODS: 1.15.2
|
COCOAPODS: 1.16.0
|
||||||
|
|||||||
@@ -401,7 +401,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 183;
|
CURRENT_PROJECT_VERSION = 184;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -543,7 +543,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 183;
|
CURRENT_PROJECT_VERSION = 184;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -571,7 +571,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 183;
|
CURRENT_PROJECT_VERSION = 184;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -1,48 +1,59 @@
|
|||||||
import UIKit
|
|
||||||
import shared_preferences_foundation
|
|
||||||
import Flutter
|
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
import path_provider_ios
|
import path_provider_ios
|
||||||
import photo_manager
|
|
||||||
import permission_handler_apple
|
import permission_handler_apple
|
||||||
|
import photo_manager
|
||||||
|
import shared_preferences_foundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
|
||||||
// Required for flutter_local_notification
|
// Required for flutter_local_notification
|
||||||
if #available(iOS 10.0, *) {
|
if #available(iOS 10.0, *) {
|
||||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
} catch {
|
||||||
|
print("Failed to set audio session category. Error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||||
|
|
||||||
|
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||||
|
|
||||||
|
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
||||||
|
if !registry.hasPlugin("org.cocoapods.path-provider-ios") {
|
||||||
|
FLTPathProviderPlugin.register(
|
||||||
|
with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!)
|
||||||
}
|
}
|
||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
if !registry.hasPlugin("org.cocoapods.photo-manager") {
|
||||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
PhotoManagerPlugin.register(
|
||||||
|
with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
|
||||||
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
|
||||||
|
|
||||||
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
|
||||||
if !registry.hasPlugin("org.cocoapods.path-provider-ios") {
|
|
||||||
FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !registry.hasPlugin("org.cocoapods.photo-manager") {
|
|
||||||
PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
|
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
|
|
||||||
PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
|
||||||
|
SharedPreferencesPlugin.register(
|
||||||
|
with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
|
||||||
|
PermissionHandlerPlugin.register(
|
||||||
|
with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,11 +58,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.120.1</string>
|
<string>1.120.2</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>183</string>
|
<string>184</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const String defaultColorPresetName = "indigo";
|
|||||||
|
|
||||||
const Color immichBrandColorLight = Color(0xFF4150AF);
|
const Color immichBrandColorLight = Color(0xFF4150AF);
|
||||||
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
||||||
|
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
|
||||||
|
const Color red400 = Color(0xFFEF5350);
|
||||||
|
const Color grey200 = Color(0xFFEEEEEE);
|
||||||
|
|
||||||
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
|
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
|
||||||
ImmichColorPreset.indigo: ImmichTheme(
|
ImmichColorPreset.indigo: ImmichTheme(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||||
import 'package:immich_mobile/utils/hash.dart';
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
@@ -22,12 +23,8 @@ class Asset {
|
|||||||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
||||||
type = remote.type.toAssetType(),
|
type = remote.type.toAssetType(),
|
||||||
fileName = remote.originalFileName,
|
fileName = remote.originalFileName,
|
||||||
height = isFlipped(remote)
|
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
||||||
? remote.exifInfo?.exifImageWidth?.toInt()
|
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
||||||
: remote.exifInfo?.exifImageHeight?.toInt(),
|
|
||||||
width = isFlipped(remote)
|
|
||||||
? remote.exifInfo?.exifImageHeight?.toInt()
|
|
||||||
: remote.exifInfo?.exifImageWidth?.toInt(),
|
|
||||||
livePhotoVideoId = remote.livePhotoVideoId,
|
livePhotoVideoId = remote.livePhotoVideoId,
|
||||||
ownerId = fastHash(remote.ownerId),
|
ownerId = fastHash(remote.ownerId),
|
||||||
exifInfo =
|
exifInfo =
|
||||||
@@ -93,6 +90,27 @@ class Asset {
|
|||||||
|
|
||||||
set local(AssetEntity? assetEntity) => _local = assetEntity;
|
set local(AssetEntity? assetEntity) => _local = assetEntity;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool _didUpdateLocal = false;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
Future<AssetEntity> get localAsync async {
|
||||||
|
final local = this.local;
|
||||||
|
if (local == null) {
|
||||||
|
throw Exception('Asset $fileName has no local data');
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedLocal =
|
||||||
|
_didUpdateLocal ? local : await local.obtainForNewProperties();
|
||||||
|
if (updatedLocal == null) {
|
||||||
|
throw Exception('Could not fetch local data for $fileName');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.local = updatedLocal;
|
||||||
|
_didUpdateLocal = true;
|
||||||
|
return updatedLocal;
|
||||||
|
}
|
||||||
|
|
||||||
Id id = Isar.autoIncrement;
|
Id id = Isar.autoIncrement;
|
||||||
|
|
||||||
/// stores the raw SHA1 bytes as a base64 String
|
/// stores the raw SHA1 bytes as a base64 String
|
||||||
@@ -150,10 +168,21 @@ class Asset {
|
|||||||
|
|
||||||
int stackCount;
|
int stackCount;
|
||||||
|
|
||||||
/// Aspect ratio of the asset
|
/// Returns null if the asset has no sync access to the exif info
|
||||||
@ignore
|
@ignore
|
||||||
double? get aspectRatio =>
|
double? get aspectRatio {
|
||||||
width == null || height == null ? 0 : width! / height!;
|
final orientatedWidth = this.orientatedWidth;
|
||||||
|
final orientatedHeight = this.orientatedHeight;
|
||||||
|
|
||||||
|
if (orientatedWidth != null &&
|
||||||
|
orientatedHeight != null &&
|
||||||
|
orientatedWidth > 0 &&
|
||||||
|
orientatedHeight > 0) {
|
||||||
|
return orientatedWidth.toDouble() / orientatedHeight.toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// `true` if this [Asset] is present on the device
|
/// `true` if this [Asset] is present on the device
|
||||||
@ignore
|
@ignore
|
||||||
@@ -172,6 +201,12 @@ class Asset {
|
|||||||
@ignore
|
@ignore
|
||||||
bool get isImage => type == AssetType.image;
|
bool get isImage => type == AssetType.image;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool get isVideo => type == AssetType.video;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
AssetState get storage {
|
AssetState get storage {
|
||||||
if (isRemote && isLocal) {
|
if (isRemote && isLocal) {
|
||||||
@@ -192,6 +227,50 @@ class Asset {
|
|||||||
@ignore
|
@ignore
|
||||||
set byteHash(List<int> hash) => checksum = base64.encode(hash);
|
set byteHash(List<int> hash) => checksum = base64.encode(hash);
|
||||||
|
|
||||||
|
/// Returns null if the asset has no sync access to the exif info
|
||||||
|
@ignore
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
bool? get isFlipped {
|
||||||
|
final exifInfo = this.exifInfo;
|
||||||
|
if (exifInfo != null) {
|
||||||
|
return exifInfo.isFlipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_didUpdateLocal && Platform.isAndroid) {
|
||||||
|
final local = this.local;
|
||||||
|
if (local == null) {
|
||||||
|
throw Exception('Asset $fileName has no local data');
|
||||||
|
}
|
||||||
|
return local.orientation == 90 || local.orientation == 270;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns null if the asset has no sync access to the exif info
|
||||||
|
@ignore
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
int? get orientatedHeight {
|
||||||
|
final isFlipped = this.isFlipped;
|
||||||
|
if (isFlipped == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isFlipped ? width : height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns null if the asset has no sync access to the exif info
|
||||||
|
@ignore
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
int? get orientatedWidth {
|
||||||
|
final isFlipped = this.isFlipped;
|
||||||
|
if (isFlipped == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isFlipped ? height : width;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(other) {
|
bool operator ==(other) {
|
||||||
if (other is! Asset) return false;
|
if (other is! Asset) return false;
|
||||||
@@ -511,21 +590,3 @@ extension AssetsHelper on IsarCollection<Asset> {
|
|||||||
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
|
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if this [int] is flipped 90° clockwise
|
|
||||||
bool isRotated90CW(int orientation) {
|
|
||||||
return [7, 8, -90].contains(orientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if this [int] is flipped 270° clockwise
|
|
||||||
bool isRotated270CW(int orientation) {
|
|
||||||
return [5, 6, 90].contains(orientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise
|
|
||||||
bool isFlipped(AssetResponseDto response) {
|
|
||||||
final int orientation =
|
|
||||||
int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0;
|
|
||||||
return orientation != 0 &&
|
|
||||||
(isRotated90CW(orientation) || isRotated270CW(orientation));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class ExifInfo {
|
|||||||
String? state;
|
String? state;
|
||||||
String? country;
|
String? country;
|
||||||
String? description;
|
String? description;
|
||||||
|
String? orientation;
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
bool get hasCoordinates =>
|
bool get hasCoordinates =>
|
||||||
@@ -45,6 +46,13 @@ class ExifInfo {
|
|||||||
@ignore
|
@ignore
|
||||||
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool? _isFlipped;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
double? get latitude => lat;
|
double? get latitude => lat;
|
||||||
|
|
||||||
@@ -67,7 +75,8 @@ class ExifInfo {
|
|||||||
city = dto.city,
|
city = dto.city,
|
||||||
state = dto.state,
|
state = dto.state,
|
||||||
country = dto.country,
|
country = dto.country,
|
||||||
description = dto.description;
|
description = dto.description,
|
||||||
|
orientation = dto.orientation;
|
||||||
|
|
||||||
ExifInfo({
|
ExifInfo({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -87,6 +96,7 @@ class ExifInfo {
|
|||||||
this.state,
|
this.state,
|
||||||
this.country,
|
this.country,
|
||||||
this.description,
|
this.description,
|
||||||
|
this.orientation,
|
||||||
});
|
});
|
||||||
|
|
||||||
ExifInfo copyWith({
|
ExifInfo copyWith({
|
||||||
@@ -107,6 +117,7 @@ class ExifInfo {
|
|||||||
String? state,
|
String? state,
|
||||||
String? country,
|
String? country,
|
||||||
String? description,
|
String? description,
|
||||||
|
String? orientation,
|
||||||
}) =>
|
}) =>
|
||||||
ExifInfo(
|
ExifInfo(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -126,6 +137,7 @@ class ExifInfo {
|
|||||||
state: state ?? this.state,
|
state: state ?? this.state,
|
||||||
country: country ?? this.country,
|
country: country ?? this.country,
|
||||||
description: description ?? this.description,
|
description: description ?? this.description,
|
||||||
|
orientation: orientation ?? this.orientation,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -147,7 +159,8 @@ class ExifInfo {
|
|||||||
city == other.city &&
|
city == other.city &&
|
||||||
state == other.state &&
|
state == other.state &&
|
||||||
country == other.country &&
|
country == other.country &&
|
||||||
description == other.description;
|
description == other.description &&
|
||||||
|
orientation == other.orientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -169,7 +182,8 @@ class ExifInfo {
|
|||||||
city.hashCode ^
|
city.hashCode ^
|
||||||
state.hashCode ^
|
state.hashCode ^
|
||||||
country.hashCode ^
|
country.hashCode ^
|
||||||
description.hashCode;
|
description.hashCode ^
|
||||||
|
orientation.hashCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
@@ -192,10 +206,21 @@ class ExifInfo {
|
|||||||
state: $state,
|
state: $state,
|
||||||
country: $country,
|
country: $country,
|
||||||
description: $description,
|
description: $description,
|
||||||
|
orientation: $orientation
|
||||||
}""";
|
}""";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isOrientationFlipped(String? orientation) {
|
||||||
|
final value = orientation != null ? int.tryParse(orientation) : null;
|
||||||
|
if (value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final isRotated90CW = value == 5 || value == 6 || value == 90;
|
||||||
|
final isRotated270CW = value == 7 || value == 8 || value == -90;
|
||||||
|
return isRotated90CW || isRotated270CW;
|
||||||
|
}
|
||||||
|
|
||||||
double? _exposureTimeToSeconds(String? s) {
|
double? _exposureTimeToSeconds(String? s) {
|
||||||
if (s == null) {
|
if (s == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -87,13 +87,18 @@ const ExifInfoSchema = CollectionSchema(
|
|||||||
name: r'model',
|
name: r'model',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'state': PropertySchema(
|
r'orientation': PropertySchema(
|
||||||
id: 14,
|
id: 14,
|
||||||
|
name: r'orientation',
|
||||||
|
type: IsarType.string,
|
||||||
|
),
|
||||||
|
r'state': PropertySchema(
|
||||||
|
id: 15,
|
||||||
name: r'state',
|
name: r'state',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'timeZone': PropertySchema(
|
r'timeZone': PropertySchema(
|
||||||
id: 15,
|
id: 16,
|
||||||
name: r'timeZone',
|
name: r'timeZone',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
)
|
)
|
||||||
@@ -154,6 +159,12 @@ int _exifInfoEstimateSize(
|
|||||||
bytesCount += 3 + value.length * 3;
|
bytesCount += 3 + value.length * 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
final value = object.orientation;
|
||||||
|
if (value != null) {
|
||||||
|
bytesCount += 3 + value.length * 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
{
|
{
|
||||||
final value = object.state;
|
final value = object.state;
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
@@ -189,8 +200,9 @@ void _exifInfoSerialize(
|
|||||||
writer.writeString(offsets[11], object.make);
|
writer.writeString(offsets[11], object.make);
|
||||||
writer.writeFloat(offsets[12], object.mm);
|
writer.writeFloat(offsets[12], object.mm);
|
||||||
writer.writeString(offsets[13], object.model);
|
writer.writeString(offsets[13], object.model);
|
||||||
writer.writeString(offsets[14], object.state);
|
writer.writeString(offsets[14], object.orientation);
|
||||||
writer.writeString(offsets[15], object.timeZone);
|
writer.writeString(offsets[15], object.state);
|
||||||
|
writer.writeString(offsets[16], object.timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
ExifInfo _exifInfoDeserialize(
|
ExifInfo _exifInfoDeserialize(
|
||||||
@@ -215,8 +227,9 @@ ExifInfo _exifInfoDeserialize(
|
|||||||
make: reader.readStringOrNull(offsets[11]),
|
make: reader.readStringOrNull(offsets[11]),
|
||||||
mm: reader.readFloatOrNull(offsets[12]),
|
mm: reader.readFloatOrNull(offsets[12]),
|
||||||
model: reader.readStringOrNull(offsets[13]),
|
model: reader.readStringOrNull(offsets[13]),
|
||||||
state: reader.readStringOrNull(offsets[14]),
|
orientation: reader.readStringOrNull(offsets[14]),
|
||||||
timeZone: reader.readStringOrNull(offsets[15]),
|
state: reader.readStringOrNull(offsets[15]),
|
||||||
|
timeZone: reader.readStringOrNull(offsets[16]),
|
||||||
);
|
);
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
@@ -260,6 +273,8 @@ P _exifInfoDeserializeProp<P>(
|
|||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 15:
|
case 15:
|
||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
|
case 16:
|
||||||
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
default:
|
default:
|
||||||
throw IsarError('Unknown property with id $propertyId');
|
throw IsarError('Unknown property with id $propertyId');
|
||||||
}
|
}
|
||||||
@@ -1909,6 +1924,155 @@ extension ExifInfoQueryFilter
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsNull() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(const FilterCondition.isNull(
|
||||||
|
property: r'orientation',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||||
|
orientationIsNotNull() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||||
|
property: r'orientation',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEqualTo(
|
||||||
|
String? value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||||
|
orientationGreaterThan(
|
||||||
|
String? value, {
|
||||||
|
bool include = false,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||||
|
include: include,
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationLessThan(
|
||||||
|
String? value, {
|
||||||
|
bool include = false,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.lessThan(
|
||||||
|
include: include,
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationBetween(
|
||||||
|
String? lower,
|
||||||
|
String? upper, {
|
||||||
|
bool includeLower = true,
|
||||||
|
bool includeUpper = true,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.between(
|
||||||
|
property: r'orientation',
|
||||||
|
lower: lower,
|
||||||
|
includeLower: includeLower,
|
||||||
|
upper: upper,
|
||||||
|
includeUpper: includeUpper,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationStartsWith(
|
||||||
|
String value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.startsWith(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEndsWith(
|
||||||
|
String value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.endsWith(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationContains(
|
||||||
|
String value,
|
||||||
|
{bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.contains(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationMatches(
|
||||||
|
String pattern,
|
||||||
|
{bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.matches(
|
||||||
|
property: r'orientation',
|
||||||
|
wildcard: pattern,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsEmpty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'orientation',
|
||||||
|
value: '',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||||
|
orientationIsNotEmpty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||||
|
property: r'orientation',
|
||||||
|
value: '',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> stateIsNull() {
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> stateIsNull() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addFilterCondition(const FilterCondition.isNull(
|
return query.addFilterCondition(const FilterCondition.isNull(
|
||||||
@@ -2377,6 +2541,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientation() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientationDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByState() {
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByState() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addSortBy(r'state', Sort.asc);
|
return query.addSortBy(r'state', Sort.asc);
|
||||||
@@ -2584,6 +2760,18 @@ extension ExifInfoQuerySortThenBy
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientation() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientationDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByState() {
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByState() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addSortBy(r'state', Sort.asc);
|
return query.addSortBy(r'state', Sort.asc);
|
||||||
@@ -2701,6 +2889,13 @@ extension ExifInfoQueryWhereDistinct
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByOrientation(
|
||||||
|
{bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByState(
|
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByState(
|
||||||
{bool caseSensitive = true}) {
|
{bool caseSensitive = true}) {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
@@ -2809,6 +3004,12 @@ extension ExifInfoQueryProperty
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, String?, QQueryOperations> orientationProperty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addPropertyName(r'orientation');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, String?, QQueryOperations> stateProperty() {
|
QueryBuilder<ExifInfo, String?, QQueryOperations> stateProperty() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addPropertyName(r'state');
|
return query.addPropertyName(r'state');
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
|
||||||
|
const _spring = SpringDescription(
|
||||||
|
mass: 40,
|
||||||
|
stiffness: 100,
|
||||||
|
damping: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/74453792
|
||||||
|
class FastScrollPhysics extends ScrollPhysics {
|
||||||
|
const FastScrollPhysics({super.parent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
FastScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||||
|
return FastScrollPhysics(parent: buildParent(ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SpringDescription get spring => _spring;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
||||||
|
const FastClampingScrollPhysics({super.parent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||||
|
return FastClampingScrollPhysics(parent: buildParent(ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SpringDescription get spring => _spring;
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ class CurrentUploadAsset {
|
|||||||
this.iCloudAsset,
|
this.iCloudAsset,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
bool get isIcloudAsset => iCloudAsset != null && iCloudAsset!;
|
||||||
|
|
||||||
CurrentUploadAsset copyWith({
|
CurrentUploadAsset copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
DateTime? fileCreatedAt,
|
DateTime? fileCreatedAt,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
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/image/immich_remote_image_provider.dart';
|
||||||
|
|
||||||
|
class GalleryStackedChildren extends HookConsumerWidget {
|
||||||
|
final ValueNotifier<int> stackIndex;
|
||||||
|
|
||||||
|
const GalleryStackedChildren(this.stackIndex, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asset = ref.watch(currentAssetProvider);
|
||||||
|
if (asset == null) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
final stackId = asset.stackId;
|
||||||
|
if (stackId == null) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
final stackElements = ref.watch(assetStackStateProvider(stackId));
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 80,
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: stackElements.length,
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 5,
|
||||||
|
right: 5,
|
||||||
|
bottom: 30,
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final currentAsset = stackElements.elementAt(index);
|
||||||
|
final assetId = currentAsset.remoteId;
|
||||||
|
if (assetId == null) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
key: ValueKey(currentAsset.id),
|
||||||
|
padding: const EdgeInsets.only(right: 5),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
stackIndex.value = index;
|
||||||
|
ref.read(currentAssetProvider.notifier).set(currentAsset);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: index == stackIndex.value
|
||||||
|
? const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||||
|
border: Border.fromBorderSide(
|
||||||
|
BorderSide(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||||
|
border: null,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||||
|
child: Image(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
image: ImmichRemoteImageProvider(assetId: assetId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,18 +8,19 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
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/constants/constants.dart';
|
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||||
import 'package:immich_mobile/pages/common/download_panel.dart';
|
import 'package:immich_mobile/pages/common/download_panel.dart';
|
||||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
|
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
||||||
|
import 'package:immich_mobile/pages/common/gallery_stacked_children.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/asset_stack.provider.dart';
|
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/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/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/image/immich_remote_image_provider.dart';
|
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart';
|
||||||
@@ -35,6 +36,7 @@ import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attri
|
|||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
|
/// Expects [currentAssetProvider] to be set before navigating to this page
|
||||||
class GalleryViewerPage extends HookConsumerWidget {
|
class GalleryViewerPage extends HookConsumerWidget {
|
||||||
final int initialIndex;
|
final int initialIndex;
|
||||||
final int heroOffset;
|
final int heroOffset;
|
||||||
@@ -53,79 +55,66 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final settings = ref.watch(appSettingsServiceProvider);
|
|
||||||
final loadAsset = renderList.loadAsset;
|
|
||||||
final totalAssets = useState(renderList.totalAssets);
|
final totalAssets = useState(renderList.totalAssets);
|
||||||
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
|
|
||||||
final isZoomed = useState(false);
|
final isZoomed = useState(false);
|
||||||
final isPlayingVideo = useState(false);
|
final stackIndex = useState(0);
|
||||||
final localPosition = useState<Offset?>(null);
|
final localPosition = useRef<Offset?>(null);
|
||||||
final currentIndex = useState(initialIndex);
|
final currentIndex = useValueNotifier(initialIndex);
|
||||||
final currentAsset = loadAsset(currentIndex.value);
|
final loadAsset = renderList.loadAsset;
|
||||||
|
|
||||||
// Update is playing motion video
|
|
||||||
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
|
|
||||||
isPlayingVideo.value = state == VideoPlaybackState.playing;
|
|
||||||
});
|
|
||||||
|
|
||||||
final stackIndex = useState(-1);
|
|
||||||
final stack = showStack && currentAsset.stackCount > 0
|
|
||||||
? ref.watch(assetStackStateProvider(currentAsset))
|
|
||||||
: <Asset>[];
|
|
||||||
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
|
|
||||||
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
|
|
||||||
final isFromDto = currentAsset.id == noDbId;
|
|
||||||
|
|
||||||
Asset asset = stackIndex.value == -1
|
|
||||||
? currentAsset
|
|
||||||
: stackElements.elementAt(stackIndex.value);
|
|
||||||
|
|
||||||
final isMotionPhoto = asset.livePhotoVideoId != null;
|
|
||||||
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
|
|
||||||
ref.listen(currentAssetProvider, (_, __) {});
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
// Delay state update to after the execution of build method
|
|
||||||
Future.microtask(
|
|
||||||
() => ref.read(currentAssetProvider.notifier).set(asset),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[asset],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
shouldLoopVideo.value =
|
|
||||||
settings.getSetting<bool>(AppSettingsEnum.loopVideo);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> precacheNextImage(int index) async {
|
Future<void> precacheNextImage(int index) async {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void onError(Object exception, StackTrace? stackTrace) {
|
void onError(Object exception, StackTrace? stackTrace) {
|
||||||
// swallow error silently
|
// swallow error silently
|
||||||
debugPrint('Error precaching next image: $exception, $stackTrace');
|
log.severe('Error precaching next image: $exception, $stackTrace');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (index < totalAssets.value && index >= 0) {
|
if (index < totalAssets.value && index >= 0) {
|
||||||
final asset = loadAsset(index);
|
final asset = loadAsset(index);
|
||||||
await precacheImage(
|
await precacheImage(
|
||||||
ImmichImage.imageProvider(asset: asset),
|
ImmichImage.imageProvider(
|
||||||
|
asset: asset,
|
||||||
|
width: context.width,
|
||||||
|
height: context.height,
|
||||||
|
),
|
||||||
context,
|
context,
|
||||||
onError: onError,
|
onError: onError,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// swallow error silently
|
// swallow error silently
|
||||||
debugPrint('Error precaching next image: $e');
|
log.severe('Error precaching next image: $e');
|
||||||
context.maybePop();
|
context.maybePop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
if (ref.read(showControlsProvider)) {
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
} else {
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay this a bit so we can finish loading the page
|
||||||
|
Timer(const Duration(milliseconds: 400), () {
|
||||||
|
precacheNextImage(currentIndex.value + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
void showInfo() {
|
void showInfo() {
|
||||||
|
final asset = ref.read(currentAssetProvider);
|
||||||
|
if (asset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(15.0)),
|
borderRadius: BorderRadius.all(Radius.circular(15.0)),
|
||||||
@@ -183,34 +172,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
if (ref.read(showControlsProvider)) {
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
||||||
} else {
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
|
||||||
}
|
|
||||||
isPlayingVideo.value = false;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
// No need to await this
|
|
||||||
unawaited(
|
|
||||||
// Delay this a bit so we can finish loading the page
|
|
||||||
Future.delayed(const Duration(milliseconds: 400)).then(
|
|
||||||
// Precache the next image
|
|
||||||
(_) => precacheNextImage(currentIndex.value + 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
ref.listen(showControlsProvider, (_, show) {
|
ref.listen(showControlsProvider, (_, show) {
|
||||||
if (show) {
|
if (show) {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
@@ -219,50 +180,88 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget buildStackedChildren() {
|
PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) {
|
||||||
return ListView.builder(
|
return PhotoViewGalleryPageOptions(
|
||||||
shrinkWrap: true,
|
onDragStart: (_, details, __) {
|
||||||
scrollDirection: Axis.horizontal,
|
localPosition.value = details.localPosition;
|
||||||
itemCount: stackElements.length,
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: 5,
|
|
||||||
right: 5,
|
|
||||||
bottom: 30,
|
|
||||||
),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final assetId = stackElements.elementAt(index).remoteId;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 5),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => stackIndex.value = index,
|
|
||||||
child: Container(
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
border: (stackIndex.value == -1 && index == 0) ||
|
|
||||||
index == stackIndex.value
|
|
||||||
? Border.all(
|
|
||||||
color: Colors.white,
|
|
||||||
width: 2,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Image(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
image: ImmichRemoteImageProvider(assetId: assetId!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
onDragUpdate: (_, details, __) {
|
||||||
|
handleSwipeUpDown(details);
|
||||||
|
},
|
||||||
|
onTapDown: (_, __, ___) {
|
||||||
|
ref.read(showControlsProvider.notifier).toggle();
|
||||||
|
},
|
||||||
|
onLongPressStart: asset.isMotionPhoto
|
||||||
|
? (_, __, ___) {
|
||||||
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
imageProvider: ImmichImage.imageProvider(asset: asset),
|
||||||
|
heroAttributes: _getHeroAttributes(asset),
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
tightMode: true,
|
||||||
|
minScale: PhotoViewComputedScale.contained,
|
||||||
|
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
||||||
|
asset,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
|
||||||
|
// This key is to prevent the video player from being re-initialized during the hero animation
|
||||||
|
final key = GlobalKey();
|
||||||
|
return PhotoViewGalleryPageOptions.customChild(
|
||||||
|
onDragStart: (_, details, __) =>
|
||||||
|
localPosition.value = details.localPosition,
|
||||||
|
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
||||||
|
heroAttributes: _getHeroAttributes(asset),
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
initialScale: 1.0,
|
||||||
|
maxScale: 1.0,
|
||||||
|
minScale: 1.0,
|
||||||
|
basePosition: Alignment.center,
|
||||||
|
child: SizedBox(
|
||||||
|
width: context.width,
|
||||||
|
height: context.height,
|
||||||
|
child: NativeVideoViewerPage(
|
||||||
|
key: key,
|
||||||
|
asset: asset,
|
||||||
|
image: Image(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
image: ImmichImage.imageProvider(
|
||||||
|
asset: asset,
|
||||||
|
width: context.width,
|
||||||
|
height: context.height,
|
||||||
|
),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
height: context.height,
|
||||||
|
width: context.width,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
|
||||||
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||||
|
var newAsset = loadAsset(index);
|
||||||
|
final stackId = newAsset.stackId;
|
||||||
|
if (stackId != null && currentIndex.value == index) {
|
||||||
|
final stackElements =
|
||||||
|
ref.read(assetStackStateProvider(newAsset.stackId!));
|
||||||
|
if (stackIndex.value < stackElements.length) {
|
||||||
|
newAsset = stackElements.elementAt(stackIndex.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAsset.isImage && !newAsset.isMotionPhoto) {
|
||||||
|
return buildImage(context, newAsset);
|
||||||
|
}
|
||||||
|
return buildVideo(context, newAsset);
|
||||||
|
}
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
// Change immersive mode back to normal "edgeToEdge" mode
|
// Change immersive mode back to normal "edgeToEdge" mode
|
||||||
onPopInvokedWithResult: (didPop, _) =>
|
onPopInvokedWithResult: (didPop, _) =>
|
||||||
@@ -272,128 +271,79 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
PhotoViewGallery.builder(
|
PhotoViewGallery.builder(
|
||||||
|
key: const ValueKey('gallery'),
|
||||||
scaleStateChangedCallback: (state) {
|
scaleStateChangedCallback: (state) {
|
||||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
final asset = ref.read(currentAssetProvider);
|
||||||
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
if (asset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.isImage && !ref.read(isPlayingMotionVideoProvider)) {
|
||||||
|
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||||
|
ref.read(showControlsProvider.notifier).show =
|
||||||
|
!isZoomed.value;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
loadingBuilder: (context, event, index) => ClipRect(
|
gaplessPlayback: true,
|
||||||
child: Stack(
|
loadingBuilder: (context, event, index) {
|
||||||
fit: StackFit.expand,
|
final asset = loadAsset(index);
|
||||||
children: [
|
return ClipRect(
|
||||||
BackdropFilter(
|
child: Stack(
|
||||||
filter: ui.ImageFilter.blur(
|
fit: StackFit.expand,
|
||||||
sigmaX: 10,
|
children: [
|
||||||
sigmaY: 10,
|
BackdropFilter(
|
||||||
|
filter: ui.ImageFilter.blur(
|
||||||
|
sigmaX: 10,
|
||||||
|
sigmaY: 10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
ImmichThumbnail(
|
||||||
ImmichThumbnail(
|
key: ValueKey(asset),
|
||||||
asset: asset,
|
asset: asset,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
pageController: controller,
|
pageController: controller,
|
||||||
scrollPhysics: isZoomed.value
|
scrollPhysics: isZoomed.value
|
||||||
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||||
: (Platform.isIOS
|
: (Platform.isIOS
|
||||||
? const ScrollPhysics() // Use bouncing physics for iOS
|
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||||
: const ClampingScrollPhysics() // Use heavy physics for Android
|
: const FastClampingScrollPhysics() // Use heavy physics for Android
|
||||||
),
|
),
|
||||||
itemCount: totalAssets.value,
|
itemCount: totalAssets.value,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
onPageChanged: (value) async {
|
onPageChanged: (value) {
|
||||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||||
|
|
||||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||||
|
|
||||||
|
final newAsset = loadAsset(value);
|
||||||
|
|
||||||
currentIndex.value = value;
|
currentIndex.value = value;
|
||||||
stackIndex.value = -1;
|
stackIndex.value = 0;
|
||||||
isPlayingVideo.value = false;
|
|
||||||
|
|
||||||
// Wait for page change animation to finish
|
ref.read(currentAssetProvider.notifier).set(newAsset);
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
if (newAsset.isVideo || newAsset.isMotionPhoto) {
|
||||||
// Then precache the next image
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
unawaited(precacheNextImage(next));
|
|
||||||
},
|
|
||||||
builder: (context, index) {
|
|
||||||
final a =
|
|
||||||
index == currentIndex.value ? asset : loadAsset(index);
|
|
||||||
|
|
||||||
final ImageProvider provider =
|
|
||||||
ImmichImage.imageProvider(asset: a);
|
|
||||||
|
|
||||||
if (a.isImage && !isPlayingVideo.value) {
|
|
||||||
return PhotoViewGalleryPageOptions(
|
|
||||||
onDragStart: (_, details, __) =>
|
|
||||||
localPosition.value = details.localPosition,
|
|
||||||
onDragUpdate: (_, details, __) =>
|
|
||||||
handleSwipeUpDown(details),
|
|
||||||
onTapDown: (_, __, ___) {
|
|
||||||
ref.read(showControlsProvider.notifier).toggle();
|
|
||||||
},
|
|
||||||
onLongPressStart: (_, __, ___) {
|
|
||||||
if (asset.livePhotoVideoId != null) {
|
|
||||||
isPlayingVideo.value = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
imageProvider: provider,
|
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
|
||||||
tag: isFromDto
|
|
||||||
? '${currentAsset.remoteId}-$heroOffset'
|
|
||||||
: currentAsset.id + heroOffset,
|
|
||||||
transitionOnUserGestures: true,
|
|
||||||
),
|
|
||||||
filterQuality: FilterQuality.high,
|
|
||||||
tightMode: true,
|
|
||||||
minScale: PhotoViewComputedScale.contained,
|
|
||||||
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
|
||||||
a,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return PhotoViewGalleryPageOptions.customChild(
|
|
||||||
onDragStart: (_, details, __) =>
|
|
||||||
localPosition.value = details.localPosition,
|
|
||||||
onDragUpdate: (_, details, __) =>
|
|
||||||
handleSwipeUpDown(details),
|
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
|
||||||
tag: isFromDto
|
|
||||||
? '${currentAsset.remoteId}-$heroOffset'
|
|
||||||
: currentAsset.id + heroOffset,
|
|
||||||
),
|
|
||||||
filterQuality: FilterQuality.high,
|
|
||||||
maxScale: 1.0,
|
|
||||||
minScale: 1.0,
|
|
||||||
basePosition: Alignment.center,
|
|
||||||
child: VideoViewerPage(
|
|
||||||
key: ValueKey(a),
|
|
||||||
asset: a,
|
|
||||||
isMotionVideo: a.livePhotoVideoId != null,
|
|
||||||
loopVideo: shouldLoopVideo.value,
|
|
||||||
placeholder: Image(
|
|
||||||
image: provider,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
height: context.height,
|
|
||||||
width: context.width,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for page change animation to finish, then precache the next image
|
||||||
|
Timer(const Duration(milliseconds: 400), () {
|
||||||
|
precacheNextImage(next);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
builder: buildAsset,
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: GalleryAppBar(
|
child: GalleryAppBar(
|
||||||
asset: asset,
|
key: const ValueKey('app-bar'),
|
||||||
showInfo: showInfo,
|
showInfo: showInfo,
|
||||||
isPlayingVideo: isPlayingVideo.value,
|
|
||||||
onToggleMotionVideo: () =>
|
|
||||||
isPlayingVideo.value = !isPlayingVideo.value,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -402,22 +352,15 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
right: 0,
|
right: 0,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Visibility(
|
GalleryStackedChildren(stackIndex),
|
||||||
visible: stack.isNotEmpty,
|
|
||||||
child: SizedBox(
|
|
||||||
height: 80,
|
|
||||||
child: buildStackedChildren(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
BottomGalleryBar(
|
BottomGalleryBar(
|
||||||
|
key: const ValueKey('bottom-bar'),
|
||||||
renderList: renderList,
|
renderList: renderList,
|
||||||
totalAssets: totalAssets,
|
totalAssets: totalAssets,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
showStack: showStack,
|
showStack: showStack,
|
||||||
stackIndex: stackIndex.value,
|
stackIndex: stackIndex.value,
|
||||||
asset: asset,
|
|
||||||
assetIndex: currentIndex,
|
assetIndex: currentIndex,
|
||||||
showVideoPlayerControls: !asset.isImage && !isMotionPhoto,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -428,4 +371,14 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
PhotoViewHeroAttributes _getHeroAttributes(Asset asset) {
|
||||||
|
return PhotoViewHeroAttributes(
|
||||||
|
tag: asset.isInDb
|
||||||
|
? asset.id + heroOffset
|
||||||
|
: '${asset.remoteId}-$heroOffset',
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/services/asset.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
|
final Asset asset;
|
||||||
|
final bool showControls;
|
||||||
|
final Widget image;
|
||||||
|
|
||||||
|
const NativeVideoViewerPage({
|
||||||
|
super.key,
|
||||||
|
required this.asset,
|
||||||
|
required this.image,
|
||||||
|
this.showControls = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final controller = useState<NativeVideoPlayerController?>(null);
|
||||||
|
final lastVideoPosition = useRef(-1);
|
||||||
|
final isBuffering = useRef(false);
|
||||||
|
final showMotionVideo = useState(false);
|
||||||
|
|
||||||
|
// When a video is opened through the timeline, `isCurrent` will immediately be true.
|
||||||
|
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
|
||||||
|
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
|
||||||
|
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
|
||||||
|
final currentAsset = useState(ref.read(currentAssetProvider));
|
||||||
|
final isCurrent = currentAsset.value == asset;
|
||||||
|
|
||||||
|
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
|
||||||
|
final isVisible =
|
||||||
|
useState((Platform.isIOS && asset.isLocal) || asset.isMotionPhoto);
|
||||||
|
|
||||||
|
final log = Logger('NativeVideoViewerPage');
|
||||||
|
|
||||||
|
ref.listen(isPlayingMotionVideoProvider, (_, value) async {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (!asset.isMotionPhoto || videoController == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMotionVideo.value = value;
|
||||||
|
try {
|
||||||
|
if (value) {
|
||||||
|
await videoController.seekTo(0);
|
||||||
|
await videoController.play();
|
||||||
|
} else {
|
||||||
|
await videoController.pause();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error toggling motion video: $error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<VideoSource?> createSource() async {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final local = asset.local;
|
||||||
|
if (local != null && !asset.isMotionPhoto) {
|
||||||
|
final file = await local.file;
|
||||||
|
if (file == null) {
|
||||||
|
throw Exception('No file found for the video');
|
||||||
|
}
|
||||||
|
|
||||||
|
final source = await VideoSource.init(
|
||||||
|
path: file.path,
|
||||||
|
type: VideoSourceType.file,
|
||||||
|
);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a network URL for the video player controller
|
||||||
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
final String videoUrl = asset.livePhotoVideoId != null
|
||||||
|
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
|
||||||
|
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
|
||||||
|
|
||||||
|
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 ${asset.fileName}: $error',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||||
|
final aspectRatio = useState<double?>(asset.aspectRatio);
|
||||||
|
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.fileName}: $error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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 volume changes, set the volume
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
|
||||||
|
(_, mute) async {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = playerController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mute && playbackInfo.volume != 0.0) {
|
||||||
|
await playerController.setVolume(0.0);
|
||||||
|
} else if (!mute && playbackInfo.volume != 0.9) {
|
||||||
|
await playerController.setVolume(0.9);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error setting volume: $error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
);
|
||||||
|
Future<void> onPlayerControlsPlayChange(bool? _, bool pause) async {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !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();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pause) {
|
||||||
|
await videoController.pause();
|
||||||
|
} else {
|
||||||
|
await videoController.play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error pausing or playing video: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
|
||||||
|
(_, position) {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = playerController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the position to seek to
|
||||||
|
final seek = position ~/ 1;
|
||||||
|
if (seek != playbackInfo.position) {
|
||||||
|
seekDebouncer.run(() => playerController.seekTo(seek));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isIOS &&
|
||||||
|
seek == 0 &&
|
||||||
|
!ref.read(videoPlayerControlsProvider.notifier).paused) {
|
||||||
|
onPlayerControlsPlayChange(null, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// // When the custom video controls pause or play
|
||||||
|
ref.listen(
|
||||||
|
videoPlayerControlsProvider.select((value) => value.pause),
|
||||||
|
onPlayerControlsPlayChange,
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (asset.isVideo || showMotionVideo.value) {
|
||||||
|
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(seconds: 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 removeListeners(NativeVideoPlayerController controller) {
|
||||||
|
controller.onPlaybackPositionChanged
|
||||||
|
.removeListener(onPlaybackPositionChanged);
|
||||||
|
controller.onPlaybackStatusChanged
|
||||||
|
.removeListener(onPlaybackStatusChanged);
|
||||||
|
controller.onPlaybackReady.removeListener(onPlaybackReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||||
|
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||||
|
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||||
|
|
||||||
|
nc.loadVideoSource(source).catchError((error) {
|
||||||
|
log.severe('Error loading video source: $error');
|
||||||
|
});
|
||||||
|
final loopVideo = ref
|
||||||
|
.read(appSettingsServiceProvider)
|
||||||
|
.getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||||
|
nc.setLoop(loopVideo);
|
||||||
|
|
||||||
|
controller.value = nc;
|
||||||
|
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.listen(currentAssetProvider, (_, value) {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController != null && value != asset) {
|
||||||
|
removeListeners(playerController);
|
||||||
|
}
|
||||||
|
|
||||||
|
final curAsset = currentAsset.value;
|
||||||
|
if (curAsset == asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No need to delay video playback when swiping from an image to a video
|
||||||
|
if (curAsset != null && !curAsset.isVideo) {
|
||||||
|
currentAsset.value = value;
|
||||||
|
onPlaybackReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay the video playback to avoid a stutter in the swipe animation
|
||||||
|
Timer(const Duration(milliseconds: 300), () {
|
||||||
|
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.severe('Error stopping video: $error');
|
||||||
|
});
|
||||||
|
|
||||||
|
WakelockPlus.disable();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// This remains under the video to avoid flickering
|
||||||
|
// For motion videos, this is the image portion of the asset
|
||||||
|
Center(key: ValueKey(asset.id), child: image),
|
||||||
|
if (aspectRatio.value != null)
|
||||||
|
Visibility.maintain(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
visible:
|
||||||
|
(asset.isVideo || showMotionVideo.value) && isVisible.value,
|
||||||
|
child: Center(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
child: AspectRatio(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
aspectRatio: aspectRatio.value!,
|
||||||
|
child: isCurrent
|
||||||
|
? NativeVideoPlayerView(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
onViewReady: initController,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showControls) const Center(child: CustomVideoPlayerControls()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controller_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/video_player.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
|
|
||||||
class VideoViewerPage extends HookConsumerWidget {
|
|
||||||
final Asset asset;
|
|
||||||
final bool isMotionVideo;
|
|
||||||
final Widget? placeholder;
|
|
||||||
final Duration hideControlsTimer;
|
|
||||||
final bool showControls;
|
|
||||||
final bool showDownloadingIndicator;
|
|
||||||
final bool loopVideo;
|
|
||||||
|
|
||||||
const VideoViewerPage({
|
|
||||||
super.key,
|
|
||||||
required this.asset,
|
|
||||||
this.isMotionVideo = false,
|
|
||||||
this.placeholder,
|
|
||||||
this.showControls = true,
|
|
||||||
this.hideControlsTimer = const Duration(seconds: 5),
|
|
||||||
this.showDownloadingIndicator = true,
|
|
||||||
this.loopVideo = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
build(BuildContext context, WidgetRef ref) {
|
|
||||||
final controller =
|
|
||||||
ref.watch(videoPlayerControllerProvider(asset: asset)).value;
|
|
||||||
// The last volume of the video used when mute is toggled
|
|
||||||
final lastVolume = useState(0.5);
|
|
||||||
|
|
||||||
// When the volume changes, set the volume
|
|
||||||
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
|
|
||||||
(_, mute) {
|
|
||||||
if (mute) {
|
|
||||||
controller?.setVolume(0.0);
|
|
||||||
} else {
|
|
||||||
controller?.setVolume(lastVolume.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// When the position changes, seek to the position
|
|
||||||
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
|
|
||||||
(_, position) {
|
|
||||||
if (controller == null) {
|
|
||||||
// No seeeking if there is no video
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the position to seek to
|
|
||||||
final Duration seek = controller.value.duration * (position / 100.0);
|
|
||||||
controller.seekTo(seek);
|
|
||||||
});
|
|
||||||
|
|
||||||
// When the custom video controls paus or plays
|
|
||||||
ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
|
|
||||||
(lastPause, pause) {
|
|
||||||
if (pause) {
|
|
||||||
controller?.pause();
|
|
||||||
} else {
|
|
||||||
controller?.play();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Updates the [videoPlaybackValueProvider] with the current
|
|
||||||
// position and duration of the video from the Chewie [controller]
|
|
||||||
// Also sets the error if there is an error in the playback
|
|
||||||
void updateVideoPlayback() {
|
|
||||||
final videoPlayback = VideoPlaybackValue.fromController(controller);
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
|
||||||
final state = videoPlayback.state;
|
|
||||||
|
|
||||||
// Enable the WakeLock while the video is playing
|
|
||||||
if (state == VideoPlaybackState.playing) {
|
|
||||||
// Sync with the controls playing
|
|
||||||
WakelockPlus.enable();
|
|
||||||
} else {
|
|
||||||
// Sync with the controls pause
|
|
||||||
WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds and removes the listener to the video player
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
Future.microtask(
|
|
||||||
() => ref.read(videoPlayerControlsProvider.notifier).reset(),
|
|
||||||
);
|
|
||||||
// Guard no controller
|
|
||||||
if (controller == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the controls
|
|
||||||
// Done in a microtask to avoid setting the state while the is building
|
|
||||||
if (!isMotionVideo) {
|
|
||||||
Future.microtask(() {
|
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribes to listener
|
|
||||||
Future.microtask(() {
|
|
||||||
controller.addListener(updateVideoPlayback);
|
|
||||||
});
|
|
||||||
return () {
|
|
||||||
// Removes listener when we dispose
|
|
||||||
controller.removeListener(updateVideoPlayback);
|
|
||||||
controller.pause();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[controller],
|
|
||||||
);
|
|
||||||
|
|
||||||
final size = MediaQuery.sizeOf(context);
|
|
||||||
|
|
||||||
return PopScope(
|
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value =
|
|
||||||
VideoPlaybackValue.uninitialized();
|
|
||||||
},
|
|
||||||
child: AnimatedSwitcher(
|
|
||||||
duration: const Duration(milliseconds: 400),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Visibility(
|
|
||||||
visible: controller == null,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
if (placeholder != null) placeholder!,
|
|
||||||
const Positioned.fill(
|
|
||||||
child: Center(
|
|
||||||
child: DelayedLoadingIndicator(
|
|
||||||
fadeInDuration: Duration(milliseconds: 500),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (controller != null)
|
|
||||||
SizedBox(
|
|
||||||
height: size.height,
|
|
||||||
width: size.width,
|
|
||||||
child: VideoPlayerViewer(
|
|
||||||
controller: controller,
|
|
||||||
isMotionVideo: isMotionVideo,
|
|
||||||
placeholder: placeholder,
|
|
||||||
hideControlsTimer: hideControlsTimer,
|
|
||||||
showControls: showControls,
|
|
||||||
showDownloadingIndicator: showDownloadingIndicator,
|
|
||||||
loopVideo: loopVideo,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -113,11 +113,15 @@ class MemoryPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Precache the asset
|
// Precache the asset
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
await precacheImage(
|
await precacheImage(
|
||||||
ImmichImage.imageProvider(
|
ImmichImage.imageProvider(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
),
|
),
|
||||||
context,
|
context,
|
||||||
|
size: size,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
|
|||||||
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
|
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
|
||||||
import 'package:immich_mobile/models/map/map_event.model.dart';
|
import 'package:immich_mobile/models/map/map_event.model.dart';
|
||||||
import 'package:immich_mobile/models/map/map_marker.model.dart';
|
import 'package:immich_mobile/models/map/map_marker.model.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/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/map/map_marker.provider.dart';
|
import 'package:immich_mobile/providers/map/map_marker.provider.dart';
|
||||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||||
@@ -99,8 +101,11 @@ class MapPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
|
final currentAssetLink =
|
||||||
|
ref.read(currentAssetProvider.notifier).ref.keepAlive();
|
||||||
|
|
||||||
loadMarkers();
|
loadMarkers();
|
||||||
return null;
|
return currentAssetLink.close;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -186,6 +191,10 @@ class MapPage extends HookConsumerWidget {
|
|||||||
GroupAssetsBy.none,
|
GroupAssetsBy.none,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ref.read(currentAssetProvider.notifier).set(asset);
|
||||||
|
if (asset.isVideo) {
|
||||||
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
|
}
|
||||||
context.pushRoute(
|
context.pushRoute(
|
||||||
GalleryViewerRoute(
|
GalleryViewerRoute(
|
||||||
initialIndex: 0,
|
initialIndex: 0,
|
||||||
|
|||||||
@@ -7,49 +7,49 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
part 'asset_stack.provider.g.dart';
|
part 'asset_stack.provider.g.dart';
|
||||||
|
|
||||||
class AssetStackNotifier extends StateNotifier<List<Asset>> {
|
class AssetStackNotifier extends StateNotifier<List<Asset>> {
|
||||||
final Asset _asset;
|
final String _stackId;
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
|
|
||||||
AssetStackNotifier(
|
AssetStackNotifier(this._stackId, this._ref) : super([]) {
|
||||||
this._asset,
|
_fetchStack(_stackId);
|
||||||
this._ref,
|
|
||||||
) : super([]) {
|
|
||||||
fetchStackChildren();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void fetchStackChildren() async {
|
void _fetchStack(String stackId) async {
|
||||||
if (mounted) {
|
if (!mounted) {
|
||||||
state = await _ref.read(assetStackProvider(_asset).future);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stack = await _ref.read(assetStackProvider(stackId).future);
|
||||||
|
if (stack.isNotEmpty) {
|
||||||
|
state = stack;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeChild(int index) {
|
void removeChild(int index) {
|
||||||
if (index < state.length) {
|
if (index < state.length) {
|
||||||
state.removeAt(index);
|
state.removeAt(index);
|
||||||
|
state = List<Asset>.from(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final assetStackStateProvider = StateNotifierProvider.autoDispose
|
final assetStackStateProvider = StateNotifierProvider.autoDispose
|
||||||
.family<AssetStackNotifier, List<Asset>, Asset>(
|
.family<AssetStackNotifier, List<Asset>, String>(
|
||||||
(ref, asset) => AssetStackNotifier(asset, ref),
|
(ref, stackId) => AssetStackNotifier(stackId, ref),
|
||||||
);
|
);
|
||||||
|
|
||||||
final assetStackProvider =
|
final assetStackProvider =
|
||||||
FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
|
FutureProvider.autoDispose.family<List<Asset>, String>((ref, stackId) {
|
||||||
// Guard [local asset]
|
return ref
|
||||||
if (asset.remoteId == null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return await ref
|
|
||||||
.watch(dbProvider)
|
.watch(dbProvider)
|
||||||
.assets
|
.assets
|
||||||
.filter()
|
.filter()
|
||||||
.isArchivedEqualTo(false)
|
.isArchivedEqualTo(false)
|
||||||
.isTrashedEqualTo(false)
|
.isTrashedEqualTo(false)
|
||||||
.stackPrimaryAssetIdEqualTo(asset.remoteId)
|
.stackIdEqualTo(stackId)
|
||||||
.sortByFileCreatedAtDesc()
|
// orders primary asset first as its ID is null
|
||||||
|
.sortByStackPrimaryAssetId()
|
||||||
|
.thenByFileCreatedAtDesc()
|
||||||
.findAll();
|
.findAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
/// Whether to display the video part of a motion photo
|
||||||
|
final isPlayingMotionVideoProvider =
|
||||||
|
StateNotifierProvider<IsPlayingMotionVideo, bool>((ref) {
|
||||||
|
return IsPlayingMotionVideo(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class IsPlayingMotionVideo extends StateNotifier<bool> {
|
||||||
|
IsPlayingMotionVideo(this.ref) : super(false);
|
||||||
|
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
bool get playing => state;
|
||||||
|
|
||||||
|
set playing(bool value) {
|
||||||
|
state = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggle() {
|
||||||
|
state = !state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
|
|
||||||
part 'video_player_controller_provider.g.dart';
|
|
||||||
|
|
||||||
@riverpod
|
|
||||||
Future<VideoPlayerController> videoPlayerController(
|
|
||||||
VideoPlayerControllerRef ref, {
|
|
||||||
required Asset asset,
|
|
||||||
}) async {
|
|
||||||
late VideoPlayerController controller;
|
|
||||||
if (asset.isLocal && asset.livePhotoVideoId == null) {
|
|
||||||
// Use a local file for the video player controller
|
|
||||||
final file = await asset.local!.file;
|
|
||||||
if (file == null) {
|
|
||||||
throw Exception('No file found for the video');
|
|
||||||
}
|
|
||||||
controller = VideoPlayerController.file(file);
|
|
||||||
} else {
|
|
||||||
// Use a network URL for the video player controller
|
|
||||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
|
||||||
final String videoUrl = asset.livePhotoVideoId != null
|
|
||||||
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
|
|
||||||
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
|
|
||||||
|
|
||||||
final url = Uri.parse(videoUrl);
|
|
||||||
controller = VideoPlayerController.networkUrl(
|
|
||||||
url,
|
|
||||||
httpHeaders: ApiService.getRequestHeaders(),
|
|
||||||
videoPlayerOptions: asset.livePhotoVideoId != null
|
|
||||||
? VideoPlayerOptions(mixWithOthers: true)
|
|
||||||
: VideoPlayerOptions(mixWithOthers: false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await controller.initialize();
|
|
||||||
|
|
||||||
ref.onDispose(() {
|
|
||||||
controller.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
return controller;
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'video_player_controller_provider.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// RiverpodGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
String _$videoPlayerControllerHash() =>
|
|
||||||
r'84b2961cc2aeaf9d03255dbf9b9484619d0c24f5';
|
|
||||||
|
|
||||||
/// Copied from Dart SDK
|
|
||||||
class _SystemHash {
|
|
||||||
_SystemHash._();
|
|
||||||
|
|
||||||
static int combine(int hash, int value) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
hash = 0x1fffffff & (hash + value);
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
|
||||||
return hash ^ (hash >> 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int finish(int hash) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
hash = hash ^ (hash >> 11);
|
|
||||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
@ProviderFor(videoPlayerController)
|
|
||||||
const videoPlayerControllerProvider = VideoPlayerControllerFamily();
|
|
||||||
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
class VideoPlayerControllerFamily
|
|
||||||
extends Family<AsyncValue<VideoPlayerController>> {
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
const VideoPlayerControllerFamily();
|
|
||||||
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
VideoPlayerControllerProvider call({
|
|
||||||
required Asset asset,
|
|
||||||
}) {
|
|
||||||
return VideoPlayerControllerProvider(
|
|
||||||
asset: asset,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
VideoPlayerControllerProvider getProviderOverride(
|
|
||||||
covariant VideoPlayerControllerProvider provider,
|
|
||||||
) {
|
|
||||||
return call(
|
|
||||||
asset: provider.asset,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
|
||||||
|
|
||||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
|
||||||
_allTransitiveDependencies;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String? get name => r'videoPlayerControllerProvider';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
class VideoPlayerControllerProvider
|
|
||||||
extends AutoDisposeFutureProvider<VideoPlayerController> {
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
VideoPlayerControllerProvider({
|
|
||||||
required Asset asset,
|
|
||||||
}) : this._internal(
|
|
||||||
(ref) => videoPlayerController(
|
|
||||||
ref as VideoPlayerControllerRef,
|
|
||||||
asset: asset,
|
|
||||||
),
|
|
||||||
from: videoPlayerControllerProvider,
|
|
||||||
name: r'videoPlayerControllerProvider',
|
|
||||||
debugGetCreateSourceHash:
|
|
||||||
const bool.fromEnvironment('dart.vm.product')
|
|
||||||
? null
|
|
||||||
: _$videoPlayerControllerHash,
|
|
||||||
dependencies: VideoPlayerControllerFamily._dependencies,
|
|
||||||
allTransitiveDependencies:
|
|
||||||
VideoPlayerControllerFamily._allTransitiveDependencies,
|
|
||||||
asset: asset,
|
|
||||||
);
|
|
||||||
|
|
||||||
VideoPlayerControllerProvider._internal(
|
|
||||||
super._createNotifier, {
|
|
||||||
required super.name,
|
|
||||||
required super.dependencies,
|
|
||||||
required super.allTransitiveDependencies,
|
|
||||||
required super.debugGetCreateSourceHash,
|
|
||||||
required super.from,
|
|
||||||
required this.asset,
|
|
||||||
}) : super.internal();
|
|
||||||
|
|
||||||
final Asset asset;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Override overrideWith(
|
|
||||||
FutureOr<VideoPlayerController> Function(VideoPlayerControllerRef provider)
|
|
||||||
create,
|
|
||||||
) {
|
|
||||||
return ProviderOverride(
|
|
||||||
origin: this,
|
|
||||||
override: VideoPlayerControllerProvider._internal(
|
|
||||||
(ref) => create(ref as VideoPlayerControllerRef),
|
|
||||||
from: from,
|
|
||||||
name: null,
|
|
||||||
dependencies: null,
|
|
||||||
allTransitiveDependencies: null,
|
|
||||||
debugGetCreateSourceHash: null,
|
|
||||||
asset: asset,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AutoDisposeFutureProviderElement<VideoPlayerController> createElement() {
|
|
||||||
return _VideoPlayerControllerProviderElement(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
return other is VideoPlayerControllerProvider && other.asset == asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode {
|
|
||||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
|
||||||
hash = _SystemHash.combine(hash, asset.hashCode);
|
|
||||||
|
|
||||||
return _SystemHash.finish(hash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mixin VideoPlayerControllerRef
|
|
||||||
on AutoDisposeFutureProviderRef<VideoPlayerController> {
|
|
||||||
/// The parameter `asset` of this provider.
|
|
||||||
Asset get asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VideoPlayerControllerProviderElement
|
|
||||||
extends AutoDisposeFutureProviderElement<VideoPlayerController>
|
|
||||||
with VideoPlayerControllerRef {
|
|
||||||
_VideoPlayerControllerProviderElement(super.provider);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Asset get asset => (origin as VideoPlayerControllerProvider).asset;
|
|
||||||
}
|
|
||||||
// ignore_for_file: type=lint
|
|
||||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
|
||||||
class VideoPlaybackControls {
|
class VideoPlaybackControls {
|
||||||
VideoPlaybackControls({
|
const VideoPlaybackControls({
|
||||||
required this.position,
|
required this.position,
|
||||||
required this.mute,
|
required this.mute,
|
||||||
required this.pause,
|
required this.pause,
|
||||||
@@ -17,15 +18,14 @@ final videoPlayerControlsProvider =
|
|||||||
return VideoPlayerControls(ref);
|
return VideoPlayerControls(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const videoPlayerControlsDefault = VideoPlaybackControls(
|
||||||
|
position: 0,
|
||||||
|
pause: false,
|
||||||
|
mute: false,
|
||||||
|
);
|
||||||
|
|
||||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||||
VideoPlayerControls(this.ref)
|
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
||||||
: super(
|
|
||||||
VideoPlaybackControls(
|
|
||||||
position: 0,
|
|
||||||
pause: false,
|
|
||||||
mute: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
@@ -36,17 +36,18 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
state = VideoPlaybackControls(
|
state = videoPlayerControlsDefault;
|
||||||
position: 0,
|
|
||||||
pause: false,
|
|
||||||
mute: false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
double get position => state.position;
|
double get position => state.position;
|
||||||
bool get mute => state.mute;
|
bool get mute => state.mute;
|
||||||
|
bool get paused => state.pause;
|
||||||
|
|
||||||
set position(double value) {
|
set position(double value) {
|
||||||
|
if (state.position == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: value,
|
position: value,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
@@ -55,6 +56,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set mute(bool value) {
|
set mute(bool value) {
|
||||||
|
if (state.mute == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: state.position,
|
position: state.position,
|
||||||
mute: value,
|
mute: value,
|
||||||
@@ -71,6 +76,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void pause() {
|
void pause() {
|
||||||
|
if (state.pause) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: state.position,
|
position: state.position,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
@@ -79,6 +88,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void play() {
|
void play() {
|
||||||
|
if (!state.pause) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: state.position,
|
position: state.position,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
@@ -95,16 +108,11 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void restart() {
|
void restart() {
|
||||||
state = VideoPlaybackControls(
|
|
||||||
position: 0,
|
|
||||||
mute: state.mute,
|
|
||||||
pause: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: 0,
|
position: 0,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
pause: false,
|
pause: false,
|
||||||
);
|
);
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).position = Duration.zero;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
|
|
||||||
enum VideoPlaybackState {
|
enum VideoPlaybackState {
|
||||||
initializing,
|
initializing,
|
||||||
@@ -22,56 +22,66 @@ class VideoPlaybackValue {
|
|||||||
/// The volume of the video
|
/// The volume of the video
|
||||||
final double volume;
|
final double volume;
|
||||||
|
|
||||||
VideoPlaybackValue({
|
const VideoPlaybackValue({
|
||||||
required this.position,
|
required this.position,
|
||||||
required this.duration,
|
required this.duration,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.volume,
|
required this.volume,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
|
factory VideoPlaybackValue.fromNativeController(
|
||||||
final video = controller?.value;
|
NativeVideoPlayerController controller,
|
||||||
late VideoPlaybackState s;
|
) {
|
||||||
if (video == null) {
|
final playbackInfo = controller.playbackInfo;
|
||||||
s = VideoPlaybackState.initializing;
|
final videoInfo = controller.videoInfo;
|
||||||
} else if (video.isCompleted) {
|
|
||||||
s = VideoPlaybackState.completed;
|
if (playbackInfo == null || videoInfo == null) {
|
||||||
} else if (video.isPlaying) {
|
return videoPlaybackValueDefault;
|
||||||
s = VideoPlaybackState.playing;
|
|
||||||
} else if (video.isBuffering) {
|
|
||||||
s = VideoPlaybackState.buffering;
|
|
||||||
} else {
|
|
||||||
s = VideoPlaybackState.paused;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final VideoPlaybackState status = switch (playbackInfo.status) {
|
||||||
|
PlaybackStatus.playing => VideoPlaybackState.playing,
|
||||||
|
PlaybackStatus.paused => VideoPlaybackState.paused,
|
||||||
|
PlaybackStatus.stopped => VideoPlaybackState.completed,
|
||||||
|
};
|
||||||
|
|
||||||
return VideoPlaybackValue(
|
return VideoPlaybackValue(
|
||||||
position: video?.position ?? Duration.zero,
|
position: Duration(seconds: playbackInfo.position),
|
||||||
duration: video?.duration ?? Duration.zero,
|
duration: Duration(seconds: videoInfo.duration),
|
||||||
state: s,
|
state: status,
|
||||||
volume: video?.volume ?? 0.0,
|
volume: playbackInfo.volume,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
factory VideoPlaybackValue.uninitialized() {
|
VideoPlaybackValue copyWith({
|
||||||
|
Duration? position,
|
||||||
|
Duration? duration,
|
||||||
|
VideoPlaybackState? state,
|
||||||
|
double? volume,
|
||||||
|
}) {
|
||||||
return VideoPlaybackValue(
|
return VideoPlaybackValue(
|
||||||
position: Duration.zero,
|
position: position ?? this.position,
|
||||||
duration: Duration.zero,
|
duration: duration ?? this.duration,
|
||||||
state: VideoPlaybackState.initializing,
|
state: state ?? this.state,
|
||||||
volume: 0.0,
|
volume: volume ?? this.volume,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
|
||||||
|
position: Duration.zero,
|
||||||
|
duration: Duration.zero,
|
||||||
|
state: VideoPlaybackState.initializing,
|
||||||
|
volume: 0.0,
|
||||||
|
);
|
||||||
|
|
||||||
final videoPlaybackValueProvider =
|
final videoPlaybackValueProvider =
|
||||||
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
||||||
return VideoPlaybackValueState(ref);
|
return VideoPlaybackValueState(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||||
VideoPlaybackValueState(this.ref)
|
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
|
||||||
: super(
|
|
||||||
VideoPlaybackValue.uninitialized(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
@@ -82,6 +92,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set position(Duration value) {
|
set position(Duration value) {
|
||||||
|
if (state.position == value) return;
|
||||||
state = VideoPlaybackValue(
|
state = VideoPlaybackValue(
|
||||||
position: value,
|
position: value,
|
||||||
duration: state.duration,
|
duration: state.duration,
|
||||||
@@ -89,4 +100,18 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|||||||
volume: state.volume,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
_ref;
|
_ref;
|
||||||
final _log = Logger("AuthenticationNotifier");
|
final _log = Logger("AuthenticationNotifier");
|
||||||
|
|
||||||
|
static const Duration _timeoutDuration = Duration(seconds: 7);
|
||||||
|
|
||||||
Future<bool> login(
|
Future<bool> login(
|
||||||
String email,
|
String email,
|
||||||
String password,
|
String password,
|
||||||
@@ -102,12 +104,15 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
|
|
||||||
await _apiService.authenticationApi
|
await _apiService.authenticationApi
|
||||||
.logout()
|
.logout()
|
||||||
|
.timeout(_timeoutDuration)
|
||||||
.then((_) => log.info("Logout was successful for $userEmail"))
|
.then((_) => log.info("Logout was successful for $userEmail"))
|
||||||
.onError(
|
.onError(
|
||||||
(error, stackTrace) =>
|
(error, stackTrace) =>
|
||||||
log.severe("Logout failed for $userEmail", error, stackTrace),
|
log.severe("Logout failed for $userEmail", error, stackTrace),
|
||||||
);
|
);
|
||||||
|
} catch (e, stack) {
|
||||||
|
log.severe('Logout failed', e, stack);
|
||||||
|
} finally {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
clearAssetsAndAlbums(_db),
|
clearAssetsAndAlbums(_db),
|
||||||
Store.delete(StoreKey.currentUser),
|
Store.delete(StoreKey.currentUser),
|
||||||
@@ -125,8 +130,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
shouldChangePassword: false,
|
shouldChangePassword: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
);
|
);
|
||||||
} catch (e, stack) {
|
|
||||||
log.severe('Logout failed', e, stack);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,10 +171,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
UserPreferencesResponseDto? userPreferences;
|
UserPreferencesResponseDto? userPreferences;
|
||||||
try {
|
try {
|
||||||
final responses = await Future.wait([
|
final responses = await Future.wait([
|
||||||
_apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)),
|
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
|
||||||
_apiService.usersApi
|
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
|
||||||
.getMyPreferences()
|
|
||||||
.timeout(const Duration(seconds: 7)),
|
|
||||||
]);
|
]);
|
||||||
userResponse = responses[0] as UserAdminResponseDto;
|
userResponse = responses[0] as UserAdminResponseDto;
|
||||||
userPreferences = responses[1] as UserPreferencesResponseDto;
|
userPreferences = responses[1] as UserPreferencesResponseDto;
|
||||||
|
|||||||
@@ -7,14 +7,21 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
|
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
|
||||||
|
|
||||||
/// The local image provider for an asset
|
/// The local image provider for an asset
|
||||||
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
||||||
final Asset asset;
|
final Asset asset;
|
||||||
|
// only used for videos
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final Logger log = Logger('ImmichLocalImageProvider');
|
||||||
|
|
||||||
ImmichLocalImageProvider({
|
ImmichLocalImageProvider({
|
||||||
required this.asset,
|
required this.asset,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
||||||
|
|
||||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||||
@@ -42,38 +49,57 @@ class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
|||||||
|
|
||||||
// Streams in each stage of the image as we ask for it
|
// Streams in each stage of the image as we ask for it
|
||||||
Stream<ui.Codec> _codec(
|
Stream<ui.Codec> _codec(
|
||||||
Asset key,
|
Asset asset,
|
||||||
ImageDecoderCallback decode,
|
ImageDecoderCallback decode,
|
||||||
StreamController<ImageChunkEvent> chunkEvents,
|
StreamController<ImageChunkEvent> chunkEvents,
|
||||||
) async* {
|
) async* {
|
||||||
// Load a small thumbnail
|
ui.ImmutableBuffer? buffer;
|
||||||
final thumbBytes = await asset.local?.thumbnailDataWithSize(
|
try {
|
||||||
const ThumbnailSize.square(256),
|
final local = asset.local;
|
||||||
quality: 80,
|
if (local == null) {
|
||||||
);
|
throw StateError('Asset ${asset.fileName} has no local data');
|
||||||
if (thumbBytes != null) {
|
|
||||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
|
||||||
final codec = await decode(buffer);
|
|
||||||
yield codec;
|
|
||||||
} else {
|
|
||||||
debugPrint("Loading thumb for ${asset.fileName} failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asset.isImage) {
|
|
||||||
final File? file = await asset.local?.originFile;
|
|
||||||
if (file == null) {
|
|
||||||
throw StateError("Opening file for asset ${asset.fileName} failed");
|
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
|
|
||||||
final codec = await decode(buffer);
|
|
||||||
yield codec;
|
|
||||||
} catch (error) {
|
|
||||||
throw StateError("Loading asset ${asset.fileName} failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chunkEvents.close();
|
var thumbBytes = await local
|
||||||
|
.thumbnailDataWithSize(const ThumbnailSize.square(256), quality: 80);
|
||||||
|
if (thumbBytes == null) {
|
||||||
|
throw StateError("Loading thumbnail for ${asset.fileName} failed");
|
||||||
|
}
|
||||||
|
buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||||
|
thumbBytes = null;
|
||||||
|
yield await decode(buffer);
|
||||||
|
buffer = null;
|
||||||
|
|
||||||
|
switch (asset.type) {
|
||||||
|
case AssetType.image:
|
||||||
|
final File? file = await local.originFile;
|
||||||
|
if (file == null) {
|
||||||
|
throw StateError("Opening file for asset ${asset.fileName} failed");
|
||||||
|
}
|
||||||
|
buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
|
||||||
|
yield await decode(buffer);
|
||||||
|
buffer = null;
|
||||||
|
break;
|
||||||
|
case AssetType.video:
|
||||||
|
final size = ThumbnailSize(width.ceil(), height.ceil());
|
||||||
|
thumbBytes = await local.thumbnailDataWithSize(size);
|
||||||
|
if (thumbBytes == null) {
|
||||||
|
throw StateError("Failed to load preview for ${asset.fileName}");
|
||||||
|
}
|
||||||
|
buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||||
|
thumbBytes = null;
|
||||||
|
yield await decode(buffer);
|
||||||
|
buffer = null;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw StateError('Unsupported asset type ${asset.type}');
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
log.severe('Error loading local image ${asset.fileName}', error, stack);
|
||||||
|
buffer?.dispose();
|
||||||
|
} finally {
|
||||||
|
chunkEvents.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
|
|||||||
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
|
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
|
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
|
||||||
import 'package:immich_mobile/pages/albums/albums.page.dart';
|
import 'package:immich_mobile/pages/albums/albums.page.dart';
|
||||||
|
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
||||||
import 'package:immich_mobile/pages/library/local_albums.page.dart';
|
import 'package:immich_mobile/pages/library/local_albums.page.dart';
|
||||||
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
|
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
|
||||||
import 'package:immich_mobile/pages/library/places/places_collection.page.dart';
|
import 'package:immich_mobile/pages/library/places/places_collection.page.dart';
|
||||||
@@ -272,6 +273,10 @@ class AppRouter extends RootStackRouter {
|
|||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||||
),
|
),
|
||||||
|
AutoRoute(
|
||||||
|
page: NativeVideoViewerRoute.page,
|
||||||
|
guards: [_authGuard, _duplicateGuard],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1079,6 +1079,64 @@ class MemoryRouteArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [NativeVideoViewerPage]
|
||||||
|
class NativeVideoViewerRoute extends PageRouteInfo<NativeVideoViewerRouteArgs> {
|
||||||
|
NativeVideoViewerRoute({
|
||||||
|
Key? key,
|
||||||
|
required Asset asset,
|
||||||
|
required Widget image,
|
||||||
|
bool showControls = true,
|
||||||
|
List<PageRouteInfo>? children,
|
||||||
|
}) : super(
|
||||||
|
NativeVideoViewerRoute.name,
|
||||||
|
args: NativeVideoViewerRouteArgs(
|
||||||
|
key: key,
|
||||||
|
asset: asset,
|
||||||
|
image: image,
|
||||||
|
showControls: showControls,
|
||||||
|
),
|
||||||
|
initialChildren: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'NativeVideoViewerRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
final args = data.argsAs<NativeVideoViewerRouteArgs>();
|
||||||
|
return NativeVideoViewerPage(
|
||||||
|
key: args.key,
|
||||||
|
asset: args.asset,
|
||||||
|
image: args.image,
|
||||||
|
showControls: args.showControls,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class NativeVideoViewerRouteArgs {
|
||||||
|
const NativeVideoViewerRouteArgs({
|
||||||
|
this.key,
|
||||||
|
required this.asset,
|
||||||
|
required this.image,
|
||||||
|
this.showControls = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
final Asset asset;
|
||||||
|
|
||||||
|
final Widget image;
|
||||||
|
|
||||||
|
final bool showControls;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [PartnerDetailPage]
|
/// [PartnerDetailPage]
|
||||||
class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
|
class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -402,4 +403,29 @@ class AssetService {
|
|||||||
|
|
||||||
return exifInfo?.description ?? "";
|
return exifInfo?.description ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<double> getAspectRatio(Asset asset) async {
|
||||||
|
// platform_manager always returns 0 for orientation on iOS, so only prefer it on Android
|
||||||
|
if (asset.isLocal && Platform.isAndroid) {
|
||||||
|
await asset.localAsync;
|
||||||
|
} else if (asset.isRemote) {
|
||||||
|
asset = await loadExif(asset);
|
||||||
|
} else if (asset.isLocal) {
|
||||||
|
await asset.localAsync;
|
||||||
|
}
|
||||||
|
|
||||||
|
final aspectRatio = asset.aspectRatio;
|
||||||
|
if (aspectRatio != null) {
|
||||||
|
return aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
final width = asset.width;
|
||||||
|
final height = asset.height;
|
||||||
|
if (width != null && height != null) {
|
||||||
|
// we don't know the orientation, so assume it's normal
|
||||||
|
return width / height;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,52 @@ import 'dart:async';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
/// Used to debounce function calls with the [interval] provided.
|
/// Used to debounce function calls with the [interval] provided.
|
||||||
|
/// If [maxWaitTime] is provided, the first [run] call as well as the next call since [maxWaitTime] has passed will be immediately executed, even if [interval] is not satisfied.
|
||||||
class Debouncer {
|
class Debouncer {
|
||||||
Debouncer({required this.interval});
|
Debouncer({required this.interval, this.maxWaitTime});
|
||||||
final Duration interval;
|
final Duration interval;
|
||||||
|
final Duration? maxWaitTime;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
FutureOr<void> Function()? _lastAction;
|
FutureOr<void> Function()? _lastAction;
|
||||||
|
DateTime? _lastActionTime;
|
||||||
|
Future<void>? _actionFuture;
|
||||||
|
|
||||||
void run(FutureOr<void> Function() action) {
|
void run(FutureOr<void> Function() action) {
|
||||||
_lastAction = action;
|
_lastAction = action;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
|
||||||
|
if (maxWaitTime != null &&
|
||||||
|
// _actionFuture == null && // TODO: should this check be here?
|
||||||
|
(_lastActionTime == null ||
|
||||||
|
DateTime.now().difference(_lastActionTime!) > maxWaitTime!)) {
|
||||||
|
_callAndRest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_timer = Timer(interval, _callAndRest);
|
_timer = Timer(interval, _callAndRest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void>? drain() {
|
||||||
|
if (_timer != null && _timer!.isActive) {
|
||||||
|
_timer!.cancel();
|
||||||
|
if (_lastAction != null) {
|
||||||
|
_callAndRest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _actionFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
void _callAndRest() {
|
void _callAndRest() {
|
||||||
_lastAction?.call();
|
_lastActionTime = DateTime.now();
|
||||||
|
final action = _lastAction;
|
||||||
|
_lastAction = null;
|
||||||
|
|
||||||
|
final result = action!();
|
||||||
|
if (result is Future) {
|
||||||
|
_actionFuture = result.whenComplete(() {
|
||||||
|
_actionFuture = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
_timer = null;
|
_timer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,31 +56,48 @@ class Debouncer {
|
|||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
_lastAction = null;
|
_lastAction = null;
|
||||||
|
_lastActionTime = null;
|
||||||
|
_actionFuture = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get isActive =>
|
||||||
|
_actionFuture != null || (_timer != null && _timer!.isActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a
|
/// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a
|
||||||
/// default interval of 300ms is used to debounce the function calls
|
/// default interval of 300ms is used to debounce the function calls
|
||||||
Debouncer useDebouncer({
|
Debouncer useDebouncer({
|
||||||
Duration interval = const Duration(milliseconds: 300),
|
Duration interval = const Duration(milliseconds: 300),
|
||||||
|
Duration? maxWaitTime,
|
||||||
List<Object?>? keys,
|
List<Object?>? keys,
|
||||||
}) =>
|
}) =>
|
||||||
use(_DebouncerHook(interval: interval, keys: keys));
|
use(
|
||||||
|
_DebouncerHook(
|
||||||
|
interval: interval,
|
||||||
|
maxWaitTime: maxWaitTime,
|
||||||
|
keys: keys,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
class _DebouncerHook extends Hook<Debouncer> {
|
class _DebouncerHook extends Hook<Debouncer> {
|
||||||
const _DebouncerHook({
|
const _DebouncerHook({
|
||||||
required this.interval,
|
required this.interval,
|
||||||
|
this.maxWaitTime,
|
||||||
super.keys,
|
super.keys,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Duration interval;
|
final Duration interval;
|
||||||
|
final Duration? maxWaitTime;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
|
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
|
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
|
||||||
late final debouncer = Debouncer(interval: hook.interval);
|
late final debouncer = Debouncer(
|
||||||
|
interval: hook.interval,
|
||||||
|
maxWaitTime: hook.maxWaitTime,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Debouncer build(_) => debouncer;
|
Debouncer build(_) => debouncer;
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import 'package:chewie/chewie.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
|
|
||||||
/// Provides the initialized video player controller
|
|
||||||
/// If the asset is local, use the local file
|
|
||||||
/// Otherwise, use a video player with a URL
|
|
||||||
ChewieController useChewieController({
|
|
||||||
required VideoPlayerController controller,
|
|
||||||
EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
|
|
||||||
bottom: 100,
|
|
||||||
),
|
|
||||||
bool showOptions = true,
|
|
||||||
bool showControlsOnInitialize = false,
|
|
||||||
bool autoPlay = true,
|
|
||||||
bool allowFullScreen = false,
|
|
||||||
bool allowedScreenSleep = false,
|
|
||||||
bool showControls = true,
|
|
||||||
bool loopVideo = false,
|
|
||||||
Widget? customControls,
|
|
||||||
Widget? placeholder,
|
|
||||||
Duration hideControlsTimer = const Duration(seconds: 1),
|
|
||||||
VoidCallback? onPlaying,
|
|
||||||
VoidCallback? onPaused,
|
|
||||||
VoidCallback? onVideoEnded,
|
|
||||||
}) {
|
|
||||||
return use(
|
|
||||||
_ChewieControllerHook(
|
|
||||||
controller: controller,
|
|
||||||
placeholder: placeholder,
|
|
||||||
showOptions: showOptions,
|
|
||||||
controlsSafeAreaMinimum: controlsSafeAreaMinimum,
|
|
||||||
autoPlay: autoPlay,
|
|
||||||
allowFullScreen: allowFullScreen,
|
|
||||||
customControls: customControls,
|
|
||||||
hideControlsTimer: hideControlsTimer,
|
|
||||||
showControlsOnInitialize: showControlsOnInitialize,
|
|
||||||
showControls: showControls,
|
|
||||||
loopVideo: loopVideo,
|
|
||||||
allowedScreenSleep: allowedScreenSleep,
|
|
||||||
onPlaying: onPlaying,
|
|
||||||
onPaused: onPaused,
|
|
||||||
onVideoEnded: onVideoEnded,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ChewieControllerHook extends Hook<ChewieController> {
|
|
||||||
final VideoPlayerController controller;
|
|
||||||
final EdgeInsets controlsSafeAreaMinimum;
|
|
||||||
final bool showOptions;
|
|
||||||
final bool showControlsOnInitialize;
|
|
||||||
final bool autoPlay;
|
|
||||||
final bool allowFullScreen;
|
|
||||||
final bool allowedScreenSleep;
|
|
||||||
final bool showControls;
|
|
||||||
final bool loopVideo;
|
|
||||||
final Widget? customControls;
|
|
||||||
final Widget? placeholder;
|
|
||||||
final Duration hideControlsTimer;
|
|
||||||
final VoidCallback? onPlaying;
|
|
||||||
final VoidCallback? onPaused;
|
|
||||||
final VoidCallback? onVideoEnded;
|
|
||||||
|
|
||||||
const _ChewieControllerHook({
|
|
||||||
required this.controller,
|
|
||||||
this.controlsSafeAreaMinimum = const EdgeInsets.only(
|
|
||||||
bottom: 100,
|
|
||||||
),
|
|
||||||
this.showOptions = true,
|
|
||||||
this.showControlsOnInitialize = false,
|
|
||||||
this.autoPlay = true,
|
|
||||||
this.allowFullScreen = false,
|
|
||||||
this.allowedScreenSleep = false,
|
|
||||||
this.showControls = true,
|
|
||||||
this.loopVideo = false,
|
|
||||||
this.customControls,
|
|
||||||
this.placeholder,
|
|
||||||
this.hideControlsTimer = const Duration(seconds: 3),
|
|
||||||
this.onPlaying,
|
|
||||||
this.onPaused,
|
|
||||||
this.onVideoEnded,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
createState() => _ChewieControllerHookState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ChewieControllerHookState
|
|
||||||
extends HookState<ChewieController, _ChewieControllerHook> {
|
|
||||||
late ChewieController chewieController = ChewieController(
|
|
||||||
videoPlayerController: hook.controller,
|
|
||||||
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
|
|
||||||
showOptions: hook.showOptions,
|
|
||||||
showControlsOnInitialize: hook.showControlsOnInitialize,
|
|
||||||
autoPlay: hook.autoPlay,
|
|
||||||
allowFullScreen: hook.allowFullScreen,
|
|
||||||
allowedScreenSleep: hook.allowedScreenSleep,
|
|
||||||
showControls: hook.showControls,
|
|
||||||
looping: hook.loopVideo,
|
|
||||||
customControls: hook.customControls,
|
|
||||||
placeholder: hook.placeholder,
|
|
||||||
hideControlsTimer: hook.hideControlsTimer,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
chewieController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
ChewieController build(BuildContext context) {
|
|
||||||
return chewieController;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
/// Initializes the chewie controller and video player controller
|
|
||||||
Future<void> _initialize() async {
|
|
||||||
if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
|
|
||||||
// Use a local file for the video player controller
|
|
||||||
final file = await hook.asset.local!.file;
|
|
||||||
if (file == null) {
|
|
||||||
throw Exception('No file found for the video');
|
|
||||||
}
|
|
||||||
videoPlayerController = VideoPlayerController.file(file);
|
|
||||||
} else {
|
|
||||||
// Use a network URL for the video player controller
|
|
||||||
final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
|
|
||||||
final String videoUrl = hook.asset.livePhotoVideoId != null
|
|
||||||
? '$serverEndpoint/assets/${hook.asset.livePhotoVideoId}/video/playback'
|
|
||||||
: '$serverEndpoint/assets/${hook.asset.remoteId}/video/playback';
|
|
||||||
|
|
||||||
final url = Uri.parse(videoUrl);
|
|
||||||
final accessToken = store.Store.get(StoreKey.accessToken);
|
|
||||||
|
|
||||||
videoPlayerController = VideoPlayerController.networkUrl(
|
|
||||||
url,
|
|
||||||
httpHeaders: {"x-immich-user-token": accessToken},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await videoPlayerController!.initialize();
|
|
||||||
|
|
||||||
chewieController = ChewieController(
|
|
||||||
videoPlayerController: videoPlayerController!,
|
|
||||||
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
|
|
||||||
showOptions: hook.showOptions,
|
|
||||||
showControlsOnInitialize: hook.showControlsOnInitialize,
|
|
||||||
autoPlay: hook.autoPlay,
|
|
||||||
allowFullScreen: hook.allowFullScreen,
|
|
||||||
allowedScreenSleep: hook.allowedScreenSleep,
|
|
||||||
showControls: hook.showControls,
|
|
||||||
customControls: hook.customControls,
|
|
||||||
placeholder: hook.placeholder,
|
|
||||||
hideControlsTimer: hook.hideControlsTimer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
|
||||||
class ImmichTheme {
|
class ImmichTheme {
|
||||||
ColorScheme light;
|
final ColorScheme light;
|
||||||
ColorScheme dark;
|
final ColorScheme dark;
|
||||||
|
|
||||||
ImmichTheme({required this.light, required this.dark});
|
const ImmichTheme({required this.light, required this.dark});
|
||||||
}
|
}
|
||||||
|
|
||||||
ImmichTheme? _immichDynamicTheme;
|
ImmichTheme? _immichDynamicTheme;
|
||||||
@@ -151,7 +151,7 @@ ThemeData getThemeData({required ColorScheme colorScheme}) {
|
|||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
brightness: colorScheme.brightness,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
primaryColor: primaryColor,
|
primaryColor: primaryColor,
|
||||||
hintColor: colorScheme.onSurfaceSecondary,
|
hintColor: colorScheme.onSurfaceSecondary,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/utils/db.dart';
|
import 'package:immich_mobile/utils/db.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
const int targetVersion = 6;
|
const int targetVersion = 7;
|
||||||
|
|
||||||
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
||||||
final int version = Store.get(StoreKey.version, 1);
|
final int version = Store.get(StoreKey.version, 1);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
/// Throttles function calls with the [interval] provided.
|
/// Throttles function calls with the [interval] provided.
|
||||||
@@ -10,12 +8,15 @@ class Throttler {
|
|||||||
|
|
||||||
Throttler({required this.interval});
|
Throttler({required this.interval});
|
||||||
|
|
||||||
void run(FutureOr<void> Function() action) {
|
T? run<T>(T Function() action) {
|
||||||
if (_lastActionTime == null ||
|
if (_lastActionTime == null ||
|
||||||
(DateTime.now().difference(_lastActionTime!) > interval)) {
|
(DateTime.now().difference(_lastActionTime!) > interval)) {
|
||||||
action();
|
final response = action();
|
||||||
_lastActionTime = DateTime.now();
|
_lastActionTime = DateTime.now();
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ 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/extensions/collection_extensions.dart';
|
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
|
import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
|
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||||
@@ -89,6 +91,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
|||||||
ScrollOffsetController();
|
ScrollOffsetController();
|
||||||
final ItemPositionsListener _itemPositionsListener =
|
final ItemPositionsListener _itemPositionsListener =
|
||||||
ItemPositionsListener.create();
|
ItemPositionsListener.create();
|
||||||
|
late final KeepAliveLink currentAssetLink;
|
||||||
|
|
||||||
/// The timestamp when the haptic feedback was last invoked
|
/// The timestamp when the haptic feedback was last invoked
|
||||||
int _hapticFeedbackTS = 0;
|
int _hapticFeedbackTS = 0;
|
||||||
@@ -201,6 +204,12 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
|||||||
allAssetsSelected: _allAssetsSelected,
|
allAssetsSelected: _allAssetsSelected,
|
||||||
showStack: widget.showStack,
|
showStack: widget.showStack,
|
||||||
heroOffset: widget.heroOffset,
|
heroOffset: widget.heroOffset,
|
||||||
|
onAssetTap: (asset) {
|
||||||
|
ref.read(currentAssetProvider.notifier).set(asset);
|
||||||
|
if (asset.isVideo) {
|
||||||
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +357,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive();
|
||||||
scrollToTopNotifierProvider.addListener(_scrollToTop);
|
scrollToTopNotifierProvider.addListener(_scrollToTop);
|
||||||
scrollToDateNotifierProvider.addListener(_scrollToDate);
|
scrollToDateNotifierProvider.addListener(_scrollToDate);
|
||||||
|
|
||||||
@@ -369,6 +379,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
|||||||
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
||||||
}
|
}
|
||||||
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
|
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
|
||||||
|
currentAssetLink.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,12 +606,13 @@ class _Section extends StatelessWidget {
|
|||||||
final RenderList renderList;
|
final RenderList renderList;
|
||||||
final bool selectionActive;
|
final bool selectionActive;
|
||||||
final bool dynamicLayout;
|
final bool dynamicLayout;
|
||||||
final Function(List<Asset>) selectAssets;
|
final void Function(List<Asset>) selectAssets;
|
||||||
final Function(List<Asset>) deselectAssets;
|
final void Function(List<Asset>) deselectAssets;
|
||||||
final bool Function(List<Asset>) allAssetsSelected;
|
final bool Function(List<Asset>) allAssetsSelected;
|
||||||
final bool showStack;
|
final bool showStack;
|
||||||
final int heroOffset;
|
final int heroOffset;
|
||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
|
final void Function(Asset) onAssetTap;
|
||||||
|
|
||||||
const _Section({
|
const _Section({
|
||||||
required this.section,
|
required this.section,
|
||||||
@@ -618,6 +630,7 @@ class _Section extends StatelessWidget {
|
|||||||
required this.showStack,
|
required this.showStack,
|
||||||
required this.heroOffset,
|
required this.heroOffset,
|
||||||
required this.showStorageIndicator,
|
required this.showStorageIndicator,
|
||||||
|
required this.onAssetTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -683,6 +696,7 @@ class _Section extends StatelessWidget {
|
|||||||
selectionActive: selectionActive,
|
selectionActive: selectionActive,
|
||||||
onSelect: (asset) => selectAssets([asset]),
|
onSelect: (asset) => selectAssets([asset]),
|
||||||
onDeselect: (asset) => deselectAssets([asset]),
|
onDeselect: (asset) => deselectAssets([asset]),
|
||||||
|
onAssetTap: onAssetTap,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -724,9 +738,9 @@ class _Title extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final List<Asset> assets;
|
final List<Asset> assets;
|
||||||
final bool selectionActive;
|
final bool selectionActive;
|
||||||
final Function(List<Asset>) selectAssets;
|
final void Function(List<Asset>) selectAssets;
|
||||||
final Function(List<Asset>) deselectAssets;
|
final void Function(List<Asset>) deselectAssets;
|
||||||
final Function(List<Asset>) allAssetsSelected;
|
final bool Function(List<Asset>) allAssetsSelected;
|
||||||
|
|
||||||
const _Title({
|
const _Title({
|
||||||
required this.title,
|
required this.title,
|
||||||
@@ -765,8 +779,9 @@ class _AssetRow extends StatelessWidget {
|
|||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
final int heroOffset;
|
final int heroOffset;
|
||||||
final bool showStack;
|
final bool showStack;
|
||||||
final Function(Asset)? onSelect;
|
final void Function(Asset) onAssetTap;
|
||||||
final Function(Asset)? onDeselect;
|
final void Function(Asset)? onSelect;
|
||||||
|
final void Function(Asset)? onDeselect;
|
||||||
final bool isSelectionActive;
|
final bool isSelectionActive;
|
||||||
|
|
||||||
const _AssetRow({
|
const _AssetRow({
|
||||||
@@ -786,6 +801,7 @@ class _AssetRow extends StatelessWidget {
|
|||||||
required this.showStack,
|
required this.showStack,
|
||||||
required this.isSelectionActive,
|
required this.isSelectionActive,
|
||||||
required this.selectedAssets,
|
required this.selectedAssets,
|
||||||
|
required this.onAssetTap,
|
||||||
this.onSelect,
|
this.onSelect,
|
||||||
this.onDeselect,
|
this.onDeselect,
|
||||||
});
|
});
|
||||||
@@ -838,6 +854,8 @@ class _AssetRow extends StatelessWidget {
|
|||||||
onSelect?.call(asset);
|
onSelect?.call(asset);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
final asset = renderList.loadAsset(absoluteOffset + index);
|
||||||
|
onAssetTap(asset);
|
||||||
context.pushRoute(
|
context.pushRoute(
|
||||||
GalleryViewerRoute(
|
GalleryViewerRoute(
|
||||||
renderList: renderList,
|
renderList: renderList,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
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/download.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/download.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/services/stack.service.dart';
|
import 'package:immich_mobile/services/stack.service.dart';
|
||||||
@@ -25,12 +26,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
import 'package:immich_mobile/pages/editing/edit.page.dart';
|
import 'package:immich_mobile/pages/editing/edit.page.dart';
|
||||||
|
|
||||||
class BottomGalleryBar extends ConsumerWidget {
|
class BottomGalleryBar extends ConsumerWidget {
|
||||||
final Asset asset;
|
|
||||||
final ValueNotifier<int> assetIndex;
|
final ValueNotifier<int> assetIndex;
|
||||||
final bool showStack;
|
final bool showStack;
|
||||||
final int stackIndex;
|
final int stackIndex;
|
||||||
final ValueNotifier<int> totalAssets;
|
final ValueNotifier<int> totalAssets;
|
||||||
final bool showVideoPlayerControls;
|
|
||||||
final PageController controller;
|
final PageController controller;
|
||||||
final RenderList renderList;
|
final RenderList renderList;
|
||||||
|
|
||||||
@@ -38,20 +37,24 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.showStack,
|
required this.showStack,
|
||||||
required this.stackIndex,
|
required this.stackIndex,
|
||||||
required this.asset,
|
|
||||||
required this.assetIndex,
|
required this.assetIndex,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.totalAssets,
|
required this.totalAssets,
|
||||||
required this.showVideoPlayerControls,
|
|
||||||
required this.renderList,
|
required this.renderList,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asset = ref.watch(currentAssetProvider);
|
||||||
|
if (asset == null) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||||
|
final showControls = ref.watch(showControlsProvider);
|
||||||
|
final stackId = asset.stackId;
|
||||||
|
|
||||||
final stackItems = showStack && asset.stackCount > 0
|
final stackItems = showStack && stackId != null
|
||||||
? ref.watch(assetStackStateProvider(asset))
|
? ref.watch(assetStackStateProvider(stackId))
|
||||||
: <Asset>[];
|
: <Asset>[];
|
||||||
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
|
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
|
||||||
final navStack = AutoRouter.of(context).stackData;
|
final navStack = AutoRouter.of(context).stackData;
|
||||||
@@ -63,9 +66,9 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
|
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
|
||||||
|
|
||||||
void removeAssetFromStack() {
|
void removeAssetFromStack() {
|
||||||
if (stackIndex > 0 && showStack) {
|
if (stackIndex > 0 && showStack && stackId != null) {
|
||||||
ref
|
ref
|
||||||
.read(assetStackStateProvider(asset).notifier)
|
.read(assetStackStateProvider(stackId).notifier)
|
||||||
.removeChild(stackIndex - 1);
|
.removeChild(stackIndex - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(stackServiceProvider)
|
.read(stackServiceProvider)
|
||||||
.deleteStack(asset.stackId!, [asset, ...stackItems]);
|
.deleteStack(asset.stackId!, stackItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
void showStackActionItems() {
|
void showStackActionItems() {
|
||||||
@@ -323,43 +326,55 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: !ref.watch(showControlsProvider),
|
ignoring: !showControls,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||||
child: Column(
|
child: DecoratedBox(
|
||||||
children: [
|
decoration: const BoxDecoration(
|
||||||
Visibility(
|
gradient: LinearGradient(
|
||||||
visible: showVideoPlayerControls,
|
begin: Alignment.bottomCenter,
|
||||||
child: const VideoControls(),
|
end: Alignment.topCenter,
|
||||||
|
colors: [Colors.black, Colors.transparent],
|
||||||
),
|
),
|
||||||
BottomNavigationBar(
|
),
|
||||||
backgroundColor: Colors.black.withOpacity(0.4),
|
position: DecorationPosition.background,
|
||||||
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
child: Padding(
|
||||||
selectedIconTheme: const IconThemeData(color: Colors.white),
|
padding: const EdgeInsets.only(top: 40.0),
|
||||||
unselectedLabelStyle: const TextStyle(
|
child: Column(
|
||||||
color: Colors.white,
|
children: [
|
||||||
fontWeight: FontWeight.w500,
|
if (asset.isVideo) const VideoControls(),
|
||||||
height: 2.3,
|
BottomNavigationBar(
|
||||||
),
|
elevation: 0.0,
|
||||||
selectedLabelStyle: const TextStyle(
|
backgroundColor: Colors.transparent,
|
||||||
color: Colors.white,
|
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
||||||
fontWeight: FontWeight.w500,
|
selectedIconTheme: const IconThemeData(color: Colors.white),
|
||||||
height: 2.3,
|
unselectedLabelStyle: const TextStyle(
|
||||||
),
|
color: Colors.white,
|
||||||
unselectedFontSize: 14,
|
fontWeight: FontWeight.w500,
|
||||||
selectedFontSize: 14,
|
height: 2.3,
|
||||||
selectedItemColor: Colors.white,
|
),
|
||||||
unselectedItemColor: Colors.white,
|
selectedLabelStyle: const TextStyle(
|
||||||
showSelectedLabels: true,
|
color: Colors.white,
|
||||||
showUnselectedLabels: true,
|
fontWeight: FontWeight.w500,
|
||||||
items:
|
height: 2.3,
|
||||||
albumActions.map((e) => e.keys.first).toList(growable: false),
|
),
|
||||||
onTap: (index) {
|
unselectedFontSize: 14,
|
||||||
albumActions[index].values.first.call(index);
|
selectedFontSize: 14,
|
||||||
},
|
selectedItemColor: Colors.white,
|
||||||
|
unselectedItemColor: Colors.white,
|
||||||
|
showSelectedLabels: true,
|
||||||
|
showUnselectedLabels: true,
|
||||||
|
items: albumActions
|
||||||
|
.map((e) => e.keys.first)
|
||||||
|
.toList(growable: false),
|
||||||
|
onTap: (index) {
|
||||||
|
albumActions[index].values.first.call(index);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,38 +1,47 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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/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_controls_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
import 'package:immich_mobile/widgets/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';
|
||||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
|
||||||
|
|
||||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||||
final Duration hideTimerDuration;
|
final Duration hideTimerDuration;
|
||||||
|
|
||||||
const CustomVideoPlayerControls({
|
const CustomVideoPlayerControls({
|
||||||
super.key,
|
super.key,
|
||||||
this.hideTimerDuration = const Duration(seconds: 3),
|
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 showControls = ref.watch(showControlsProvider);
|
||||||
|
final VideoPlaybackState state =
|
||||||
|
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||||
|
|
||||||
// A timer to hide the controls
|
// A timer to hide the controls
|
||||||
final hideTimer = useTimer(
|
final hideTimer = useTimer(
|
||||||
hideTimerDuration,
|
hideTimerDuration,
|
||||||
() {
|
() {
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Do not hide on paused
|
// Do not hide on paused
|
||||||
if (state != VideoPlaybackState.paused) {
|
if (state != VideoPlaybackState.paused &&
|
||||||
|
state != VideoPlaybackState.completed &&
|
||||||
|
assetIsVideo) {
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
final showBuffering = state == VideoPlaybackState.buffering;
|
||||||
final showBuffering = useState(false);
|
|
||||||
final VideoPlaybackState state =
|
|
||||||
ref.watch(videoPlaybackValueProvider).state;
|
|
||||||
|
|
||||||
/// Shows the controls and starts the timer to hide them
|
/// Shows the controls and starts the timer to hide them
|
||||||
void showControlsAndStartHideTimer() {
|
void showControlsAndStartHideTimer() {
|
||||||
@@ -52,16 +61,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
showControlsAndStartHideTimer();
|
showControlsAndStartHideTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
|
|
||||||
(_, state) {
|
|
||||||
// Show buffering
|
|
||||||
showBuffering.value = state == VideoPlaybackState.buffering;
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Toggles between playing and pausing depending on the state of the video
|
/// Toggles between playing and pausing depending on the state of the video
|
||||||
void togglePlay() {
|
void togglePlay() {
|
||||||
showControlsAndStartHideTimer();
|
showControlsAndStartHideTimer();
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
|
||||||
if (state == VideoPlaybackState.playing) {
|
if (state == VideoPlaybackState.playing) {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
} else if (state == VideoPlaybackState.completed) {
|
} else if (state == VideoPlaybackState.completed) {
|
||||||
@@ -75,10 +77,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: showControlsAndStartHideTimer,
|
onTap: showControlsAndStartHideTimer,
|
||||||
child: AbsorbPointer(
|
child: AbsorbPointer(
|
||||||
absorbing: !ref.watch(showControlsProvider),
|
absorbing: !showControls,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (showBuffering.value)
|
if (showBuffering)
|
||||||
const Center(
|
const Center(
|
||||||
child: DelayedLoadingIndicator(
|
child: DelayedLoadingIndicator(
|
||||||
fadeInDuration: Duration(milliseconds: 400),
|
fadeInDuration: Duration(milliseconds: 400),
|
||||||
@@ -86,18 +88,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () =>
|
||||||
if (state != VideoPlaybackState.playing) {
|
ref.read(showControlsProvider.notifier).show = false,
|
||||||
togglePlay();
|
|
||||||
}
|
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
|
||||||
},
|
|
||||||
child: CenterPlayButton(
|
child: CenterPlayButton(
|
||||||
backgroundColor: Colors.black54,
|
backgroundColor: Colors.black54,
|
||||||
iconColor: Colors.white,
|
iconColor: Colors.white,
|
||||||
isFinished: state == VideoPlaybackState.completed,
|
isFinished: state == VideoPlaybackState.completed,
|
||||||
isPlaying: state == VideoPlaybackState.playing,
|
isPlaying: state == VideoPlaybackState.playing,
|
||||||
show: ref.watch(showControlsProvider),
|
show: assetIsVideo && showControls,
|
||||||
onPressed: togglePlay,
|
onPressed: togglePlay,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||||
|
|
||||||
String resolution = asset.width != null && asset.height != null
|
final height = asset.orientatedHeight ?? asset.height;
|
||||||
? "${asset.height} x ${asset.width} "
|
final width = asset.orientatedWidth ?? asset.width;
|
||||||
: "";
|
String resolution =
|
||||||
|
height != null && width != null ? "$height x $width " : "";
|
||||||
String fileSize = asset.exifInfo?.fileSize != null
|
String fileSize = asset.exifInfo?.fileSize != null
|
||||||
? formatBytes(asset.exifInfo!.fileSize!)
|
? formatBytes(asset.exifInfo!.fileSize!)
|
||||||
: "";
|
: "";
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
String _formatDuration(Duration position) {
|
||||||
|
final seconds = position.inSeconds.remainder(60).toString().padLeft(2, "0");
|
||||||
|
final minutes = position.inMinutes.remainder(60).toString().padLeft(2, "0");
|
||||||
|
if (position.inHours == 0) {
|
||||||
|
return "$minutes:$seconds";
|
||||||
|
}
|
||||||
|
final hours = position.inHours.toString().padLeft(2, '0');
|
||||||
|
return "$hours:$minutes:$seconds";
|
||||||
|
}
|
||||||
|
|
||||||
|
class FormattedDuration extends StatelessWidget {
|
||||||
|
final Duration data;
|
||||||
|
const FormattedDuration(this.data, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter
|
||||||
|
child: Text(
|
||||||
|
_formatDuration(data),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14.0,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
|
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
@@ -19,23 +20,19 @@ import 'package:immich_mobile/providers/user.provider.dart';
|
|||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
class GalleryAppBar extends ConsumerWidget {
|
class GalleryAppBar extends ConsumerWidget {
|
||||||
final Asset asset;
|
|
||||||
final void Function() showInfo;
|
final void Function() showInfo;
|
||||||
final void Function() onToggleMotionVideo;
|
|
||||||
final bool isPlayingVideo;
|
|
||||||
|
|
||||||
const GalleryAppBar({
|
const GalleryAppBar({super.key, required this.showInfo});
|
||||||
super.key,
|
|
||||||
required this.asset,
|
|
||||||
required this.showInfo,
|
|
||||||
required this.onToggleMotionVideo,
|
|
||||||
required this.isPlayingVideo,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asset = ref.watch(currentAssetProvider);
|
||||||
|
if (asset == null) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
final album = ref.watch(currentAlbumProvider);
|
final album = ref.watch(currentAlbumProvider);
|
||||||
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||||
|
final showControls = ref.watch(showControlsProvider);
|
||||||
|
|
||||||
final isPartner = ref
|
final isPartner = ref
|
||||||
.watch(partnerSharedWithProvider)
|
.watch(partnerSharedWithProvider)
|
||||||
@@ -98,23 +95,21 @@ class GalleryAppBar extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: !ref.watch(showControlsProvider),
|
ignoring: !showControls,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
opacity: showControls ? 1.0 : 0.0,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(0.4),
|
color: Colors.black.withOpacity(0.4),
|
||||||
child: TopControlAppBar(
|
child: TopControlAppBar(
|
||||||
isOwner: isOwner,
|
isOwner: isOwner,
|
||||||
isPartner: isPartner,
|
isPartner: isPartner,
|
||||||
isPlayingMotionVideo: isPlayingVideo,
|
|
||||||
asset: asset,
|
asset: asset,
|
||||||
onMoreInfoPressed: showInfo,
|
onMoreInfoPressed: showInfo,
|
||||||
onFavorite: toggleFavorite,
|
onFavorite: toggleFavorite,
|
||||||
onRestorePressed: () => handleRestore(asset),
|
onRestorePressed: () => handleRestore(asset),
|
||||||
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
|
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
|
||||||
onDownloadPressed: asset.isLocal ? null : handleDownloadAsset,
|
onDownloadPressed: asset.isLocal ? null : handleDownloadAsset,
|
||||||
onToggleMotionVideo: onToggleMotionVideo,
|
|
||||||
onAddToAlbumPressed: () => addToAlbum(asset),
|
onAddToAlbumPressed: () => addToAlbum(asset),
|
||||||
onActivitiesPressed: handleActivities,
|
onActivitiesPressed: handleActivities,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
|
|
||||||
|
class MotionPhotoButton extends ConsumerWidget {
|
||||||
|
const MotionPhotoButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isPlaying = ref.watch(isPlayingMotionVideoProvider);
|
||||||
|
|
||||||
|
return IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(isPlayingMotionVideoProvider.notifier).toggle();
|
||||||
|
},
|
||||||
|
icon: isPlaying
|
||||||
|
? const Icon(Icons.motion_photos_pause_outlined, color: grey200)
|
||||||
|
: const Icon(Icons.play_circle_outline_rounded, color: grey200),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
|
||||||
|
|
||||||
class TopControlAppBar extends HookConsumerWidget {
|
class TopControlAppBar extends HookConsumerWidget {
|
||||||
const TopControlAppBar({
|
const TopControlAppBar({
|
||||||
@@ -14,8 +15,6 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
required this.onDownloadPressed,
|
required this.onDownloadPressed,
|
||||||
required this.onAddToAlbumPressed,
|
required this.onAddToAlbumPressed,
|
||||||
required this.onRestorePressed,
|
required this.onRestorePressed,
|
||||||
required this.onToggleMotionVideo,
|
|
||||||
required this.isPlayingMotionVideo,
|
|
||||||
required this.onFavorite,
|
required this.onFavorite,
|
||||||
required this.onUploadPressed,
|
required this.onUploadPressed,
|
||||||
required this.isOwner,
|
required this.isOwner,
|
||||||
@@ -27,12 +26,10 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
final Function onMoreInfoPressed;
|
final Function onMoreInfoPressed;
|
||||||
final VoidCallback? onUploadPressed;
|
final VoidCallback? onUploadPressed;
|
||||||
final VoidCallback? onDownloadPressed;
|
final VoidCallback? onDownloadPressed;
|
||||||
final VoidCallback onToggleMotionVideo;
|
|
||||||
final VoidCallback onAddToAlbumPressed;
|
final VoidCallback onAddToAlbumPressed;
|
||||||
final VoidCallback onRestorePressed;
|
final VoidCallback onRestorePressed;
|
||||||
final VoidCallback onActivitiesPressed;
|
final VoidCallback onActivitiesPressed;
|
||||||
final Function(Asset) onFavorite;
|
final Function(Asset) onFavorite;
|
||||||
final bool isPlayingMotionVideo;
|
|
||||||
final bool isOwner;
|
final bool isOwner;
|
||||||
final bool isPartner;
|
final bool isPartner;
|
||||||
|
|
||||||
@@ -57,23 +54,6 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildLivePhotoButton() {
|
|
||||||
return IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
onToggleMotionVideo();
|
|
||||||
},
|
|
||||||
icon: isPlayingMotionVideo
|
|
||||||
? Icon(
|
|
||||||
Icons.motion_photos_pause_outlined,
|
|
||||||
color: Colors.grey[200],
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
Icons.play_circle_outline_rounded,
|
|
||||||
color: Colors.grey[200],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildMoreInfoButton() {
|
Widget buildMoreInfoButton() {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -175,13 +155,11 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
foregroundColor: Colors.grey[100],
|
foregroundColor: Colors.grey[100],
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
leading: buildBackButton(),
|
leading: buildBackButton(),
|
||||||
actionsIconTheme: const IconThemeData(
|
actionsIconTheme: const IconThemeData(size: iconSize),
|
||||||
size: iconSize,
|
|
||||||
),
|
|
||||||
shape: const Border(),
|
shape: const Border(),
|
||||||
actions: [
|
actions: [
|
||||||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
||||||
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
|
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
|
||||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||||
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
|
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
|
||||||
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
||||||
|
|||||||
@@ -1,125 +1,20 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
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/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
|
|
||||||
/// The video controls for the [videPlayerControlsProvider]
|
/// The video controls for the [videoPlayerControlsProvider]
|
||||||
class VideoControls extends ConsumerWidget {
|
class VideoControls extends ConsumerWidget {
|
||||||
const VideoControls({super.key});
|
const VideoControls({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final duration =
|
final isPortrait =
|
||||||
ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
|
MediaQuery.orientationOf(context) == Orientation.portrait;
|
||||||
final position =
|
return isPortrait
|
||||||
ref.watch(videoPlaybackValueProvider.select((v) => v.position));
|
? const VideoPosition()
|
||||||
|
: const Padding(
|
||||||
return AnimatedOpacity(
|
padding: EdgeInsets.symmetric(horizontal: 60.0),
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
child: VideoPosition(),
|
||||||
duration: const Duration(milliseconds: 100),
|
);
|
||||||
child: OrientationBuilder(
|
|
||||||
builder: (context, orientation) => Container(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: orientation == Orientation.portrait ? 12.0 : 64.0,
|
|
||||||
),
|
|
||||||
color: Colors.black.withOpacity(0.4),
|
|
||||||
child: Padding(
|
|
||||||
padding: MediaQuery.of(context).orientation == Orientation.portrait
|
|
||||||
? const EdgeInsets.symmetric(horizontal: 12.0)
|
|
||||||
: const EdgeInsets.symmetric(horizontal: 64.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatDuration(position),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.0,
|
|
||||||
color: Colors.white.withOpacity(.75),
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Slider(
|
|
||||||
value: duration == Duration.zero
|
|
||||||
? 0.0
|
|
||||||
: min(
|
|
||||||
position.inMicroseconds /
|
|
||||||
duration.inMicroseconds *
|
|
||||||
100,
|
|
||||||
100,
|
|
||||||
),
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
thumbColor: Colors.white,
|
|
||||||
activeColor: Colors.white,
|
|
||||||
inactiveColor: Colors.white.withOpacity(0.75),
|
|
||||||
onChanged: (position) {
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).position =
|
|
||||||
position;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_formatDuration(duration),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.0,
|
|
||||||
color: Colors.white.withOpacity(.75),
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
ref.watch(
|
|
||||||
videoPlayerControlsProvider.select((value) => value.mute),
|
|
||||||
)
|
|
||||||
? Icons.volume_off
|
|
||||||
: Icons.volume_up,
|
|
||||||
),
|
|
||||||
onPressed: () => ref
|
|
||||||
.read(videoPlayerControlsProvider.notifier)
|
|
||||||
.toggleMute(),
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDuration(Duration position) {
|
|
||||||
final ms = position.inMilliseconds;
|
|
||||||
|
|
||||||
int seconds = ms ~/ 1000;
|
|
||||||
final int hours = seconds ~/ 3600;
|
|
||||||
seconds = seconds % 3600;
|
|
||||||
final minutes = seconds ~/ 60;
|
|
||||||
seconds = seconds % 60;
|
|
||||||
|
|
||||||
final hoursString = hours >= 10
|
|
||||||
? '$hours'
|
|
||||||
: hours == 0
|
|
||||||
? '00'
|
|
||||||
: '0$hours';
|
|
||||||
|
|
||||||
final minutesString = minutes >= 10
|
|
||||||
? '$minutes'
|
|
||||||
: minutes == 0
|
|
||||||
? '00'
|
|
||||||
: '0$minutes';
|
|
||||||
|
|
||||||
final secondsString = seconds >= 10
|
|
||||||
? '$seconds'
|
|
||||||
: seconds == 0
|
|
||||||
? '00'
|
|
||||||
: '0$seconds';
|
|
||||||
|
|
||||||
final formattedTime =
|
|
||||||
'${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
|
|
||||||
|
|
||||||
return formattedTime;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import 'package:chewie/chewie.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/utils/hooks/chewiew_controller_hook.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
|
|
||||||
class VideoPlayerViewer extends HookConsumerWidget {
|
|
||||||
final VideoPlayerController controller;
|
|
||||||
final bool isMotionVideo;
|
|
||||||
final Widget? placeholder;
|
|
||||||
final Duration hideControlsTimer;
|
|
||||||
final bool showControls;
|
|
||||||
final bool showDownloadingIndicator;
|
|
||||||
final bool loopVideo;
|
|
||||||
|
|
||||||
const VideoPlayerViewer({
|
|
||||||
super.key,
|
|
||||||
required this.controller,
|
|
||||||
required this.isMotionVideo,
|
|
||||||
this.placeholder,
|
|
||||||
required this.hideControlsTimer,
|
|
||||||
required this.showControls,
|
|
||||||
required this.showDownloadingIndicator,
|
|
||||||
required this.loopVideo,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final chewie = useChewieController(
|
|
||||||
controller: controller,
|
|
||||||
controlsSafeAreaMinimum: const EdgeInsets.only(
|
|
||||||
bottom: 100,
|
|
||||||
),
|
|
||||||
placeholder: SizedBox.expand(child: placeholder),
|
|
||||||
customControls: CustomVideoPlayerControls(
|
|
||||||
hideTimerDuration: hideControlsTimer,
|
|
||||||
),
|
|
||||||
showControls: showControls && !isMotionVideo,
|
|
||||||
hideControlsTimer: hideControlsTimer,
|
|
||||||
loopVideo: loopVideo,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Chewie(
|
|
||||||
controller: chewie,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
||||||
|
|
||||||
|
class VideoPosition extends HookConsumerWidget {
|
||||||
|
const VideoPosition({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final (position, duration) = ref.watch(
|
||||||
|
videoPlaybackValueProvider.select((v) => (v.position, v.duration)),
|
||||||
|
);
|
||||||
|
final wasPlaying = useRef<bool>(true);
|
||||||
|
return duration == Duration.zero
|
||||||
|
? const _VideoPositionPlaceholder()
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
// align with slider's inherent padding
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
FormattedDuration(position),
|
||||||
|
FormattedDuration(duration),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: min(
|
||||||
|
position.inMicroseconds / duration.inMicroseconds * 100,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: whiteOpacity75,
|
||||||
|
onChangeStart: (value) {
|
||||||
|
final state =
|
||||||
|
ref.read(videoPlaybackValueProvider).state;
|
||||||
|
wasPlaying.value = state != VideoPlaybackState.paused;
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
|
},
|
||||||
|
onChangeEnd: (value) {
|
||||||
|
if (wasPlaying.value) {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChanged: (value) {
|
||||||
|
final inSeconds =
|
||||||
|
(duration * (value / 100.0)).inSeconds;
|
||||||
|
final position = inSeconds.toDouble();
|
||||||
|
ref
|
||||||
|
.read(videoPlayerControlsProvider.notifier)
|
||||||
|
.position = position;
|
||||||
|
// This immediately updates the slider position without waiting for the video to update
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).position =
|
||||||
|
Duration(seconds: inSeconds);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPositionPlaceholder extends StatelessWidget {
|
||||||
|
const _VideoPositionPlaceholder();
|
||||||
|
|
||||||
|
static void _onChangedDummy(_) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
FormattedDuration(Duration.zero),
|
||||||
|
FormattedDuration(Duration.zero),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: 0.0,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: whiteOpacity75,
|
||||||
|
onChanged: _onChangedDummy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
|
|
||||||
|
class BackupAssetInfoTable extends ConsumerWidget {
|
||||||
|
const BackupAssetInfoTable({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isManualUpload = ref.watch(
|
||||||
|
backupProvider.select(
|
||||||
|
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final asset = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider.select((value) => value.currentUploadAsset),
|
||||||
|
)
|
||||||
|
: ref.watch(backupProvider.select((value) => value.currentUploadAsset));
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Table(
|
||||||
|
border: TableBorder.all(
|
||||||
|
color: context.colorScheme.outlineVariant,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TableRow(
|
||||||
|
children: [
|
||||||
|
TableCell(
|
||||||
|
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6.0),
|
||||||
|
child: Text(
|
||||||
|
'backup_controller_page_filename',
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.colorScheme.onSurfaceSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 10.0,
|
||||||
|
),
|
||||||
|
).tr(
|
||||||
|
args: [asset.fileName, asset.fileType.toLowerCase()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TableRow(
|
||||||
|
children: [
|
||||||
|
TableCell(
|
||||||
|
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6.0),
|
||||||
|
child: Text(
|
||||||
|
"backup_controller_page_created",
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.colorScheme.onSurfaceSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 10.0,
|
||||||
|
),
|
||||||
|
).tr(
|
||||||
|
args: [_getAssetCreationDate(asset)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TableRow(
|
||||||
|
children: [
|
||||||
|
TableCell(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6.0),
|
||||||
|
child: Text(
|
||||||
|
"backup_controller_page_id",
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.colorScheme.onSurfaceSecondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 10.0,
|
||||||
|
),
|
||||||
|
).tr(args: [asset.id]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
String _getAssetCreationDate(CurrentUploadAsset asset) {
|
||||||
|
return DateFormat.yMMMMd().format(asset.fileCreatedAt.toLocal());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,296 +1,43 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/widgets/backup/asset_info_table.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
import 'package:immich_mobile/widgets/backup/error_chip.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/widgets/backup/icloud_download_progress_bar.dart';
|
||||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
import 'package:immich_mobile/widgets/backup/upload_progress_bar.dart';
|
||||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
import 'package:immich_mobile/widgets/backup/upload_stats.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
|
|
||||||
|
|
||||||
class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
|
class CurrentUploadingAssetInfoBox extends StatelessWidget {
|
||||||
const CurrentUploadingAssetInfoBox({super.key});
|
const CurrentUploadingAssetInfoBox({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
var isManualUpload = ref.watch(backupProvider).backupProgress ==
|
return ListTile(
|
||||||
BackUpProgressEnum.manualInProgress;
|
isThreeLine: true,
|
||||||
var asset = !isManualUpload
|
leading: Icon(
|
||||||
? ref.watch(backupProvider).currentUploadAsset
|
Icons.image_outlined,
|
||||||
: ref.watch(manualUploadProvider).currentUploadAsset;
|
color: context.primaryColor,
|
||||||
var uploadProgress = !isManualUpload
|
size: 30,
|
||||||
? ref.watch(backupProvider).progressInPercentage
|
),
|
||||||
: ref.watch(manualUploadProvider).progressInPercentage;
|
title: Row(
|
||||||
var uploadFileProgress = !isManualUpload
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
? ref.watch(backupProvider).progressInFileSize
|
|
||||||
: ref.watch(manualUploadProvider).progressInFileSize;
|
|
||||||
var uploadFileSpeed = !isManualUpload
|
|
||||||
? ref.watch(backupProvider).progressInFileSpeed
|
|
||||||
: ref.watch(manualUploadProvider).progressInFileSpeed;
|
|
||||||
var iCloudDownloadProgress =
|
|
||||||
ref.watch(backupProvider).iCloudDownloadProgress;
|
|
||||||
final isShowThumbnail = useState(false);
|
|
||||||
|
|
||||||
String formatUploadFileSpeed(double uploadFileSpeed) {
|
|
||||||
if (uploadFileSpeed < 1024) {
|
|
||||||
return '${uploadFileSpeed.toStringAsFixed(2)} B/s';
|
|
||||||
} else if (uploadFileSpeed < 1024 * 1024) {
|
|
||||||
return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s';
|
|
||||||
} else if (uploadFileSpeed < 1024 * 1024 * 1024) {
|
|
||||||
return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s';
|
|
||||||
} else {
|
|
||||||
return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String getAssetCreationDate() {
|
|
||||||
return DateFormat.yMMMMd().format(
|
|
||||||
DateTime.parse(
|
|
||||||
asset.fileCreatedAt.toString(),
|
|
||||||
).toLocal(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildErrorChip() {
|
|
||||||
return ActionChip(
|
|
||||||
avatar: Icon(
|
|
||||||
Icons.info,
|
|
||||||
color: Colors.red[400],
|
|
||||||
),
|
|
||||||
elevation: 1,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
label: Text(
|
|
||||||
"backup_controller_page_failed",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red[400],
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
).tr(
|
|
||||||
args: [ref.watch(errorBackupListProvider).length.toString()],
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget buildAssetInfoTable() {
|
|
||||||
return Table(
|
|
||||||
border: TableBorder.all(
|
|
||||||
color: context.colorScheme.outlineVariant,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
TableRow(
|
Text(
|
||||||
children: [
|
"backup_controller_page_uploading_file_info",
|
||||||
TableCell(
|
style: context.textTheme.titleSmall,
|
||||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
).tr(),
|
||||||
child: Padding(
|
const BackupErrorChip(),
|
||||||
padding: const EdgeInsets.all(6.0),
|
],
|
||||||
child: Text(
|
),
|
||||||
'backup_controller_page_filename',
|
subtitle: Column(
|
||||||
style: TextStyle(
|
children: [
|
||||||
color: context.colorScheme.onSurfaceSecondary,
|
if (Platform.isIOS) const IcloudDownloadProgressBar(),
|
||||||
fontWeight: FontWeight.bold,
|
const BackupUploadProgressBar(),
|
||||||
fontSize: 10.0,
|
const BackupUploadStats(),
|
||||||
),
|
const BackupAssetInfoTable(),
|
||||||
).tr(
|
|
||||||
args: [asset.fileName, asset.fileType.toLowerCase()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
TableRow(
|
|
||||||
children: [
|
|
||||||
TableCell(
|
|
||||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(6.0),
|
|
||||||
child: Text(
|
|
||||||
"backup_controller_page_created",
|
|
||||||
style: TextStyle(
|
|
||||||
color: context.colorScheme.onSurfaceSecondary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 10.0,
|
|
||||||
),
|
|
||||||
).tr(
|
|
||||||
args: [getAssetCreationDate()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
TableRow(
|
|
||||||
children: [
|
|
||||||
TableCell(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(6.0),
|
|
||||||
child: Text(
|
|
||||||
"backup_controller_page_id",
|
|
||||||
style: TextStyle(
|
|
||||||
color: context.colorScheme.onSurfaceSecondary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 10.0,
|
|
||||||
),
|
|
||||||
).tr(args: [asset.id]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildiCloudDownloadProgerssBar() {
|
|
||||||
if (asset.iCloudAsset != null && asset.iCloudAsset!) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 110,
|
|
||||||
child: Text(
|
|
||||||
"iCloud Download",
|
|
||||||
style: context.textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
minHeight: 10.0,
|
|
||||||
value: uploadProgress / 100.0,
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
" ${iCloudDownloadProgress.toStringAsFixed(0)}%",
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const SizedBox();
|
|
||||||
}
|
|
||||||
|
|
||||||
buildUploadProgressBar() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (asset.iCloudAsset != null && asset.iCloudAsset!)
|
|
||||||
SizedBox(
|
|
||||||
width: 110,
|
|
||||||
child: Text(
|
|
||||||
"Immich Upload",
|
|
||||||
style: context.textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
minHeight: 10.0,
|
|
||||||
value: uploadProgress / 100.0,
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
" ${uploadProgress.toStringAsFixed(0)}%",
|
|
||||||
style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildUploadStats() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 2.0, bottom: 2.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
uploadFileProgress,
|
|
||||||
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
formatUploadFileSpeed(uploadFileSpeed),
|
|
||||||
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return FutureBuilder<Asset?>(
|
|
||||||
future: ref.read(assetMediaRepositoryProvider).get(asset.id),
|
|
||||||
builder: (context, thumbnail) => ListTile(
|
|
||||||
isThreeLine: true,
|
|
||||||
leading: AnimatedCrossFade(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
firstChild: GestureDetector(
|
|
||||||
onTap: () => isShowThumbnail.value = false,
|
|
||||||
child: thumbnail.hasData
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(5),
|
|
||||||
child: ImmichThumbnail(
|
|
||||||
asset: thumbnail.data,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const SizedBox(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(8.0),
|
|
||||||
child: CircularProgressIndicator.adaptive(
|
|
||||||
strokeWidth: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
secondChild: GestureDetector(
|
|
||||||
onTap: () => isShowThumbnail.value = true,
|
|
||||||
child: Icon(
|
|
||||||
Icons.image_outlined,
|
|
||||||
color: context.primaryColor,
|
|
||||||
size: 30,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
crossFadeState: isShowThumbnail.value
|
|
||||||
? CrossFadeState.showFirst
|
|
||||||
: CrossFadeState.showSecond,
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
"backup_controller_page_uploading_file_info",
|
|
||||||
style: context.textTheme.titleSmall,
|
|
||||||
).tr(),
|
|
||||||
if (ref.watch(errorBackupListProvider).isNotEmpty) buildErrorChip(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
children: [
|
|
||||||
if (Platform.isIOS) buildiCloudDownloadProgerssBar(),
|
|
||||||
buildUploadProgressBar(),
|
|
||||||
buildUploadStats(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
|
||||||
child: buildAssetInfoTable(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/widgets/backup/error_chip_text.dart';
|
||||||
|
|
||||||
|
class BackupErrorChip extends ConsumerWidget {
|
||||||
|
const BackupErrorChip({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final hasErrors =
|
||||||
|
ref.watch(errorBackupListProvider.select((value) => value.isNotEmpty));
|
||||||
|
if (!hasErrors) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActionChip(
|
||||||
|
avatar: const Icon(
|
||||||
|
Icons.info,
|
||||||
|
color: red400,
|
||||||
|
),
|
||||||
|
elevation: 1,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
label: const BackupErrorChipText(),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||||
|
|
||||||
|
class BackupErrorChipText extends ConsumerWidget {
|
||||||
|
const BackupErrorChipText({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final count = ref.watch(errorBackupListProvider).length;
|
||||||
|
if (count == 0) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Text(
|
||||||
|
"backup_controller_page_failed",
|
||||||
|
style: TextStyle(
|
||||||
|
color: red400,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
).tr(
|
||||||
|
args: [count.toString()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
|
|
||||||
|
class IcloudDownloadProgressBar extends ConsumerWidget {
|
||||||
|
const IcloudDownloadProgressBar({super.key});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isManualUpload = ref.watch(
|
||||||
|
backupProvider.select(
|
||||||
|
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final isIcloudAsset = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider
|
||||||
|
.select((value) => value.currentUploadAsset.isIcloudAsset),
|
||||||
|
)
|
||||||
|
: ref.watch(
|
||||||
|
backupProvider
|
||||||
|
.select((value) => value.currentUploadAsset.isIcloudAsset),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isIcloudAsset) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
final iCloudDownloadProgress = ref
|
||||||
|
.watch(backupProvider.select((value) => value.iCloudDownloadProgress));
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 110,
|
||||||
|
child: Text(
|
||||||
|
"iCloud Download",
|
||||||
|
style: context.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 10.0,
|
||||||
|
value: iCloudDownloadProgress / 100.0,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
" ${iCloudDownloadProgress ~/ 1}%",
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
|
|
||||||
|
class BackupUploadProgressBar extends ConsumerWidget {
|
||||||
|
const BackupUploadProgressBar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isManualUpload = ref.watch(
|
||||||
|
backupProvider.select(
|
||||||
|
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final isIcloudAsset = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider
|
||||||
|
.select((value) => value.currentUploadAsset.isIcloudAsset),
|
||||||
|
)
|
||||||
|
: ref.watch(
|
||||||
|
backupProvider
|
||||||
|
.select((value) => value.currentUploadAsset.isIcloudAsset),
|
||||||
|
);
|
||||||
|
|
||||||
|
final uploadProgress = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider.select((value) => value.progressInPercentage),
|
||||||
|
)
|
||||||
|
: ref.watch(
|
||||||
|
backupProvider.select((value) => value.progressInPercentage),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (isIcloudAsset)
|
||||||
|
SizedBox(
|
||||||
|
width: 110,
|
||||||
|
child: Text(
|
||||||
|
"Immich Upload",
|
||||||
|
style: context.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 10.0,
|
||||||
|
value: uploadProgress / 100.0,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
" ${uploadProgress.toStringAsFixed(0)}%",
|
||||||
|
style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
|
|
||||||
|
class BackupUploadStats extends ConsumerWidget {
|
||||||
|
const BackupUploadStats({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isManualUpload = ref.watch(
|
||||||
|
backupProvider.select(
|
||||||
|
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final uploadFileProgress = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider.select((value) => value.progressInFileSize),
|
||||||
|
)
|
||||||
|
: ref.watch(backupProvider.select((value) => value.progressInFileSize));
|
||||||
|
|
||||||
|
final uploadFileSpeed = isManualUpload
|
||||||
|
? ref.watch(
|
||||||
|
manualUploadProvider.select((value) => value.progressInFileSpeed),
|
||||||
|
)
|
||||||
|
: ref.watch(
|
||||||
|
backupProvider.select((value) => value.progressInFileSpeed),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2.0, bottom: 2.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
uploadFileProgress,
|
||||||
|
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_formatUploadFileSpeed(uploadFileSpeed),
|
||||||
|
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
String _formatUploadFileSpeed(double uploadFileSpeed) {
|
||||||
|
if (uploadFileSpeed < 1024) {
|
||||||
|
return '${uploadFileSpeed.toStringAsFixed(2)} B/s';
|
||||||
|
} else if (uploadFileSpeed < 1024 * 1024) {
|
||||||
|
return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s';
|
||||||
|
} else if (uploadFileSpeed < 1024 * 1024 * 1024) {
|
||||||
|
return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s';
|
||||||
|
} else {
|
||||||
|
return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
bool isHorizontal = !context.isMobile;
|
bool isHorizontal = !context.isMobile;
|
||||||
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
|
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
|
final isLoggingOut = useState(false);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@@ -63,11 +64,16 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildActionButton(IconData icon, String text, Function() onTap) {
|
buildActionButton(
|
||||||
|
IconData icon,
|
||||||
|
String text,
|
||||||
|
Function() onTap, {
|
||||||
|
Widget? trailing,
|
||||||
|
}) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
visualDensity: VisualDensity.standard,
|
visualDensity: VisualDensity.standard,
|
||||||
contentPadding: const EdgeInsets.only(left: 30),
|
contentPadding: const EdgeInsets.only(left: 30, right: 30),
|
||||||
minLeadingWidth: 40,
|
minLeadingWidth: 40,
|
||||||
leading: SizedBox(
|
leading: SizedBox(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -83,6 +89,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
).tr(),
|
).tr(),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
trailing: trailing,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +114,10 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
Icons.logout_rounded,
|
Icons.logout_rounded,
|
||||||
"profile_drawer_sign_out",
|
"profile_drawer_sign_out",
|
||||||
() async {
|
() async {
|
||||||
|
if (isLoggingOut.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
@@ -115,7 +126,11 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
content: "app_bar_signout_dialog_content",
|
content: "app_bar_signout_dialog_content",
|
||||||
ok: "app_bar_signout_dialog_ok",
|
ok: "app_bar_signout_dialog_ok",
|
||||||
onOk: () async {
|
onOk: () async {
|
||||||
await ref.read(authenticationProvider.notifier).logout();
|
isLoggingOut.value = true;
|
||||||
|
await ref
|
||||||
|
.read(authenticationProvider.notifier)
|
||||||
|
.logout()
|
||||||
|
.whenComplete(() => isLoggingOut.value = false);
|
||||||
|
|
||||||
ref.read(manualUploadProvider.notifier).cancelBackup();
|
ref.read(manualUploadProvider.notifier).cancelBackup();
|
||||||
ref.read(backupProvider.notifier).cancelBackup();
|
ref.read(backupProvider.notifier).cancelBackup();
|
||||||
@@ -127,6 +142,12 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
trailing: isLoggingOut.value
|
||||||
|
? SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,11 @@ class ImmichImage extends StatelessWidget {
|
|||||||
// either by using the asset ID or the asset itself
|
// either by using the asset ID or the asset itself
|
||||||
/// [asset] is the Asset to request, or else use [assetId] to get a remote
|
/// [asset] is the Asset to request, or else use [assetId] to get a remote
|
||||||
/// image provider
|
/// image provider
|
||||||
/// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail
|
|
||||||
/// The size of the square thumbnail to request. Ignored if isThumbnail
|
|
||||||
/// is not true
|
|
||||||
static ImageProvider imageProvider({
|
static ImageProvider imageProvider({
|
||||||
Asset? asset,
|
Asset? asset,
|
||||||
String? assetId,
|
String? assetId,
|
||||||
|
double width = 1080,
|
||||||
|
double height = 1920,
|
||||||
}) {
|
}) {
|
||||||
if (asset == null && assetId == null) {
|
if (asset == null && assetId == null) {
|
||||||
throw Exception('Must supply either asset or assetId');
|
throw Exception('Must supply either asset or assetId');
|
||||||
@@ -48,6 +47,8 @@ class ImmichImage extends StatelessWidget {
|
|||||||
if (useLocal(asset)) {
|
if (useLocal(asset)) {
|
||||||
return ImmichLocalImageProvider(
|
return ImmichLocalImageProvider(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return ImmichRemoteImageProvider(
|
return ImmichRemoteImageProvider(
|
||||||
@@ -87,6 +88,8 @@ class ImmichImage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
image: ImmichImage.imageProvider(
|
image: ImmichImage.imageProvider(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
|
width: context.width,
|
||||||
|
height: context.height,
|
||||||
),
|
),
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'dart:ui';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||||
|
|
||||||
@@ -68,18 +68,20 @@ class MemoryCard extends StatelessWidget {
|
|||||||
} else {
|
} else {
|
||||||
return Hero(
|
return Hero(
|
||||||
tag: 'memory-${asset.id}',
|
tag: 'memory-${asset.id}',
|
||||||
child: VideoViewerPage(
|
child: SizedBox(
|
||||||
key: ValueKey(asset),
|
width: context.width,
|
||||||
asset: asset,
|
height: context.height,
|
||||||
showDownloadingIndicator: false,
|
child: NativeVideoViewerPage(
|
||||||
placeholder: SizedBox.expand(
|
key: ValueKey(asset.id),
|
||||||
child: ImmichImage(
|
asset: asset,
|
||||||
|
showControls: false,
|
||||||
|
image: ImmichImage(
|
||||||
asset,
|
asset,
|
||||||
|
width: context.width,
|
||||||
|
height: context.height,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
hideControlsTimer: const Duration(seconds: 2),
|
|
||||||
showControls: false,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,6 +139,8 @@ class _BlurredBackdrop extends HookWidget {
|
|||||||
image: DecorationImage(
|
image: DecorationImage(
|
||||||
image: ImmichImage.imageProvider(
|
image: ImmichImage.imageProvider(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
|
height: context.height,
|
||||||
|
width: context.width,
|
||||||
),
|
),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class ServerStatsResponseDto {
|
|||||||
this.photos = 0,
|
this.photos = 0,
|
||||||
this.usage = 0,
|
this.usage = 0,
|
||||||
this.usageByUser = const [],
|
this.usageByUser = const [],
|
||||||
|
this.usagePhotos = 0,
|
||||||
|
this.usageVideos = 0,
|
||||||
this.videos = 0,
|
this.videos = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,6 +27,10 @@ class ServerStatsResponseDto {
|
|||||||
|
|
||||||
List<UsageByUserDto> usageByUser;
|
List<UsageByUserDto> usageByUser;
|
||||||
|
|
||||||
|
int usagePhotos;
|
||||||
|
|
||||||
|
int usageVideos;
|
||||||
|
|
||||||
int videos;
|
int videos;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,6 +38,8 @@ class ServerStatsResponseDto {
|
|||||||
other.photos == photos &&
|
other.photos == photos &&
|
||||||
other.usage == usage &&
|
other.usage == usage &&
|
||||||
_deepEquality.equals(other.usageByUser, usageByUser) &&
|
_deepEquality.equals(other.usageByUser, usageByUser) &&
|
||||||
|
other.usagePhotos == usagePhotos &&
|
||||||
|
other.usageVideos == usageVideos &&
|
||||||
other.videos == videos;
|
other.videos == videos;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -40,16 +48,20 @@ class ServerStatsResponseDto {
|
|||||||
(photos.hashCode) +
|
(photos.hashCode) +
|
||||||
(usage.hashCode) +
|
(usage.hashCode) +
|
||||||
(usageByUser.hashCode) +
|
(usageByUser.hashCode) +
|
||||||
|
(usagePhotos.hashCode) +
|
||||||
|
(usageVideos.hashCode) +
|
||||||
(videos.hashCode);
|
(videos.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'ServerStatsResponseDto[photos=$photos, usage=$usage, usageByUser=$usageByUser, videos=$videos]';
|
String toString() => 'ServerStatsResponseDto[photos=$photos, usage=$usage, usageByUser=$usageByUser, usagePhotos=$usagePhotos, usageVideos=$usageVideos, videos=$videos]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'photos'] = this.photos;
|
json[r'photos'] = this.photos;
|
||||||
json[r'usage'] = this.usage;
|
json[r'usage'] = this.usage;
|
||||||
json[r'usageByUser'] = this.usageByUser;
|
json[r'usageByUser'] = this.usageByUser;
|
||||||
|
json[r'usagePhotos'] = this.usagePhotos;
|
||||||
|
json[r'usageVideos'] = this.usageVideos;
|
||||||
json[r'videos'] = this.videos;
|
json[r'videos'] = this.videos;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
@@ -66,6 +78,8 @@ class ServerStatsResponseDto {
|
|||||||
photos: mapValueOfType<int>(json, r'photos')!,
|
photos: mapValueOfType<int>(json, r'photos')!,
|
||||||
usage: mapValueOfType<int>(json, r'usage')!,
|
usage: mapValueOfType<int>(json, r'usage')!,
|
||||||
usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser']),
|
usageByUser: UsageByUserDto.listFromJson(json[r'usageByUser']),
|
||||||
|
usagePhotos: mapValueOfType<int>(json, r'usagePhotos')!,
|
||||||
|
usageVideos: mapValueOfType<int>(json, r'usageVideos')!,
|
||||||
videos: mapValueOfType<int>(json, r'videos')!,
|
videos: mapValueOfType<int>(json, r'videos')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,6 +131,8 @@ class ServerStatsResponseDto {
|
|||||||
'photos',
|
'photos',
|
||||||
'usage',
|
'usage',
|
||||||
'usageByUser',
|
'usageByUser',
|
||||||
|
'usagePhotos',
|
||||||
|
'usageVideos',
|
||||||
'videos',
|
'videos',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class UsageByUserDto {
|
|||||||
required this.photos,
|
required this.photos,
|
||||||
required this.quotaSizeInBytes,
|
required this.quotaSizeInBytes,
|
||||||
required this.usage,
|
required this.usage,
|
||||||
|
required this.usagePhotos,
|
||||||
|
required this.usageVideos,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.userName,
|
required this.userName,
|
||||||
required this.videos,
|
required this.videos,
|
||||||
@@ -27,6 +29,10 @@ class UsageByUserDto {
|
|||||||
|
|
||||||
int usage;
|
int usage;
|
||||||
|
|
||||||
|
int usagePhotos;
|
||||||
|
|
||||||
|
int usageVideos;
|
||||||
|
|
||||||
String userId;
|
String userId;
|
||||||
|
|
||||||
String userName;
|
String userName;
|
||||||
@@ -38,6 +44,8 @@ class UsageByUserDto {
|
|||||||
other.photos == photos &&
|
other.photos == photos &&
|
||||||
other.quotaSizeInBytes == quotaSizeInBytes &&
|
other.quotaSizeInBytes == quotaSizeInBytes &&
|
||||||
other.usage == usage &&
|
other.usage == usage &&
|
||||||
|
other.usagePhotos == usagePhotos &&
|
||||||
|
other.usageVideos == usageVideos &&
|
||||||
other.userId == userId &&
|
other.userId == userId &&
|
||||||
other.userName == userName &&
|
other.userName == userName &&
|
||||||
other.videos == videos;
|
other.videos == videos;
|
||||||
@@ -48,12 +56,14 @@ class UsageByUserDto {
|
|||||||
(photos.hashCode) +
|
(photos.hashCode) +
|
||||||
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
|
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
|
||||||
(usage.hashCode) +
|
(usage.hashCode) +
|
||||||
|
(usagePhotos.hashCode) +
|
||||||
|
(usageVideos.hashCode) +
|
||||||
(userId.hashCode) +
|
(userId.hashCode) +
|
||||||
(userName.hashCode) +
|
(userName.hashCode) +
|
||||||
(videos.hashCode);
|
(videos.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'UsageByUserDto[photos=$photos, quotaSizeInBytes=$quotaSizeInBytes, usage=$usage, userId=$userId, userName=$userName, videos=$videos]';
|
String toString() => 'UsageByUserDto[photos=$photos, quotaSizeInBytes=$quotaSizeInBytes, usage=$usage, usagePhotos=$usagePhotos, usageVideos=$usageVideos, userId=$userId, userName=$userName, videos=$videos]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@@ -64,6 +74,8 @@ class UsageByUserDto {
|
|||||||
// json[r'quotaSizeInBytes'] = null;
|
// json[r'quotaSizeInBytes'] = null;
|
||||||
}
|
}
|
||||||
json[r'usage'] = this.usage;
|
json[r'usage'] = this.usage;
|
||||||
|
json[r'usagePhotos'] = this.usagePhotos;
|
||||||
|
json[r'usageVideos'] = this.usageVideos;
|
||||||
json[r'userId'] = this.userId;
|
json[r'userId'] = this.userId;
|
||||||
json[r'userName'] = this.userName;
|
json[r'userName'] = this.userName;
|
||||||
json[r'videos'] = this.videos;
|
json[r'videos'] = this.videos;
|
||||||
@@ -82,6 +94,8 @@ class UsageByUserDto {
|
|||||||
photos: mapValueOfType<int>(json, r'photos')!,
|
photos: mapValueOfType<int>(json, r'photos')!,
|
||||||
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
|
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
|
||||||
usage: mapValueOfType<int>(json, r'usage')!,
|
usage: mapValueOfType<int>(json, r'usage')!,
|
||||||
|
usagePhotos: mapValueOfType<int>(json, r'usagePhotos')!,
|
||||||
|
usageVideos: mapValueOfType<int>(json, r'usageVideos')!,
|
||||||
userId: mapValueOfType<String>(json, r'userId')!,
|
userId: mapValueOfType<String>(json, r'userId')!,
|
||||||
userName: mapValueOfType<String>(json, r'userName')!,
|
userName: mapValueOfType<String>(json, r'userName')!,
|
||||||
videos: mapValueOfType<int>(json, r'videos')!,
|
videos: mapValueOfType<int>(json, r'videos')!,
|
||||||
@@ -135,6 +149,8 @@ class UsageByUserDto {
|
|||||||
'photos',
|
'photos',
|
||||||
'quotaSizeInBytes',
|
'quotaSizeInBytes',
|
||||||
'usage',
|
'usage',
|
||||||
|
'usagePhotos',
|
||||||
|
'usageVideos',
|
||||||
'userId',
|
'userId',
|
||||||
'userName',
|
'userName',
|
||||||
'videos',
|
'videos',
|
||||||
|
|||||||
@@ -378,10 +378,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: device_info_plus
|
name: device_info_plus
|
||||||
sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091
|
sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.0.0"
|
version: "11.1.1"
|
||||||
device_info_plus_platform_interface:
|
device_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -450,10 +450,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12"
|
sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.2"
|
version: "8.1.3"
|
||||||
file_selector_linux:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -548,10 +548,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.2.4"
|
version: "17.2.1+2"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1024,6 +1024,15 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.0.4"
|
||||||
|
native_video_player:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "."
|
||||||
|
ref: "68ea203"
|
||||||
|
resolved-ref: "68ea2030ba7aceb1bc44b683ff0b742fd1a52d2f"
|
||||||
|
url: "https://github.com/immich-app/native_video_player"
|
||||||
|
source: git
|
||||||
|
version: "1.3.1"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1067,10 +1076,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef"
|
sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.3"
|
version: "8.1.1"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1339,10 +1348,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: share_plus
|
name: share_plus
|
||||||
sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11
|
sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.3"
|
version: "10.1.2"
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ dependencies:
|
|||||||
path_provider: ^2.1.2
|
path_provider: ^2.1.2
|
||||||
collection: ^1.18.0
|
collection: ^1.18.0
|
||||||
http_parser: ^4.0.2
|
http_parser: ^4.0.2
|
||||||
flutter_web_auth: ^0.6.0
|
flutter_web_auth: 0.6.0
|
||||||
easy_image_viewer: ^1.4.0
|
easy_image_viewer: ^1.4.0
|
||||||
isar:
|
isar:
|
||||||
version: *isar_version
|
version: *isar_version
|
||||||
@@ -64,6 +64,10 @@ dependencies:
|
|||||||
async: ^2.11.0
|
async: ^2.11.0
|
||||||
dynamic_color: ^1.7.0 #package to apply system theme
|
dynamic_color: ^1.7.0 #package to apply system theme
|
||||||
background_downloader: ^8.5.5
|
background_downloader: ^8.5.5
|
||||||
|
native_video_player:
|
||||||
|
git:
|
||||||
|
url: https://github.com/immich-app/native_video_player
|
||||||
|
ref: 68ea203
|
||||||
|
|
||||||
#image editing packages
|
#image editing packages
|
||||||
crop_image: ^1.0.13
|
crop_image: ^1.0.13
|
||||||
|
|||||||
@@ -10966,7 +10966,9 @@
|
|||||||
{
|
{
|
||||||
"photos": 1,
|
"photos": 1,
|
||||||
"videos": 1,
|
"videos": 1,
|
||||||
"diskUsageRaw": 1
|
"diskUsageRaw": 2,
|
||||||
|
"usagePhotos": 1,
|
||||||
|
"usageVideos": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"items": {
|
"items": {
|
||||||
@@ -10975,6 +10977,16 @@
|
|||||||
"title": "Array of usage for each user",
|
"title": "Array of usage for each user",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"usagePhotos": {
|
||||||
|
"default": 0,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"usageVideos": {
|
||||||
|
"default": 0,
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"videos": {
|
"videos": {
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -10984,6 +10996,8 @@
|
|||||||
"photos",
|
"photos",
|
||||||
"usage",
|
"usage",
|
||||||
"usageByUser",
|
"usageByUser",
|
||||||
|
"usagePhotos",
|
||||||
|
"usageVideos",
|
||||||
"videos"
|
"videos"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -12503,6 +12517,14 @@
|
|||||||
"format": "int64",
|
"format": "int64",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"usagePhotos": {
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"usageVideos": {
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -12517,6 +12539,8 @@
|
|||||||
"photos",
|
"photos",
|
||||||
"quotaSizeInBytes",
|
"quotaSizeInBytes",
|
||||||
"usage",
|
"usage",
|
||||||
|
"usagePhotos",
|
||||||
|
"usageVideos",
|
||||||
"userId",
|
"userId",
|
||||||
"userName",
|
"userName",
|
||||||
"videos"
|
"videos"
|
||||||
|
|||||||
@@ -969,6 +969,8 @@ export type UsageByUserDto = {
|
|||||||
photos: number;
|
photos: number;
|
||||||
quotaSizeInBytes: number | null;
|
quotaSizeInBytes: number | null;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
usagePhotos: number;
|
||||||
|
usageVideos: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
videos: number;
|
videos: number;
|
||||||
@@ -977,6 +979,8 @@ export type ServerStatsResponseDto = {
|
|||||||
photos: number;
|
photos: number;
|
||||||
usage: number;
|
usage: number;
|
||||||
usageByUser: UsageByUserDto[];
|
usageByUser: UsageByUserDto[];
|
||||||
|
usagePhotos: number;
|
||||||
|
usageVideos: number;
|
||||||
videos: number;
|
videos: number;
|
||||||
};
|
};
|
||||||
export type ServerStorageResponseDto = {
|
export type ServerStorageResponseDto = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
|||||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
import { AlbumEntity } from 'src/entities/album.entity';
|
||||||
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
||||||
|
import { getAssetDateTime } from 'src/utils/date-time';
|
||||||
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class AlbumInfoDto {
|
export class AlbumInfoDto {
|
||||||
@@ -164,8 +165,8 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
|
|||||||
const hasSharedLink = entity.sharedLinks?.length > 0;
|
const hasSharedLink = entity.sharedLinks?.length > 0;
|
||||||
const hasSharedUser = sharedUsers.length > 0;
|
const hasSharedUser = sharedUsers.length > 0;
|
||||||
|
|
||||||
let startDate = assets.at(0)?.fileCreatedAt || undefined;
|
let startDate = getAssetDateTime(assets.at(0));
|
||||||
let endDate = assets.at(-1)?.fileCreatedAt || undefined;
|
let endDate = getAssetDateTime(assets.at(-1));
|
||||||
// Swap dates if start date is greater than end date.
|
// Swap dates if start date is greater than end date.
|
||||||
if (startDate && endDate && startDate > endDate) {
|
if (startDate && endDate && startDate > endDate) {
|
||||||
[startDate, endDate] = [endDate, startDate];
|
[startDate, endDate] = [endDate, startDate];
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export class UsageByUserDto {
|
|||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
usage!: number;
|
usage!: number;
|
||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
|
usagePhotos!: number;
|
||||||
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
|
usageVideos!: number;
|
||||||
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
quotaSizeInBytes!: number | null;
|
quotaSizeInBytes!: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +103,12 @@ export class ServerStatsResponseDto {
|
|||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
usage = 0;
|
usage = 0;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
|
usagePhotos = 0;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
|
usageVideos = 0;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
isArray: true,
|
isArray: true,
|
||||||
type: UsageByUserDto,
|
type: UsageByUserDto,
|
||||||
@@ -107,7 +117,9 @@ export class ServerStatsResponseDto {
|
|||||||
{
|
{
|
||||||
photos: 1,
|
photos: 1,
|
||||||
videos: 1,
|
videos: 1,
|
||||||
diskUsageRaw: 1,
|
diskUsageRaw: 2,
|
||||||
|
usagePhotos: 1,
|
||||||
|
usageVideos: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -114,7 +114,12 @@ export interface ImageBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoCodecSWConfig {
|
export interface VideoCodecSWConfig {
|
||||||
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand;
|
getCommand(
|
||||||
|
target: TranscodeTarget,
|
||||||
|
videoStream: VideoStreamInfo,
|
||||||
|
audioStream: AudioStreamInfo,
|
||||||
|
format?: VideoFormat,
|
||||||
|
): TranscodeCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
|
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface UserStatsQueryResponse {
|
|||||||
photos: number;
|
photos: number;
|
||||||
videos: number;
|
videos: number;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
usagePhotos: number;
|
||||||
|
usageVideos: number;
|
||||||
quotaSizeInBytes: number | null;
|
quotaSizeInBytes: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,23 @@ SELECT
|
|||||||
"assets"."libraryId" IS NULL
|
"assets"."libraryId" IS NULL
|
||||||
),
|
),
|
||||||
0
|
0
|
||||||
) AS "usage"
|
) AS "usage",
|
||||||
|
COALESCE(
|
||||||
|
SUM("exif"."fileSizeInByte") FILTER (
|
||||||
|
WHERE
|
||||||
|
"assets"."libraryId" IS NULL
|
||||||
|
AND "assets"."type" = 'IMAGE'
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS "usagePhotos",
|
||||||
|
COALESCE(
|
||||||
|
SUM("exif"."fileSizeInByte") FILTER (
|
||||||
|
WHERE
|
||||||
|
"assets"."libraryId" IS NULL
|
||||||
|
AND "assets"."type" = 'VIDEO'
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS "usageVideos"
|
||||||
FROM
|
FROM
|
||||||
"users" "users"
|
"users" "users"
|
||||||
LEFT JOIN "assets" "assets" ON "assets"."ownerId" = "users"."id"
|
LEFT JOIN "assets" "assets" ON "assets"."ownerId" = "users"."id"
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ export class UserRepository implements IUserRepository {
|
|||||||
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
|
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
|
||||||
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
|
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
|
||||||
.addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage')
|
.addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage')
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'IMAGE'), 0)`,
|
||||||
|
'usagePhotos',
|
||||||
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'VIDEO'), 0)`,
|
||||||
|
'usageVideos',
|
||||||
|
)
|
||||||
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
|
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
|
||||||
.leftJoin('users.assets', 'assets')
|
.leftJoin('users.assets', 'assets')
|
||||||
.leftJoin('assets.exifInfo', 'exif')
|
.leftJoin('assets.exifInfo', 'exif')
|
||||||
@@ -119,6 +127,8 @@ export class UserRepository implements IUserRepository {
|
|||||||
stat.photos = Number(stat.photos);
|
stat.photos = Number(stat.photos);
|
||||||
stat.videos = Number(stat.videos);
|
stat.videos = Number(stat.videos);
|
||||||
stat.usage = Number(stat.usage);
|
stat.usage = Number(stat.usage);
|
||||||
|
stat.usagePhotos = Number(stat.usagePhotos);
|
||||||
|
stat.usageVideos = Number(stat.usageVideos);
|
||||||
stat.quotaSizeInBytes = stat.quotaSizeInBytes;
|
stat.quotaSizeInBytes = stat.quotaSizeInBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||