Compare commits

..

118 Commits

Author SHA1 Message Date
mertalev
d414c2789a fix looping 2024-12-01 16:11:26 -05:00
mertalev
b15e01e2b6 fix hdr playback on android 2024-12-01 00:14:55 -05:00
mertalev
fa3156e6ee remove video_player 2024-11-21 20:24:34 -05:00
mertalev
2ece3d3822 fix map marker -> galleryviewer 2024-11-18 10:34:16 -05:00
mertalev
128f19efa5 fix live photo play button not updating 2024-11-18 10:21:38 -05:00
mertalev
20b1572b8e remove unused import 2024-11-17 16:18:39 -05:00
mertalev
2ab1bbabac increase bottom bar opacity to account for hdr 2024-11-17 16:17:34 -05:00
mertalev
7e74b8d1bb fix no audio in silent mode on ios 2024-11-17 15:51:37 -05:00
mertalev
dec514bd6d local orientation on ios is unreliable; prefer remote 2024-11-17 14:59:39 -05:00
mertalev
0a77a65044 refactor: move exif search from aspect ratio to orientation 2024-11-17 13:53:06 -05:00
mertalev
4d1d902773 make aspect ratio logic reusable, optimizations 2024-11-17 10:45:19 -05:00
mertalev
60715059f7 ensure dimensions for memory cards 2024-11-17 00:02:22 -05:00
mertalev
aa890c0858 faster initialization for remote videos 2024-11-16 23:51:48 -05:00
mertalev
03211c43f9 increase delay for hero animation 2024-11-16 23:34:48 -05:00
mertalev
381fe5c2fd maybe fix duplicate key error 2024-11-16 22:26:22 -05:00
mertalev
937fb4e30e fix duplicate key error 2024-11-16 21:31:13 -05:00
mertalev
71e058af2e error handling for async calls 2024-11-16 20:57:42 -05:00
mertalev
6c8f7b7e6d use visibility for motion videos 2024-11-16 20:35:11 -05:00
mertalev
b0a2a6ac13 improved motion photo handling 2024-11-16 20:19:08 -05:00
mertalev
d1c7ed5464 fix stack handling 2024-11-16 20:19:08 -05:00
mertalev
3b9a3d4037 use current asset provider and loadAsset 2024-11-16 20:19:08 -05:00
Alex
613ce513cd chore: generate router page 2024-11-16 20:19:08 -05:00
mertalev
caee381721 extra smooth seeking, add comments 2024-11-16 20:19:08 -05:00
mertalev
e59912e16e linting 2024-11-16 20:19:08 -05:00
mertalev
0010eda67f fix controls when swiping between image and video 2024-11-16 20:19:08 -05:00
mertalev
190dbb0042 await things 2024-11-16 20:19:08 -05:00
mertalev
dbca16e352 slightly lower delay 2024-11-16 20:19:08 -05:00
mertalev
64e23a3b5c higher res placeholder for local videos 2024-11-16 20:19:08 -05:00
Alex
5766551447 use correct ref to build android 2024-11-16 20:19:08 -05:00
mertalev
7d5294f7ef fixed referencing uninitialized orientation 2024-11-16 20:19:08 -05:00
Alex
973b146d06 transitive dependencies 2024-11-16 20:19:08 -05:00
mertalev
3da28a4685 removed unnecessary import 2024-11-16 20:19:08 -05:00
mertalev
de61abb3aa refactor aspect ratio calculation 2024-11-16 20:19:08 -05:00
mertalev
bef9a1eae7 clean up logging 2024-11-16 20:19:08 -05:00
mertalev
49c4d7cff9 refinements and fixes
fix orientation for remote assets

wip separate widget

separate video loader widget

fixed memory leak

optimized seeking, cleanup

debug context pop

use global key

back to one widget

fixed rebuild

wait for swipe animation to finish

smooth hero animation for remote videos

faster scroll animation
2024-11-16 20:19:08 -05:00
shenlong-tanwen
3272ad4a7b fix: handle remote asset orientation 2024-11-16 20:19:08 -05:00
shenlong-tanwen
46a8e9084a fix: aspect ratio 2024-11-16 20:19:08 -05:00
Alex
5e8ee6dc5f turn on volume when video plays 2024-11-16 20:19:08 -05:00
shenlong-tanwen
9c470def18 fix: handle buffering 2024-11-16 20:19:08 -05:00
shenlong-tanwen
5ebac69647 refactor: native_video_player 2024-11-16 20:19:08 -05:00
Alex
6f3ceb58b8 stateful widget 2024-11-16 20:19:08 -05:00
Alex
a346a37743 splitup the player 2024-11-16 20:19:08 -05:00
Alex
a8994ffb22 add native player library 2024-11-16 20:19:08 -05:00
Matthew Momjian
4b5657c21e docs: get asset owner by ID from database (#14174)
asset owner
2024-11-16 14:43:32 -05:00
weathondev
f5c4af73aa feat: adding photo & video storage space to server stats (#14125)
* expose detailed user storage stats + display them in the storage per user table

* chore: openapi & sql

* fix: fix test stubs

* fix: formatting errors, e2e test and server test

* fix: upper lower case typo in spec file

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-11-15 16:38:57 -06:00
Michel Heusschen
24ae4ecff1 fix(web): don't refresh panorama viewer when modifying asset (#14163) 2024-11-15 16:30:38 -06:00
Michel Heusschen
64a7baec8c refactor(web): remove reactivity triggers (#14164) 2024-11-15 16:28:28 -06:00
Mert
caf6c0996d refactor(mobile): backup info box (#14171)
split up backup info box into separate widgets
2024-11-15 17:16:52 -05:00
Lena Tauchner
6729782c3f fix(cli): Concurrency not fully using queue potential (#11828) 2024-11-15 13:09:56 -05:00
Michel Heusschen
a60209db3e fix(web): prevent infinite loop when modifying stacked asset (#14162) 2024-11-15 07:16:56 -05:00
renovate[bot]
d1169e3b2f chore(deps): update prom/prometheus docker digest to 3b9b2a1 (#14154)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-14 18:56:37 -05:00
Ben
df972ef711 feat(web): Added tag button to the context menu in the favorites page (#14156)
Added tag action to the context menu in the favorites page when selection is active
2024-11-14 16:06:36 -06:00
Alex
33263cf9f3 fix(mobile): Android local notification failes to invoke (#14155)
* fix(mobile): local notification failed to invoke

* add proguard rules
2024-11-14 16:05:32 -06:00
Michel Heusschen
1b5811d992 fix(web): allow selecting people after clearing search options (#14146) 2024-11-14 09:59:50 -06:00
Michel Heusschen
1fa0122eda fix(web): update description height when navigating between assets (#14145) 2024-11-14 09:59:30 -06:00
Alex
d1085e8a02 chore(web): move enum out of .svelte file (#14144)
* chore(web): clean up todo task

* chore(web): move enums out of .svelte file
2024-11-14 15:41:11 +00:00
Michel Heusschen
d6a70bc7e5 fix(web): saving pasted coordinates (#14143) 2024-11-14 15:21:40 +00:00
Michel Heusschen
d3fe238eef fix(web): ensure current asset index stays within bounds (#14013) 2024-11-14 09:05:36 -06:00
Pranay Pandey
35f24270fe fix: Routing back button in sharedLinks page (#13703)
* fix: go back to last page from shared links page. Handle albums page from shared links page routing

* add default route for sharing

* chore: remove redundant import

* remove unnecessary comment

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-11-14 15:00:33 +00:00
Đức
1f1a4ab1a3 fix(web): textarea autogrow height (#13983)
fix(web): remove album assetGridWidth & globalWidth

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-11-14 15:00:18 +00:00
Alex
0b3742cf13 chore(web): migration svelte 5 syntax (#13883) 2024-11-14 08:43:25 -06:00
Lukas
9203a61709 fix(server): Some MTS videos fail to generate thumbnail (#14134)
* Stop skipping of all frames in MTS video

* Only skip flag for mts videos

* Fix lint checks

* Adds test

* Add comment for why flag is removed
2024-11-14 07:07:04 +00:00
Mert
11403abfbc feat(mobile): new video slider ui (#14126) 2024-11-13 19:49:25 -05:00
mcarbonne
5a2af558fb feat: add minimal devcontainer setup (#14038)
* add minimal devcontainer setup

* fix Makefile & update doc

* fix Makefile

* add warning regarding devcontainer + add newline at EOF
2024-11-13 11:28:07 -06:00
John Stef
de993289ad fix(mobile): fix logout timeout (#14104)
* fix(mobile): add timeout to logout

* chore(mobile): refactor timeout durations

* feat(mobile): add loading state to logout button

* chore(mobile): format authentication.provider.dart

* chore: format

* chore: revert settings.json change

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2024-11-13 17:27:49 +00:00
Nicholas Flamy
c58bd307ce docs: Update TrueNAS docs for TrueNAS SCALE 24.10 (#14067)
* initial-docs-update

* add-info-about-external-libraries
2024-11-13 11:26:23 -06:00
Pablo Molina
333ca8827e feat: use dateTimeOriginal to calculate album date (#14119) 2024-11-13 11:17:14 -06:00
Alex
3dad19883d fix(mobile): duration ui overflow (#14120)
* fix(mobile): duration ui overflow

* pr feedback
2024-11-13 09:39:21 -06:00
renovate[bot]
4ca27a3e7f chore(deps): update redis:6.2-alpine docker digest to eaba718 (#14114)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 07:12:30 -05:00
renovate[bot]
b0bb11f9e0 chore(deps): update docker.io/redis:6.2-alpine docker digest to eaba718 (#14113)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 07:12:06 -05:00
Xuesong
ecb8349085 chore(docs): encode db dump in UTF-8 without BOM for Windows (#13775) 2024-11-13 10:05:53 +00:00
Mert
e1feba2198 refactor(mobile): video controls (#14086)
* refactor video controls

* inline

* make mute icon const

* move placeholder to private widget

* adjust text width, move volume button slightly right
2024-11-13 01:13:21 -05:00
renovate[bot]
53a7ac3868 chore(deps): update prom/prometheus docker digest to 2659f4c (#13928)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-12 23:30:06 -05:00
dependabot[bot]
f2e950d89c chore(deps): bump ytanikin/PRConventionalCommits from 1.2.0 to 1.3.0 (#13051)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 01:27:37 +00:00
renovate[bot]
8ba2c99b08 chore(deps): update docker.io/redis:6.2-alpine docker digest to 77c6e37 (#14097)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 01:15:45 +00:00
renovate[bot]
93346496fc chore(deps): update redis:6.2-alpine docker digest to 77c6e37 (#14098)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-13 01:15:30 +00:00
Alex
a9525de356 chore(mobile): post release tasks (#14105) 2024-11-12 14:34:33 -05:00
github-actions
31a1e64b58 chore: version v1.120.2 2024-11-12 17:30:29 +00:00
Zack Pollard
e17bd8efc6 fix(server): backup version checks not handling database versions correctly (#14102) 2024-11-12 10:57:05 -06:00
Alex
2f9019c0e1 fix(server): correct rotation for common files (#14092)
* fix(server): correct rotation for common files

* fix: test:

* pr feedback
2024-11-12 15:07:56 +00:00
Zack Pollard
dfa8a8a6e1 feat(server): use pg_dumpall version that matches the database version (#14083) 2024-11-12 14:58:29 +00:00
renovate[bot]
b9a0c3c79f chore(deps): update base-image to v20241112 (major) (#14088)
chore(deps): update base-image to v20241112

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-12 12:49:31 +00:00
renovate[bot]
bda97c4e0e chore(deps): update node (#14090)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-12 07:32:52 -05:00
Robert Schütz
e3426c880f chore(ml): replace fastapi-slim with fastapi (#14091)
The two have been identical since version 0.112.0:
https://github.com/fastapi/fastapi/discussions/11525#discussioncomment-10219861
2024-11-11 23:08:29 -05:00
Zack Pollard
d4ca7d0075 fix: config updates not applying for job and storage template service (#14074) 2024-11-11 12:50:09 +00:00
Zack Pollard
f1c9b763cf docs: backup folder name is backups (#14073) 2024-11-11 12:28:53 +00:00
Zack Pollard
5097c92494 fix(server): attempt to delete failed backups immediately after failure (#13995) 2024-11-11 12:08:52 +00:00
gamescom15
7aacc92699 docs: clarify file size impact in hardware-transcoding.md (#14049) 2024-11-11 03:51:00 +00:00
Daniel Dietzler
00d6cc86ad chore: add weblate requests (#14051) 2024-11-10 15:49:23 -05:00
Joren Guillaume
54d881e5c6 docs: Fix DCM docs link (#14059)
Fix DCM docs link
2024-11-10 13:33:51 -05:00
Snowknight26
edce096680 chore(web): Update the new version announcement text (#14001)
* Update en.json

* Update en.json

* Update en.json
2024-11-09 12:15:25 -06:00
mcarbonne
5c31acbcf0 feat(web): stable json settings export (#14036)
* recursively sort json output (settings)

* fix format/lint/...g
2024-11-09 12:11:20 -06:00
Alex
6b49104d59 fix(mobile): make sure date locale is inititialized for some languages (#14035) 2024-11-09 10:40:13 -05:00
Ben
97dbe3236b chore(docs): roadmap SEO (#14024) 2024-11-08 19:48:23 -05:00
Michel Heusschen
586393f178 fix(web): use locale for scrubber label when scrolling (#14012) 2024-11-08 15:36:26 -05:00
bo0tzz
f3e88ea2fa docs: make IGNORE_MOUNT_CHECK warning stronger (#14011) 2024-11-08 13:29:10 +00:00
Terry Zhao
c8b46802d6 fix(server): thumbnail rotation when using embedded previews (#13948) 2024-11-08 06:30:59 +00:00
Jason Rasmussen
7534098596 fix(server): support non-default postgres port when taking a backup (#13992) 2024-11-07 20:06:16 +00:00
Zack Pollard
ec5b7c266b chore: backups custom location and config file docs (#13996) 2024-11-07 18:08:02 +00:00
Jason Rasmussen
e84ad084d5 refactor(server): cron validation (#13990) 2024-11-07 17:27:52 +00:00
Jason Rasmussen
dc2de47204 refactor(server): cron repository (#13988) 2024-11-07 12:15:54 -05:00
Alex
2fe6607aea chore(mobile): post release tasks (#13989) 2024-11-07 10:27:28 -06:00
Jason Rasmussen
64831e2328 refactor: remove smart info table (#13985) 2024-11-07 11:25:10 -05:00
Alex
6053214e75 chore(mobile): update isar build (#13987) 2024-11-07 09:54:19 -06:00
github-actions
599b489f81 chore: version v1.120.1 2024-11-07 15:31:19 +00:00
Jason Rasmussen
0b98c5e3c4 fix(web): time zone dependent test (#13859) 2024-11-07 10:05:55 -05:00
Alex
b238b69689 fix(mobile): video player not playing in full size on Android (#13986) 2024-11-07 15:04:20 +00:00
Jason Rasmussen
decbc741e2 docs: update roadmap (#13984) 2024-11-07 09:24:21 -05:00
Sefa Eyeoglu
564449a555 fix(server): database backups compatible with deduplication (#13965)
gzip --rsyncable has a slightly worse compression ratio, but allows for
efficient deduplication and, as the name implies, faster rsync
operations.

Signed-off-by: Sefa Eyeoglu <contact@scrumplex.net>
2024-11-07 12:36:17 +00:00
Robert Schütz
f4741c70f3 fix(server): allow starting backup through API and fix pg_dumpall args when using database URLs (#13970)
* fix(server): allow starting backup through API

* fix(server): fix pg_dumpall args when using database URLs

The database has to be specified using `-d`, unlike for pg_dump.
2024-11-07 11:57:36 +00:00
yodatak
be2b76be8c docs: add backups to startup folders list (#13967)
Add the check of backups that is done on immich microservice of backups folder presence
2024-11-07 04:18:14 +00:00
Alex
cff0b95f4c chore(mobile): post release task (#13954) 2024-11-06 17:57:45 -05:00
Daniel Dietzler
1321a393c1 docs: 50k stars (#13964) 2024-11-06 22:49:18 +01:00
bo0tzz
a9fc840d65 chore: tidy up backup-and-restore.md (#13961) 2024-11-06 22:18:55 +01:00
Alex
ebf06dc12e fix(server): cannot render email template (#13957) 2024-11-06 22:14:11 +01:00
Thariq Shanavas
8d8becd0f7 docs: Added a note about avoiding redundant database backups (#13958)
* Add note about built-in backups

* npm run format:fix
2024-11-06 15:09:53 -06:00
slamp
3b5f5ec57a docs: improve custom-locations wording to be easier to read (#13849)
* Improve wording to make it easier to read custom-locations.md

It's only grammatical change

* Update docs/docs/guides/custom-locations.md

Co-authored-by: bo0tzz <git@bo0tzz.me>

* Update custom-locations.md

Revert to 'because of' and remove 'hard drive'

---------

Co-authored-by: bo0tzz <git@bo0tzz.me>
2024-11-06 19:49:23 +00:00
Daniel Dietzler
b29e4ec39f fix: docker link (#13956) 2024-11-06 13:45:52 -06:00
499 changed files with 10022 additions and 6826 deletions

2
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,2 @@
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22
FROM ${BASEIMAGE}

View File

@@ -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"
}

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: PR Conventional Commit Validation
uses: ytanikin/PRConventionalCommits@1.2.0
uses: ytanikin/PRConventionalCommits@1.3.0
with:
task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]'
add_label: 'false'

View File

@@ -41,4 +41,4 @@
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
}
}
}

View File

@@ -39,7 +39,7 @@ attach-server:
renovate:
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
MODULES = e2e server web cli sdk
MODULES = e2e server web cli sdk docs
audit-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
@@ -48,11 +48,9 @@ install-%:
build-cli: build-sdk
build-web: build-sdk
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 || true
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
format-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'format:fix' >/dev/null \
&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run format:fix || true
npm --prefix $* run format:fix
lint-%:
npm --prefix $* run lint:fix
check-%:
@@ -79,14 +77,14 @@ test-medium:
test-medium-dev:
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) ;
check-all: $(foreach M,$(MODULES),check-$M) ;
lint-all: $(foreach M,$(MODULES),lint-$M) ;
format-all: $(foreach M,$(MODULES),format-$M) ;
check-all: $(foreach M,$(filter-out sdk cli docs,$(MODULES)),check-$M) ;
lint-all: $(foreach M,$(filter-out sdk docs,$(MODULES)),lint-$M) ;
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
audit-all: $(foreach M,$(MODULES),audit-$M) ;
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:
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +

View File

@@ -1,4 +1,4 @@
FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS core
FROM node:22.11.0-alpine3.20@sha256:dc8ba2f61dd86c44e43eb25a7812ad03c5b1b224a19fc6f77e1eb9e5669f0b82 AS core
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

10
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.29",
"version": "2.2.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.29",
"version": "2.2.31",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@@ -24,7 +24,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",
@@ -52,14 +52,14 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.120.0",
"version": "1.120.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"typescript": "^5.3.3"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.29",
"version": "2.2.31",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -20,7 +20,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",

View File

@@ -1,5 +1,6 @@
import {
Action,
AssetBulkUploadCheckItem,
AssetBulkUploadCheckResult,
AssetMediaResponseDto,
AssetMediaStatus,
@@ -11,7 +12,7 @@ import {
getSupportedMediaTypes,
} from '@immich/sdk';
import byteSize from 'byte-size';
import { Presets, SingleBar } from 'cli-progress';
import { MultiBar, Presets, SingleBar } from 'cli-progress';
import { chunk } from 'lodash-es';
import { Stats, createReadStream } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
@@ -90,23 +91,23 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
return { newFiles: files, duplicates: [] };
}
const progressBar = new SingleBar(
{ format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
const multiBar = new MultiBar(
{ format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
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 duplicates: Asset[] = [];
const queue = new Queue<string[], AssetBulkUploadCheckResults>(
async (filepaths: string[]) => {
const dto = await Promise.all(
filepaths.map(async (filepath) => ({ id: filepath, checksum: await sha1(filepath) })),
);
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: dto } });
const checkBulkUploadQueue = new Queue<AssetBulkUploadCheckItem[], void>(
async (assets: AssetBulkUploadCheckItem[]) => {
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets } });
const results = response.results as AssetBulkUploadCheckResults;
for (const { id: filepath, assetId, action } of results) {
if (action === Action.Accept) {
newFiles.push(filepath);
@@ -115,19 +116,46 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
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;
},
{ concurrency, retry: 3 },
);
for (const items of chunk(files, concurrency)) {
await queue.push(items);
for (const item of files) {
void queue.push(item);
}
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)}`);
@@ -201,8 +229,8 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo
{ concurrency, retry: 3 },
);
for (const filepath of files) {
await queue.push(filepath);
for (const item of files) {
void queue.push(item);
}
await queue.drained();

View File

@@ -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.
* This promise could be ignored as it will not lead to a `unhandledRejection`.
*/
async drained(): Promise<void> {
await this.queue.drain();
drained(): Promise<void> {
return this.queue.drained();
}
/**

View File

@@ -103,7 +103,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -47,7 +47,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -94,7 +94,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:378f4e03703557d1c6419e6caccf922f96e6d88a530f7431d66a4c4f4b1000fe
image: prom/prometheus@sha256:3b9b2a15d376334da8c286d995777d3b9315aa666d2311170ada6059a517b74f
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus

View File

@@ -48,7 +48,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
image: docker.io/redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -15,8 +15,6 @@ Immich saves [file paths in the database](https://github.com/immich-app/immich/d
Refer to the official [postgres documentation](https://www.postgresql.org/docs/current/backup.html) for details about backing up and restoring a postgres database.
:::
The recommended way to backup and restore the Immich database is to use the `pg_dumpall` command. When restoring, you need to delete the `DB_DATA_LOCATION` folder (if it exists) to reset the database.
:::caution
It is not recommended to directly backup the `DB_DATA_LOCATION` folder. Doing so while the database is running can lead to a corrupted backup that cannot be restored.
:::
@@ -60,7 +58,7 @@ docker compose up -d # Start remainder of Immich apps
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
```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'
@@ -79,53 +77,10 @@ docker compose up -d # Start remainder of Immich apps
</TabItem>
</Tabs>
Note that for the database restore to proceed properly, it requires a completely fresh install (i.e. the Immich server has never run since creating the Docker containers). If the Immich app has run, Postgres conflicts may be encountered upon database restoration (relation already exists, violated foreign key constraints, multiple primary keys, etc.).
Note that for the database restore to proceed properly, it requires a completely fresh install (i.e. the Immich server has never run since creating the Docker containers). If the Immich app has run, Postgres conflicts may be encountered upon database restoration (relation already exists, violated foreign key constraints, multiple primary keys, etc.), in which case you need to delete the `DB_DATA_LOCATION` folder to reset the database.
:::tip
Some deployment methods make it difficult to start the database without also starting the server or microservices. In these cases, you may set the environmental variable `DB_SKIP_MIGRATIONS=true` before starting the services. This will prevent the server from running migrations that interfere with the restore process. Note that both the server and microservices must have this variable set to prevent the migrations from running. Be sure to remove this variable and restart the services after the database is restored.
:::
### Automatic Database Backups
The database dumps can also be automated (using [this image](https://github.com/prodrigestivill/docker-postgres-backup-local)) by editing the docker compose file to match the following:
```yaml
services:
...
backup:
container_name: immich_db_dumper
image: prodrigestivill/postgres-backup-local:14
restart: always
env_file:
- .env
environment:
POSTGRES_HOST: database
POSTGRES_CLUSTER: 'TRUE'
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_DATABASE_NAME}
SCHEDULE: "@daily"
POSTGRES_EXTRA_OPTS: '--clean --if-exists'
BACKUP_DIR: /db_dumps
volumes:
- ./db_dumps:/db_dumps
depends_on:
- database
```
Then you can restore with the same command but pointed at the latest dump.
```bash title='Automated Restore'
# Be sure to check the username if you changed it from default
gunzip < db_dumps/last/immich-latest.sql.gz \
| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \
| docker exec -i immich_postgres psql --username=postgres
```
:::note
If you see the error `ERROR: type "earth" does not exist`, or you have problems with Reverse Geocoding after a restore, add the following `sed` fragment to your restore command.
Example: `gunzip < "/path/to/backup/dump.sql.gz" | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | docker exec -i immich_postgres psql --username=postgres`
Some deployment methods make it difficult to start the database without also starting the server. In these cases, you may set the environment variable `DB_SKIP_MIGRATIONS=true` before starting the services. This will prevent the server from running migrations that interfere with the restore process. Be sure to remove this variable and restart the services after the database is restored.
:::
## Filesystem

View File

@@ -3,7 +3,7 @@
## Folder checks
:::info
The folders considered for these checks include: `upload/`, `library/`, `thumbs/`, `encoded-video/`, `profile/`
The folders considered for these checks include: `upload/`, `library/`, `thumbs/`, `encoded-video/`, `profile/`, `backups/`
:::
When Immich starts, it performs a series of checks in order to validate that it can read and write files to the volume mounts used by the storage system. If it cannot perform all the required operations, it will fail to start. The checks include:
@@ -40,7 +40,9 @@ The above error messages show that the server has previously (successfully) writ
### Ignoring the checks
The checks are designed to catch common problems that we have seen users have in the past, but if you want to disable them you can set the following environment variable:
:::warning
The checks are designed to catch common problems that we have seen users have in the past, and often indicate there's something wrong that you should solve. If you know what you're doing and you want to disable them you can set the following environment variable:
:::
```
IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true

View File

@@ -1,5 +1,9 @@
# 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:
## Web Checks

View File

@@ -76,7 +76,7 @@ Setting these in the IDE give a better developer experience, auto-formatting cod
### Dart Code Metrics
The mobile app uses DCM (Dart Code Metrics) for linting and metrics calculation. Please refer to the [Getting Started](https://dcm.dev/docs/getting-started/#installation) page for more information on setting up DCM
The mobile app uses DCM (Dart Code Metrics) for linting and metrics calculation. Please refer to the [Getting Started](https://dcm.dev/docs/) page for more information on setting up DCM
Note: Activating the license is not required.

View File

@@ -1,7 +1,7 @@
# Hardware Transcoding [Experimental]
This feature allows you to use a GPU to accelerate transcoding and reduce CPU load.
Note that hardware transcoding is much less efficient for file sizes.
Note that hardware transcoding produces significantly larger videos than software transcoding with similar settings, typically with lower quality. Using slow presets and preferring more efficient codecs can narrow this gap.
As this is a new feature, it is still experimental and may not work on all systems.
:::info

View File

@@ -1,15 +1,15 @@
# Files Custom Locations
This guide explains storing generated and raw files with docker's volume mount in different locations.
This guide explains how to store generated and raw files with docker's volume mount in different locations.
:::caution Backup
It is important to remember to update the backup settings after following the guide to back up the new backup paths if using automatic backup tools, especially `profile/`.
:::
In our `.env` file, we will define variables that will help us in the future when we want to move to a more advanced server in the future
In our `.env` file, we will define variables that will help us in the future when we want to move to a more advanced server
```diff title=".env"
# You can find documentation for all the supported env variables [here](/docs/install/environment-variables)
# You can find documentation for all the supported environment variables [here](/docs/install/environment-variables)
# Custom location where your uploaded, thumbnails, and transcoded video files are stored
- UPLOAD_LOCATION=./library
@@ -17,10 +17,11 @@ In our `.env` file, we will define variables that will help us in the future whe
+ THUMB_LOCATION=/custom/path/immich/thumbs
+ ENCODED_VIDEO_LOCATION=/custom/path/immich/encoded-video
+ PROFILE_LOCATION=/custom/path/immich/profile
+ BACKUP_LOCATION=/custom/path/immich/backups
...
```
After defining the locations for these files, we will edit the `docker-compose.yml` file accordingly and add the new variables to the `immich-server` container.
After defining the locations of these files, we will edit the `docker-compose.yml` file accordingly and add the new variables to the `immich-server` container.
```diff title="docker-compose.yml"
services:
@@ -30,6 +31,7 @@ services:
+ - ${THUMB_LOCATION}:/usr/src/app/upload/thumbs
+ - ${ENCODED_VIDEO_LOCATION}:/usr/src/app/upload/encoded-video
+ - ${PROFILE_LOCATION}:/usr/src/app/upload/profile
+ - ${BACKUP_LOCATION}:/usr/src/app/upload/backups
- /etc/localtime:/etc/localtime:ro
```
@@ -41,12 +43,11 @@ docker compose up -d
:::note
Because of the underlying properties of docker bind mounts, it is not recommended to mount the `upload/` and `library/` folders as separate bind mounts if they are on the same device.
For this reason, we mount the HDD or network storage to `/usr/src/app/upload` and then mount the folders we want quick access to below this folder.
For this reason, we mount the HDD or the network storage (NAS) to `/usr/src/app/upload` and then mount the folders we want to access under that folder.
The `thumbs/` folder contains both the small thumbnails shown in the timeline, and the larger previews shown when clicking into an image. These cannot be split up.
The `thumbs/` folder contains both the small thumbnails displayed in the timeline and the larger previews shown when clicking into an image. These cannot be separated.
The storage metrics of the Immich server will track the storage available at `UPLOAD_LOCATION`,
so the administrator should setup some kind of monitoring to make sure the SSD does not run out of space. The `profile/` folder is much smaller, typically less than 1 MB.
The storage metrics of the Immich server will track available storage at `UPLOAD_LOCATION`, so the administrator must set up some sort of monitoring to ensure the storage does not run out of space. The `profile/` folder is much smaller, usually less than 1 MB.
:::
Thanks to [Jrasm91](https://github.com/immich-app/immich/discussions/2110#discussioncomment-5477767) for writing the guide.

View File

@@ -98,6 +98,10 @@ SELECT * FROM "move_history";
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
```sql title="Custom settings"

View File

@@ -6,6 +6,15 @@ This script assumes you have a second hard drive connected to your server for on
The database is saved to your Immich upload folder in the `database-backup` subdirectory. The database is then backed up and versioned with your assets by Borg. This ensures that the database backup is in sync with your assets in every snapshot.
:::info
This script makes backups of your database along with your photo/video library. This is redundant with the [automatic database backup tool](https://immich.app/docs/administration/backup-and-restore#automatic-database-backups) built into Immich. Using this script to backup your database has two advantages over the built-in backup tool:
- This script uses storage more efficiently by versioning your backups instead of making multiple copies.
- The database backups are performed at the same time as the library backup, ensuring that the backups of your database and the library are always in sync.
If you are using this script, it is therefore safe to turn off the built-in automatic database backups from your admin panel to save storage space.
:::
### Prerequisites
- Borg needs to be installed on your server as well as the remote machine. You can find instructions to install Borg [here](https://borgbackup.readthedocs.io/en/latest/installation.html).

View File

@@ -35,6 +35,13 @@ The default configuration looks like this:
"accel": "disabled",
"accelDecode": false
},
"backup": {
"database": {
"enabled": true,
"cronExpression": "0 02 * * *",
"keepLastAmount": 14
}
},
"job": {
"backgroundTask": {
"concurrency": 5

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
## Software
- [Docker](https://docs.docker.com/get-docker/)
- [Docker](https://docs.docker.com/engine/install/)
- [Docker Compose](https://docs.docker.com/compose/install/)
:::note

View File

@@ -7,7 +7,9 @@ sidebar_position: 80
:::note
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.
@@ -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.
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.
You can configure environment variables at any time after deploying the application.
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 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.
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**.
You can organize these as one parent with seven child datasets, for example `mnt/tank/immich/library`, `mnt/tank/immich/pgBackup`, and so on.
### Setting up Storage Datasets
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`, `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
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
@@ -47,6 +57,8 @@ className="border rounded-xl"
Click on the widget to open the **Immich** application details screen.
<br/><br/>
<img
src={require('./img/truenas02.png').default}
width="100%"
@@ -56,9 +68,13 @@ className="border rounded-xl"
Click **Install** to open the Immich application configuration screen.
<br/><br/>
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.
### Application Name and Version
<img
src={require('./img/truenas03.png').default}
width="100%"
@@ -66,21 +82,123 @@ alt="Install Immich Screen"
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.
**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.
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.
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.
:::
## Editing Environment Variables
## Edit App Settings
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.
The settings on the edit screen are the same as on the install screen.
You cannot edit **Storage Configuration** paths after the initial app install.
- 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.
- Change any settings you would like to change.
- 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.
TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated environment variables.
## 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
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.
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.
## Understanding Immich Settings in TrueNAS SCALE
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 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.
- 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.
- This opens an update window with some options
- You may select an Image update too.
- You may view the Changelog.
- 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.

View File

@@ -49,7 +49,7 @@ export function Timeline({ items }: Props): JSX.Element {
<div className="flex flex-col flex-grow justify-between gap-2">
<div className="flex gap-2 items-center">
{cardIcon === 'immich' ? (
<img src="img/immich-logo.svg" height="30" className="rounded-none" />
<img src="/img/immich-logo.svg" height="30" className="rounded-none" />
) : (
<Icon path={cardIcon} size={1} color={item.iconColor} />
)}

View File

@@ -74,12 +74,14 @@ import {
mdiFaceRecognition,
mdiVideo,
mdiWeb,
mdiDatabaseOutline,
} from '@mdi/js';
import Layout from '@theme/Layout';
import React from 'react';
import { Item, Timeline } from '../components/timeline';
const releases = {
'v1.120.0': new Date(2024, 10, 6),
'v1.114.0': new Date(2024, 8, 6),
'v1.113.0': new Date(2024, 7, 30),
'v1.112.0': new Date(2024, 7, 14),
@@ -151,6 +153,9 @@ const weirdTags = {
'v1.2.0': 'v0.2-dev ',
};
const title = 'Roadmap';
const description = 'A list of future plans and goals, as well as past achievements and milestones.';
const withLanguage = (date: Date) => (language: string) => date.toLocaleDateString(language);
type Base = { icon: string; iconColor?: React.CSSProperties['color']; title: string; description: string };
@@ -175,6 +180,38 @@ const withRelease = ({
};
const roadmap: Item[] = [
{
done: false,
icon: mdiFlash,
iconColor: 'gold',
title: 'Workflows',
description: 'Automate tasks with workflows',
getDateLabel: () => 'Planned for 2025',
},
{
done: false,
icon: mdiTableKey,
iconColor: 'gray',
title: 'Fine grained access controls',
description: 'Granular access controls for users and api keys',
getDateLabel: () => 'Planned for 2025',
},
{
done: false,
icon: mdiImageEdit,
iconColor: 'rebeccapurple',
title: 'Basic editor',
description: 'Basic photo editing capabilities',
getDateLabel: () => 'Planned for 2025',
},
{
done: false,
icon: mdiRocketLaunch,
iconColor: 'indianred',
title: 'Stable release',
description: 'Immich goes stable',
getDateLabel: () => 'Planned for early 2025',
},
{
done: false,
icon: mdiLockOutline,
@@ -183,14 +220,6 @@ const roadmap: Item[] = [
description: 'Private assets with extra protections',
getDateLabel: () => 'Planned for 2024',
},
{
done: false,
icon: mdiRocketLaunch,
iconColor: 'indianred',
title: 'Stable release',
description: 'Immich goes stable',
getDateLabel: () => 'Planned for 2024',
},
{
done: false,
icon: mdiCloudUploadOutline,
@@ -199,30 +228,6 @@ const roadmap: Item[] = [
description: 'Rework background backups to be more reliable',
getDateLabel: () => 'Planned for 2024',
},
{
done: false,
icon: mdiImageEdit,
iconColor: 'rebeccapurple',
title: 'Basic editor',
description: 'Basic photo editing capabilities',
getDateLabel: () => 'Planned for 2024',
},
{
done: false,
icon: mdiFlash,
iconColor: 'gold',
title: 'Workflows',
description: 'Automate tasks with workflows',
getDateLabel: () => 'Planned for 2024',
},
{
done: false,
icon: mdiTableKey,
iconColor: 'gray',
title: 'Fine grained access controls',
description: 'Granular access controls for users and api keys',
getDateLabel: () => 'Planned for 2024',
},
{
done: false,
icon: mdiCameraBurst,
@@ -234,6 +239,20 @@ const roadmap: Item[] = [
];
const milestones: Item[] = [
withRelease({
icon: mdiDatabaseOutline,
iconColor: 'brown',
title: 'Automatic database backups',
description: 'Database backups are now integrated into the Immich server',
release: 'v1.120.0',
}),
{
icon: mdiStar,
iconColor: 'gold',
title: '50,000 Stars',
description: 'Reached 50K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2024, 10, 1)),
},
withRelease({
icon: mdiFaceRecognition,
title: 'Metadata Face Import',
@@ -853,14 +872,12 @@ const milestones: Item[] = [
export default function MilestonePage(): JSX.Element {
return (
<Layout title="Milestones" description="History of Immich">
<Layout title={title} description={description}>
<section className="my-8">
<h1 className="md:text-6xl text-center mb-10 text-immich-primary dark:text-immich-dark-primary px-2">
Roadmap
{title}
</h1>
<p className="text-center text-xl px-2">
A list of future plans and goals, as well as past achievements and milestones.
</p>
<p className="text-center text-xl px-2">{description}</p>
<div className="flex justify-around mt-8 w-full max-w-full">
<Timeline items={[...roadmap, ...milestones]} />
</div>

View File

@@ -1,4 +1,12 @@
[
{
"label": "v1.120.2",
"url": "https://v1.120.2.archive.immich.app"
},
{
"label": "v1.120.1",
"url": "https://v1.120.1.archive.immich.app"
},
{
"label": "v1.120.0",
"url": "https://v1.120.0.archive.immich.app"

View File

@@ -34,7 +34,7 @@ services:
- 2285:2285
redis:
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0

14
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.120.0",
"version": "1.120.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.120.0",
"version": "1.120.2",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
@@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.29",
"version": "2.2.31",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -64,7 +64,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",
@@ -92,14 +92,14 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.120.0",
"version": "1.120.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"typescript": "^5.3.3"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.120.0",
"version": "1.120.2",
"description": "",
"main": "index.js",
"type": "module",
@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.8.6",
"@types/node": "^22.9.0",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",

View File

@@ -473,10 +473,7 @@ describe('/search', () => {
.get('/search/explore')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
{ fieldName: 'exifInfo.city', items: [] },
{ fieldName: 'smartInfo.tags', items: [] },
]);
expect(body).toEqual([{ fieldName: 'exifInfo.city', items: [] }]);
});
});

View File

@@ -163,11 +163,15 @@ describe('/server', () => {
expect(body).toEqual({
photos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
usageByUser: [
{
quotaSizeInBytes: null,
photos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
userName: 'Immich Admin',
userId: admin.userId,
videos: 0,
@@ -176,6 +180,8 @@ describe('/server', () => {
quotaSizeInBytes: null,
photos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
userName: 'User 1',
userId: nonAdmin.userId,
videos: 0,

View File

@@ -103,7 +103,7 @@ describe(`immich upload`, () => {
describe(`immich upload /path/to/file.jpg`, () => {
it('should upload a single file', async () => {
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.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 { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
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]);
const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]),
);
@@ -169,7 +169,7 @@ describe(`immich upload`, () => {
it('should skip a duplicate file', async () => {
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.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
);
@@ -179,7 +179,7 @@ describe(`immich upload`, () => {
expect(assets.total).toBe(1);
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.arrayContaining([
expect.stringContaining('Found 0 new files and 1 duplicate'),
@@ -205,7 +205,7 @@ describe(`immich upload`, () => {
`${testAssetDir}/albums/nature/silver_fir.jpg`,
'--dry-run',
]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Would have uploaded 1 asset')]),
);
@@ -217,7 +217,7 @@ describe(`immich upload`, () => {
it('dry run should handle duplicates', async () => {
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.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
);
@@ -227,7 +227,7 @@ describe(`immich upload`, () => {
expect(assets.total).toBe(1);
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.arrayContaining([
expect.stringContaining('Found 8 new files and 1 duplicate'),
@@ -241,7 +241,7 @@ describe(`immich upload`, () => {
describe('immich upload --recursive', () => {
it('should upload a folder recursively', async () => {
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
);
@@ -267,7 +267,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Successfully updated 9 assets'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -283,7 +283,7 @@ describe(`immich upload`, () => {
expect(response1.stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
);
expect(response1.stderr).toBe('');
expect(response1.stderr).toContain('{message}');
expect(response1.exitCode).toBe(0);
const assets1 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -299,7 +299,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Successfully updated 9 assets'),
]),
);
expect(response2.stderr).toBe('');
expect(response2.stderr).toContain('{message}');
expect(response2.exitCode).toBe(0);
const assets2 = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -325,7 +325,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Would have updated albums of 9 assets'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -351,7 +351,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Successfully updated 9 assets'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -377,7 +377,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Would have updated albums of 9 assets'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -408,7 +408,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Deleting assets that have been uploaded'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -434,7 +434,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Would have deleted 9 local assets'),
]),
);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(exitCode).toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
@@ -493,7 +493,7 @@ describe(`immich upload`, () => {
'2',
]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
'Found 9 new files and 0 duplicates',
@@ -534,7 +534,7 @@ describe(`immich upload`, () => {
'silver_fir.jpg',
]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
'Found 8 new files and 0 duplicates',
@@ -555,7 +555,7 @@ describe(`immich upload`, () => {
'!(*_*_*).jpg',
]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
'Found 1 new files and 0 duplicates',
@@ -577,7 +577,7 @@ describe(`immich upload`, () => {
'--dry-run',
]);
expect(stderr).toBe('');
expect(stderr).toContain('{message}');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
'Found 8 new files and 0 duplicates',

View File

@@ -1283,7 +1283,7 @@
"variables": "Variables",
"version": "Version",
"version_announcement_closing": "Your friend, Alex",
"version_announcement_message": "Hi friend, there is a new version of the application please take your time to visit the <link>release notes</link> and ensure your <code>docker-compose.yml</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your application automatically.",
"version_announcement_message": "Hi there! A new version of Immich is available. Please take some time to read the <link>release notes</link> to ensure your setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your Immich instance automatically.",
"version_history": "Version History",
"version_history_item": "Installed {version} on {date}",
"video": "Video",

1
i18n/fil.json Normal file
View File

@@ -0,0 +1 @@
{}

1
i18n/nn.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -747,14 +747,14 @@ files = [
test = ["pytest (>=6)"]
[[package]]
name = "fastapi-slim"
name = "fastapi"
version = "0.115.4"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi_slim-0.115.4-py3-none-any.whl", hash = "sha256:8947515618c21665590a1673a0bfe4c721db4267999c149d5301c3c0f7b3d9ce"},
{file = "fastapi_slim-0.115.4.tar.gz", hash = "sha256:6d37987e4d1f6adefb8c7119c9b804e59c9b3f1a488be5425994d52308e2f958"},
{file = "fastapi-0.115.4-py3-none-any.whl", hash = "sha256:0b504a063ffb3cf96a5e27dc1bc32c80ca743a2528574f9cdc77daa2d31b4742"},
{file = "fastapi-0.115.4.tar.gz", hash = "sha256:db653475586b091cb8b2fec2ac54a680ac6a158e07406e1abae31679e8826349"},
]
[package.dependencies]
@@ -3778,4 +3778,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4.0"
content-hash = "f95dddfd343a4b2f4d19ffee71ce6b2f5137e5514a60765424164259c4dc1044"
content-hash = "b690d5fbd141da3947f4f1dc029aba1b95e7faafd723166f2c4bdc47a66c095e"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.120.0"
version = "1.120.2"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
@@ -11,7 +11,7 @@ python = ">=3.10,<4.0"
insightface = ">=0.7.3,<1.0"
opencv-python-headless = ">=4.7.0.72,<5.0"
pillow = ">=9.5.0,<11.0"
fastapi-slim = ">=0.95.2,<1.0"
fastapi = ">=0.95.2,<1.0"
uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"}
pydantic = "^2.0.0"
pydantic-settings = "^2.5.2"

View File

@@ -28,7 +28,7 @@ if (keystorePropertiesFile.exists()) {
}
android {
compileSdkVersion 34
compileSdkVersion 35
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@@ -47,7 +47,7 @@ android {
defaultConfig {
applicationId "app.alextran.immich"
minSdkVersion 26
targetSdkVersion 34
targetSdkVersion 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}

32
mobile/android/app/proguard-rules.pro vendored Normal file
View File

@@ -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 ----------

View File

@@ -35,7 +35,7 @@
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
android:value="true" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"

View File

@@ -16,8 +16,8 @@ subprojects {
if (project.plugins.hasPlugin("com.android.application") ||
project.plugins.hasPlugin("com.android.library")) {
project.android {
compileSdkVersion 34
buildToolsVersion "34.0.0"
compileSdkVersion 35
buildToolsVersion "35.0.0"
}
}
}

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 165,
"android.injected.version.name" => "1.120.0",
"android.injected.version.code" => 167,
"android.injected.version.name" => "1.120.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4096M
android.useAndroidX=true
android.enableJetifier=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
android.nonFinalResIds=false

View File

@@ -65,6 +65,8 @@ PODS:
- maplibre_gl (0.0.1):
- Flutter
- MapLibre (= 5.14.0-pre3)
- native_video_player (1.0.0):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -115,6 +117,7 @@ DEPENDENCIES:
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/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`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
@@ -168,6 +171,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/isar_flutter_libs/ios"
maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios"
native_video_player:
:path: ".symlinks/plugins/native_video_player/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -194,7 +199,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
@@ -207,17 +212,18 @@ SPEC CHECKSUMS:
geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
@@ -228,4 +234,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
COCOAPODS: 1.15.2
COCOAPODS: 1.16.0

View File

@@ -401,7 +401,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 184;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 184;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -571,7 +571,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 184;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -1,48 +1,59 @@
import UIKit
import shared_preferences_foundation
import Flutter
import BackgroundTasks
import Flutter
import UIKit
import path_provider_ios
import photo_manager
import permission_handler_apple
import photo_manager
import shared_preferences_foundation
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Required for flutter_local_notification
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
// Required for flutter_local_notification
if #available(iOS 10.0, *) {
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)
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")!)
}
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")!)
}
if !registry.hasPlugin("org.cocoapods.photo-manager") {
PhotoManagerPlugin.register(
with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
}
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)
}
}

View File

@@ -58,11 +58,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.119.0</string>
<string>1.120.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>181</string>
<string>184</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.120.0"
version_number: "1.120.2"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -19,6 +19,9 @@ const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF);
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 = {
ImmichColorPreset.indigo: ImmichTheme(

View File

@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/utils/hash.dart';
@@ -22,12 +23,8 @@ class Asset {
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
type = remote.type.toAssetType(),
fileName = remote.originalFileName,
height = isFlipped(remote)
? remote.exifInfo?.exifImageWidth?.toInt()
: remote.exifInfo?.exifImageHeight?.toInt(),
width = isFlipped(remote)
? remote.exifInfo?.exifImageHeight?.toInt()
: remote.exifInfo?.exifImageWidth?.toInt(),
height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId,
ownerId = fastHash(remote.ownerId),
exifInfo =
@@ -93,6 +90,27 @@ class Asset {
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;
/// stores the raw SHA1 bytes as a base64 String
@@ -150,10 +168,21 @@ class Asset {
int stackCount;
/// Aspect ratio of the asset
/// Returns null if the asset has no sync access to the exif info
@ignore
double? get aspectRatio =>
width == null || height == null ? 0 : width! / height!;
double? get aspectRatio {
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
@ignore
@@ -172,6 +201,12 @@ class Asset {
@ignore
bool get isImage => type == AssetType.image;
@ignore
bool get isVideo => type == AssetType.video;
@ignore
bool get isMotionPhoto => livePhotoVideoId != null;
@ignore
AssetState get storage {
if (isRemote && isLocal) {
@@ -192,6 +227,50 @@ class Asset {
@ignore
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
bool operator ==(other) {
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));
}
}
/// 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));
}

View File

@@ -23,6 +23,7 @@ class ExifInfo {
String? state;
String? country;
String? description;
String? orientation;
@ignore
bool get hasCoordinates =>
@@ -45,6 +46,13 @@ class ExifInfo {
@ignore
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
@ignore
bool? _isFlipped;
@ignore
@pragma('vm:prefer-inline')
bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
@ignore
double? get latitude => lat;
@@ -67,7 +75,8 @@ class ExifInfo {
city = dto.city,
state = dto.state,
country = dto.country,
description = dto.description;
description = dto.description,
orientation = dto.orientation;
ExifInfo({
this.id,
@@ -87,6 +96,7 @@ class ExifInfo {
this.state,
this.country,
this.description,
this.orientation,
});
ExifInfo copyWith({
@@ -107,6 +117,7 @@ class ExifInfo {
String? state,
String? country,
String? description,
String? orientation,
}) =>
ExifInfo(
id: id ?? this.id,
@@ -126,6 +137,7 @@ class ExifInfo {
state: state ?? this.state,
country: country ?? this.country,
description: description ?? this.description,
orientation: orientation ?? this.orientation,
);
@override
@@ -147,7 +159,8 @@ class ExifInfo {
city == other.city &&
state == other.state &&
country == other.country &&
description == other.description;
description == other.description &&
orientation == other.orientation;
}
@override
@@ -169,7 +182,8 @@ class ExifInfo {
city.hashCode ^
state.hashCode ^
country.hashCode ^
description.hashCode;
description.hashCode ^
orientation.hashCode;
@override
String toString() {
@@ -192,10 +206,21 @@ class ExifInfo {
state: $state,
country: $country,
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) {
if (s == null) {
return null;

View File

@@ -87,13 +87,18 @@ const ExifInfoSchema = CollectionSchema(
name: r'model',
type: IsarType.string,
),
r'state': PropertySchema(
r'orientation': PropertySchema(
id: 14,
name: r'orientation',
type: IsarType.string,
),
r'state': PropertySchema(
id: 15,
name: r'state',
type: IsarType.string,
),
r'timeZone': PropertySchema(
id: 15,
id: 16,
name: r'timeZone',
type: IsarType.string,
)
@@ -154,6 +159,12 @@ int _exifInfoEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.orientation;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.state;
if (value != null) {
@@ -189,8 +200,9 @@ void _exifInfoSerialize(
writer.writeString(offsets[11], object.make);
writer.writeFloat(offsets[12], object.mm);
writer.writeString(offsets[13], object.model);
writer.writeString(offsets[14], object.state);
writer.writeString(offsets[15], object.timeZone);
writer.writeString(offsets[14], object.orientation);
writer.writeString(offsets[15], object.state);
writer.writeString(offsets[16], object.timeZone);
}
ExifInfo _exifInfoDeserialize(
@@ -215,8 +227,9 @@ ExifInfo _exifInfoDeserialize(
make: reader.readStringOrNull(offsets[11]),
mm: reader.readFloatOrNull(offsets[12]),
model: reader.readStringOrNull(offsets[13]),
state: reader.readStringOrNull(offsets[14]),
timeZone: reader.readStringOrNull(offsets[15]),
orientation: reader.readStringOrNull(offsets[14]),
state: reader.readStringOrNull(offsets[15]),
timeZone: reader.readStringOrNull(offsets[16]),
);
return object;
}
@@ -260,6 +273,8 @@ P _exifInfoDeserializeProp<P>(
return (reader.readStringOrNull(offset)) as P;
case 15:
return (reader.readStringOrNull(offset)) as P;
case 16:
return (reader.readStringOrNull(offset)) as P;
default:
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() {
return QueryBuilder.apply(this, (query) {
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() {
return QueryBuilder.apply(this, (query) {
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() {
return QueryBuilder.apply(this, (query) {
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(
{bool caseSensitive = true}) {
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() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'state');

View File

@@ -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;
}

View File

@@ -11,6 +11,7 @@ import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:timezone/data/latest.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/services/background.service.dart';
@@ -56,6 +57,7 @@ void main() async {
Future<void> initApp() async {
await EasyLocalization.ensureInitialized();
await initializeDateFormatting();
if (kReleaseMode && Platform.isAndroid) {
try {

View File

@@ -18,6 +18,9 @@ class CurrentUploadAsset {
this.iCloudAsset,
});
@pragma('vm:prefer-inline')
bool get isIcloudAsset => iCloudAsset != null && iCloudAsset!;
CurrentUploadAsset copyWith({
String? id,
DateTime? fileCreatedAt,

View File

@@ -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),
),
),
),
),
);
},
),
);
}
}

View File

@@ -8,18 +8,19 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
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/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/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/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/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/widgets/asset_grid/asset_grid_data_structure.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()
// ignore: must_be_immutable
/// Expects [currentAssetProvider] to be set before navigating to this page
class GalleryViewerPage extends HookConsumerWidget {
final int initialIndex;
final int heroOffset;
@@ -53,79 +55,66 @@ class GalleryViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider);
final loadAsset = renderList.loadAsset;
final totalAssets = useState(renderList.totalAssets);
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
final isZoomed = useState(false);
final isPlayingVideo = useState(false);
final localPosition = useState<Offset?>(null);
final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value);
// 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;
},
[],
);
final stackIndex = useState(0);
final localPosition = useRef<Offset?>(null);
final currentIndex = useValueNotifier(initialIndex);
final loadAsset = renderList.loadAsset;
Future<void> precacheNextImage(int index) async {
if (!context.mounted) {
return;
}
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
debugPrint('Error precaching next image: $exception, $stackTrace');
log.severe('Error precaching next image: $exception, $stackTrace');
}
try {
if (index < totalAssets.value && index >= 0) {
final asset = loadAsset(index);
await precacheImage(
ImmichImage.imageProvider(asset: asset),
ImmichImage.imageProvider(
asset: asset,
width: context.width,
height: context.height,
),
context,
onError: onError,
);
}
} catch (e) {
// swallow error silently
debugPrint('Error precaching next image: $e');
log.severe('Error precaching next image: $e');
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() {
final asset = ref.read(currentAssetProvider);
if (asset == null) {
return;
}
showModalBottomSheet(
shape: const RoundedRectangleBorder(
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) {
if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -219,50 +180,88 @@ class GalleryViewerPage extends HookConsumerWidget {
}
});
Widget buildStackedChildren() {
return ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
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!),
),
),
),
),
);
PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) {
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) {
localPosition.value = details.localPosition;
},
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(
// Change immersive mode back to normal "edgeToEdge" mode
onPopInvokedWithResult: (didPop, _) =>
@@ -272,128 +271,79 @@ class GalleryViewerPage extends HookConsumerWidget {
body: Stack(
children: [
PhotoViewGallery.builder(
key: const ValueKey('gallery'),
scaleStateChangedCallback: (state) {
isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
final asset = ref.read(currentAssetProvider);
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(
child: Stack(
fit: StackFit.expand,
children: [
BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
gaplessPlayback: true,
loadingBuilder: (context, event, index) {
final asset = loadAsset(index);
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
),
),
ImmichThumbnail(
asset: asset,
fit: BoxFit.contain,
),
],
),
),
ImmichThumbnail(
key: ValueKey(asset),
asset: asset,
fit: BoxFit.contain,
),
],
),
);
},
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const ScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: totalAssets.value,
scrollDirection: Axis.horizontal,
onPageChanged: (value) async {
onPageChanged: (value) {
final next = currentIndex.value < value ? value + 1 : value - 1;
ref.read(hapticFeedbackProvider.notifier).selectionClick();
final newAsset = loadAsset(value);
currentIndex.value = value;
stackIndex.value = -1;
isPlayingVideo.value = false;
stackIndex.value = 0;
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// Then precache the next image
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,
),
),
);
ref.read(currentAssetProvider.notifier).set(newAsset);
if (newAsset.isVideo || newAsset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
// Wait for page change animation to finish, then precache the next image
Timer(const Duration(milliseconds: 400), () {
precacheNextImage(next);
});
},
builder: buildAsset,
),
Positioned(
top: 0,
left: 0,
right: 0,
child: GalleryAppBar(
asset: asset,
key: const ValueKey('app-bar'),
showInfo: showInfo,
isPlayingVideo: isPlayingVideo.value,
onToggleMotionVideo: () =>
isPlayingVideo.value = !isPlayingVideo.value,
),
),
Positioned(
@@ -402,22 +352,15 @@ class GalleryViewerPage extends HookConsumerWidget {
right: 0,
child: Column(
children: [
Visibility(
visible: stack.isNotEmpty,
child: SizedBox(
height: 80,
child: buildStackedChildren(),
),
),
GalleryStackedChildren(stackIndex),
BottomGalleryBar(
key: const ValueKey('bottom-bar'),
renderList: renderList,
totalAssets: totalAssets,
controller: controller,
showStack: showStack,
stackIndex: stackIndex.value,
asset: asset,
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,
);
}
}

View File

@@ -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()),
],
);
}
}

View File

@@ -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,
),
),
],
),
),
);
}
}

View File

@@ -113,11 +113,15 @@ class MemoryPage extends HookConsumerWidget {
}
// Precache the asset
final size = MediaQuery.sizeOf(context);
await precacheImage(
ImmichImage.imageProvider(
asset: asset,
width: size.width,
height: size.height,
),
context,
size: size,
);
}

View File

@@ -15,6 +15,8 @@ import 'package:immich_mobile/extensions/latlngbounds_extension.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_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/map/map_marker.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
@@ -99,8 +101,11 @@ class MapPage extends HookConsumerWidget {
useEffect(
() {
final currentAssetLink =
ref.read(currentAssetProvider.notifier).ref.keepAlive();
loadMarkers();
return null;
return currentAssetLink.close;
},
[],
);
@@ -186,6 +191,10 @@ class MapPage extends HookConsumerWidget {
GroupAssetsBy.none,
);
ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo) {
ref.read(showControlsProvider.notifier).show = false;
}
context.pushRoute(
GalleryViewerRoute(
initialIndex: 0,

View File

@@ -7,49 +7,49 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'asset_stack.provider.g.dart';
class AssetStackNotifier extends StateNotifier<List<Asset>> {
final Asset _asset;
final String _stackId;
final Ref _ref;
AssetStackNotifier(
this._asset,
this._ref,
) : super([]) {
fetchStackChildren();
AssetStackNotifier(this._stackId, this._ref) : super([]) {
_fetchStack(_stackId);
}
void fetchStackChildren() async {
if (mounted) {
state = await _ref.read(assetStackProvider(_asset).future);
void _fetchStack(String stackId) async {
if (!mounted) {
return;
}
final stack = await _ref.read(assetStackProvider(stackId).future);
if (stack.isNotEmpty) {
state = stack;
}
}
void removeChild(int index) {
if (index < state.length) {
state.removeAt(index);
state = List<Asset>.from(state);
}
}
}
final assetStackStateProvider = StateNotifierProvider.autoDispose
.family<AssetStackNotifier, List<Asset>, Asset>(
(ref, asset) => AssetStackNotifier(asset, ref),
.family<AssetStackNotifier, List<Asset>, String>(
(ref, stackId) => AssetStackNotifier(stackId, ref),
);
final assetStackProvider =
FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
// Guard [local asset]
if (asset.remoteId == null) {
return [];
}
return await ref
FutureProvider.autoDispose.family<List<Asset>, String>((ref, stackId) {
return ref
.watch(dbProvider)
.assets
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackPrimaryAssetIdEqualTo(asset.remoteId)
.sortByFileCreatedAtDesc()
.stackIdEqualTo(stackId)
// orders primary asset first as its ID is null
.sortByStackPrimaryAssetId()
.thenByFileCreatedAtDesc()
.findAll();
});

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -1,7 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
class VideoPlaybackControls {
VideoPlaybackControls({
const VideoPlaybackControls({
required this.position,
required this.mute,
required this.pause,
@@ -17,15 +18,14 @@ final videoPlayerControlsProvider =
return VideoPlayerControls(ref);
});
const videoPlayerControlsDefault = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref)
: super(
VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
),
);
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
final Ref ref;
@@ -36,17 +36,18 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void reset() {
state = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
state = videoPlayerControlsDefault;
}
double get position => state.position;
bool get mute => state.mute;
bool get paused => state.pause;
set position(double value) {
if (state.position == value) {
return;
}
state = VideoPlaybackControls(
position: value,
mute: state.mute,
@@ -55,6 +56,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
set mute(bool value) {
if (state.mute == value) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: value,
@@ -71,6 +76,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void pause() {
if (state.pause) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
@@ -79,6 +88,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void play() {
if (!state.pause) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
@@ -95,16 +108,11 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void restart() {
state = VideoPlaybackControls(
position: 0,
mute: state.mute,
pause: true,
);
state = VideoPlaybackControls(
position: 0,
mute: state.mute,
pause: false,
);
ref.read(videoPlaybackValueProvider.notifier).position = Duration.zero;
}
}

View File

@@ -1,5 +1,5 @@
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 {
initializing,
@@ -22,56 +22,66 @@ class VideoPlaybackValue {
/// The volume of the video
final double volume;
VideoPlaybackValue({
const VideoPlaybackValue({
required this.position,
required this.duration,
required this.state,
required this.volume,
});
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
final video = controller?.value;
late VideoPlaybackState s;
if (video == null) {
s = VideoPlaybackState.initializing;
} else if (video.isCompleted) {
s = VideoPlaybackState.completed;
} else if (video.isPlaying) {
s = VideoPlaybackState.playing;
} else if (video.isBuffering) {
s = VideoPlaybackState.buffering;
} else {
s = VideoPlaybackState.paused;
factory VideoPlaybackValue.fromNativeController(
NativeVideoPlayerController controller,
) {
final playbackInfo = controller.playbackInfo;
final videoInfo = controller.videoInfo;
if (playbackInfo == null || videoInfo == null) {
return videoPlaybackValueDefault;
}
final VideoPlaybackState status = switch (playbackInfo.status) {
PlaybackStatus.playing => VideoPlaybackState.playing,
PlaybackStatus.paused => VideoPlaybackState.paused,
PlaybackStatus.stopped => VideoPlaybackState.completed,
};
return VideoPlaybackValue(
position: video?.position ?? Duration.zero,
duration: video?.duration ?? Duration.zero,
state: s,
volume: video?.volume ?? 0.0,
position: Duration(seconds: playbackInfo.position),
duration: Duration(seconds: videoInfo.duration),
state: status,
volume: playbackInfo.volume,
);
}
factory VideoPlaybackValue.uninitialized() {
VideoPlaybackValue copyWith({
Duration? position,
Duration? duration,
VideoPlaybackState? state,
double? volume,
}) {
return VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
position: position ?? this.position,
duration: duration ?? this.duration,
state: state ?? this.state,
volume: volume ?? this.volume,
);
}
}
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
final videoPlaybackValueProvider =
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
return VideoPlaybackValueState(ref);
});
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
VideoPlaybackValueState(this.ref)
: super(
VideoPlaybackValue.uninitialized(),
);
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
final Ref ref;
@@ -82,6 +92,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
}
set position(Duration value) {
if (state.position == value) return;
state = VideoPlaybackValue(
position: value,
duration: state.duration,
@@ -89,4 +100,18 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
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;
}
}

View File

@@ -41,6 +41,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
_ref;
final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7);
Future<bool> login(
String email,
String password,
@@ -102,12 +104,15 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
await _apiService.authenticationApi
.logout()
.timeout(_timeoutDuration)
.then((_) => log.info("Logout was successful for $userEmail"))
.onError(
(error, stackTrace) =>
log.severe("Logout failed for $userEmail", error, stackTrace),
);
} catch (e, stack) {
log.severe('Logout failed', e, stack);
} finally {
await Future.wait([
clearAssetsAndAlbums(_db),
Store.delete(StoreKey.currentUser),
@@ -125,8 +130,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
shouldChangePassword: false,
isAuthenticated: false,
);
} catch (e, stack) {
log.severe('Logout failed', e, stack);
}
}
@@ -168,10 +171,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
UserPreferencesResponseDto? userPreferences;
try {
final responses = await Future.wait([
_apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)),
_apiService.usersApi
.getMyPreferences()
.timeout(const Duration(seconds: 7)),
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
]);
userResponse = responses[0] as UserAdminResponseDto;
userPreferences = responses[1] as UserPreferencesResponseDto;

View File

@@ -7,14 +7,21 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
/// The local image provider for an asset
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
final Asset asset;
// only used for videos
final double width;
final double height;
final Logger log = Logger('ImmichLocalImageProvider');
ImmichLocalImageProvider({
required this.asset,
required this.width,
required this.height,
}) : assert(asset.local != null, 'Only usable when asset.local is set');
/// 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
Stream<ui.Codec> _codec(
Asset key,
Asset asset,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a small thumbnail
final thumbBytes = await asset.local?.thumbnailDataWithSize(
const ThumbnailSize.square(256),
quality: 80,
);
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");
ui.ImmutableBuffer? buffer;
try {
final local = asset.local;
if (local == null) {
throw StateError('Asset ${asset.fileName} has no local data');
}
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

View File

@@ -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/failed_backup_status.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/people/people_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],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
AutoRoute(
page: NativeVideoViewerRoute.page,
guards: [_authGuard, _duplicateGuard],
),
];
}

View File

@@ -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
/// [PartnerDetailPage]
class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
@@ -402,4 +403,29 @@ class AssetService {
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;
}
}

View File

@@ -3,20 +3,52 @@ import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
/// 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 {
Debouncer({required this.interval});
Debouncer({required this.interval, this.maxWaitTime});
final Duration interval;
final Duration? maxWaitTime;
Timer? _timer;
FutureOr<void> Function()? _lastAction;
DateTime? _lastActionTime;
Future<void>? _actionFuture;
void run(FutureOr<void> Function() action) {
_lastAction = action;
_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);
}
Future<void>? drain() {
if (_timer != null && _timer!.isActive) {
_timer!.cancel();
if (_lastAction != null) {
_callAndRest();
}
}
return _actionFuture;
}
@pragma('vm:prefer-inline')
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;
}
@@ -24,31 +56,48 @@ class Debouncer {
_timer?.cancel();
_timer = 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
/// default interval of 300ms is used to debounce the function calls
Debouncer useDebouncer({
Duration interval = const Duration(milliseconds: 300),
Duration? maxWaitTime,
List<Object?>? keys,
}) =>
use(_DebouncerHook(interval: interval, keys: keys));
use(
_DebouncerHook(
interval: interval,
maxWaitTime: maxWaitTime,
keys: keys,
),
);
class _DebouncerHook extends Hook<Debouncer> {
const _DebouncerHook({
required this.interval,
this.maxWaitTime,
super.keys,
});
final Duration interval;
final Duration? maxWaitTime;
@override
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
}
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
late final debouncer = Debouncer(interval: hook.interval);
late final debouncer = Debouncer(
interval: hook.interval,
maxWaitTime: hook.maxWaitTime,
);
@override
Debouncer build(_) => debouncer;

View File

@@ -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,
);
}
*/
}

View File

@@ -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],
);
}

View File

@@ -7,10 +7,10 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
class ImmichTheme {
ColorScheme light;
ColorScheme dark;
final ColorScheme light;
final ColorScheme dark;
ImmichTheme({required this.light, required this.dark});
const ImmichTheme({required this.light, required this.dark});
}
ImmichTheme? _immichDynamicTheme;
@@ -151,7 +151,7 @@ ThemeData getThemeData({required ColorScheme colorScheme}) {
return ThemeData(
useMaterial3: true,
brightness: isDark ? Brightness.dark : Brightness.light,
brightness: colorScheme.brightness,
colorScheme: colorScheme,
primaryColor: primaryColor,
hintColor: colorScheme.onSurfaceSecondary,

View File

@@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:isar/isar.dart';
const int targetVersion = 6;
const int targetVersion = 7;
Future<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Throttles function calls with the [interval] provided.
@@ -10,12 +8,15 @@ class Throttler {
Throttler({required this.interval});
void run(FutureOr<void> Function() action) {
T? run<T>(T Function() action) {
if (_lastActionTime == null ||
(DateTime.now().difference(_lastActionTime!) > interval)) {
action();
final response = action();
_lastActionTime = DateTime.now();
return response;
}
return null;
}
void dispose() {

View File

@@ -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/collection_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/show_controls.provider.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_placeholder.dart';
@@ -89,6 +91,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
ScrollOffsetController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
late final KeepAliveLink currentAssetLink;
/// The timestamp when the haptic feedback was last invoked
int _hapticFeedbackTS = 0;
@@ -201,6 +204,12 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
allAssetsSelected: _allAssetsSelected,
showStack: widget.showStack,
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
void initState() {
super.initState();
currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive();
scrollToTopNotifierProvider.addListener(_scrollToTop);
scrollToDateNotifierProvider.addListener(_scrollToDate);
@@ -369,6 +379,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
_itemPositionsListener.itemPositions.removeListener(_positionListener);
}
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
currentAssetLink.close();
super.dispose();
}
@@ -595,12 +606,13 @@ class _Section extends StatelessWidget {
final RenderList renderList;
final bool selectionActive;
final bool dynamicLayout;
final Function(List<Asset>) selectAssets;
final Function(List<Asset>) deselectAssets;
final void Function(List<Asset>) selectAssets;
final void Function(List<Asset>) deselectAssets;
final bool Function(List<Asset>) allAssetsSelected;
final bool showStack;
final int heroOffset;
final bool showStorageIndicator;
final void Function(Asset) onAssetTap;
const _Section({
required this.section,
@@ -618,6 +630,7 @@ class _Section extends StatelessWidget {
required this.showStack,
required this.heroOffset,
required this.showStorageIndicator,
required this.onAssetTap,
});
@override
@@ -683,6 +696,7 @@ class _Section extends StatelessWidget {
selectionActive: selectionActive,
onSelect: (asset) => selectAssets([asset]),
onDeselect: (asset) => deselectAssets([asset]),
onAssetTap: onAssetTap,
),
],
);
@@ -724,9 +738,9 @@ class _Title extends StatelessWidget {
final String title;
final List<Asset> assets;
final bool selectionActive;
final Function(List<Asset>) selectAssets;
final Function(List<Asset>) deselectAssets;
final Function(List<Asset>) allAssetsSelected;
final void Function(List<Asset>) selectAssets;
final void Function(List<Asset>) deselectAssets;
final bool Function(List<Asset>) allAssetsSelected;
const _Title({
required this.title,
@@ -765,8 +779,9 @@ class _AssetRow extends StatelessWidget {
final bool showStorageIndicator;
final int heroOffset;
final bool showStack;
final Function(Asset)? onSelect;
final Function(Asset)? onDeselect;
final void Function(Asset) onAssetTap;
final void Function(Asset)? onSelect;
final void Function(Asset)? onDeselect;
final bool isSelectionActive;
const _AssetRow({
@@ -786,6 +801,7 @@ class _AssetRow extends StatelessWidget {
required this.showStack,
required this.isSelectionActive,
required this.selectedAssets,
required this.onAssetTap,
this.onSelect,
this.onDeselect,
});
@@ -838,6 +854,8 @@ class _AssetRow extends StatelessWidget {
onSelect?.call(asset);
}
} else {
final asset = renderList.loadAsset(absoluteOffset + index);
onAssetTap(asset);
context.pushRoute(
GalleryViewerRoute(
renderList: renderList,

View File

@@ -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/current_album.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/show_controls.provider.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';
class BottomGalleryBar extends ConsumerWidget {
final Asset asset;
final ValueNotifier<int> assetIndex;
final bool showStack;
final int stackIndex;
final ValueNotifier<int> totalAssets;
final bool showVideoPlayerControls;
final PageController controller;
final RenderList renderList;
@@ -38,20 +37,24 @@ class BottomGalleryBar extends ConsumerWidget {
super.key,
required this.showStack,
required this.stackIndex,
required this.asset,
required this.assetIndex,
required this.controller,
required this.totalAssets,
required this.showVideoPlayerControls,
required this.renderList,
});
@override
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 showControls = ref.watch(showControlsProvider);
final stackId = asset.stackId;
final stackItems = showStack && asset.stackCount > 0
? ref.watch(assetStackStateProvider(asset))
final stackItems = showStack && stackId != null
? ref.watch(assetStackStateProvider(stackId))
: <Asset>[];
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
final navStack = AutoRouter.of(context).stackData;
@@ -63,9 +66,9 @@ class BottomGalleryBar extends ConsumerWidget {
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
void removeAssetFromStack() {
if (stackIndex > 0 && showStack) {
if (stackIndex > 0 && showStack && stackId != null) {
ref
.read(assetStackStateProvider(asset).notifier)
.read(assetStackStateProvider(stackId).notifier)
.removeChild(stackIndex - 1);
}
}
@@ -134,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget {
await ref
.read(stackServiceProvider)
.deleteStack(asset.stackId!, [asset, ...stackItems]);
.deleteStack(asset.stackId!, stackItems);
}
void showStackActionItems() {
@@ -323,43 +326,55 @@ class BottomGalleryBar extends ConsumerWidget {
},
];
return IgnorePointer(
ignoring: !ref.watch(showControlsProvider),
ignoring: !showControls,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
child: Column(
children: [
Visibility(
visible: showVideoPlayerControls,
child: const VideoControls(),
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black, Colors.transparent],
),
BottomNavigationBar(
backgroundColor: Colors.black.withOpacity(0.4),
unselectedIconTheme: const IconThemeData(color: Colors.white),
selectedIconTheme: const IconThemeData(color: Colors.white),
unselectedLabelStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
height: 2.3,
),
selectedLabelStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
height: 2.3,
),
unselectedFontSize: 14,
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);
},
),
position: DecorationPosition.background,
child: Padding(
padding: const EdgeInsets.only(top: 40.0),
child: Column(
children: [
if (asset.isVideo) const VideoControls(),
BottomNavigationBar(
elevation: 0.0,
backgroundColor: Colors.transparent,
unselectedIconTheme: const IconThemeData(color: Colors.white),
selectedIconTheme: const IconThemeData(color: Colors.white),
unselectedLabelStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
height: 2.3,
),
selectedLabelStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
height: 2.3,
),
unselectedFontSize: 14,
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);
},
),
],
),
],
),
),
),
);

View File

@@ -1,38 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/video_player_controls_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/common/delayed_loading_indicator.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
class CustomVideoPlayerControls extends HookConsumerWidget {
final Duration hideTimerDuration;
const CustomVideoPlayerControls({
super.key,
this.hideTimerDuration = const Duration(seconds: 3),
this.hideTimerDuration = const Duration(seconds: 5),
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(
currentAssetProvider.select((asset) => asset != null && asset.isVideo),
);
final showControls = ref.watch(showControlsProvider);
final VideoPlaybackState state =
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
// A timer to hide the controls
final hideTimer = useTimer(
hideTimerDuration,
() {
final state = ref.read(videoPlaybackValueProvider).state;
if (!context.mounted) {
return;
}
// Do not hide on paused
if (state != VideoPlaybackState.paused) {
if (state != VideoPlaybackState.paused &&
state != VideoPlaybackState.completed &&
assetIsVideo) {
ref.read(showControlsProvider.notifier).show = false;
}
},
);
final showBuffering = useState(false);
final VideoPlaybackState state =
ref.watch(videoPlaybackValueProvider).state;
final showBuffering = state == VideoPlaybackState.buffering;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
@@ -52,16 +61,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
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
void togglePlay() {
showControlsAndStartHideTimer();
final state = ref.read(videoPlaybackValueProvider).state;
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) {
@@ -75,10 +77,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
behavior: HitTestBehavior.opaque,
onTap: showControlsAndStartHideTimer,
child: AbsorbPointer(
absorbing: !ref.watch(showControlsProvider),
absorbing: !showControls,
child: Stack(
children: [
if (showBuffering.value)
if (showBuffering)
const Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
@@ -86,18 +88,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
)
else
GestureDetector(
onTap: () {
if (state != VideoPlaybackState.playing) {
togglePlay();
}
ref.read(showControlsProvider.notifier).show = false;
},
onTap: () =>
ref.read(showControlsProvider.notifier).show = false,
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isPlaying: state == VideoPlaybackState.playing,
show: ref.watch(showControlsProvider),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
),

View File

@@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget {
Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
String resolution = asset.width != null && asset.height != null
? "${asset.height} x ${asset.width} "
: "";
final height = asset.orientatedHeight ?? asset.height;
final width = asset.orientatedWidth ?? asset.width;
String resolution =
height != null && width != null ? "$height x $width " : "";
String fileSize = asset.exifInfo?.fileSize != null
? formatBytes(asset.exifInfo!.fileSize!)
: "";

View File

@@ -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,
),
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/providers/asset_viewer/download.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';
class GalleryAppBar extends ConsumerWidget {
final Asset asset;
final void Function() showInfo;
final void Function() onToggleMotionVideo;
final bool isPlayingVideo;
const GalleryAppBar({
super.key,
required this.asset,
required this.showInfo,
required this.onToggleMotionVideo,
required this.isPlayingVideo,
});
const GalleryAppBar({super.key, required this.showInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetProvider);
if (asset == null) {
return const SizedBox();
}
final album = ref.watch(currentAlbumProvider);
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
final showControls = ref.watch(showControlsProvider);
final isPartner = ref
.watch(partnerSharedWithProvider)
@@ -98,23 +95,21 @@ class GalleryAppBar extends ConsumerWidget {
}
return IgnorePointer(
ignoring: !ref.watch(showControlsProvider),
ignoring: !showControls,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
opacity: showControls ? 1.0 : 0.0,
child: Container(
color: Colors.black.withOpacity(0.4),
child: TopControlAppBar(
isOwner: isOwner,
isPartner: isPartner,
isPlayingMotionVideo: isPlayingVideo,
asset: asset,
onMoreInfoPressed: showInfo,
onFavorite: toggleFavorite,
onRestorePressed: () => handleRestore(asset),
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
onDownloadPressed: asset.isLocal ? null : handleDownloadAsset,
onToggleMotionVideo: onToggleMotionVideo,
onAddToAlbumPressed: () => addToAlbum(asset),
onActivitiesPressed: handleActivities,
),

View File

@@ -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),
);
}
}

View File

@@ -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/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
class TopControlAppBar extends HookConsumerWidget {
const TopControlAppBar({
@@ -14,8 +15,6 @@ class TopControlAppBar extends HookConsumerWidget {
required this.onDownloadPressed,
required this.onAddToAlbumPressed,
required this.onRestorePressed,
required this.onToggleMotionVideo,
required this.isPlayingMotionVideo,
required this.onFavorite,
required this.onUploadPressed,
required this.isOwner,
@@ -27,12 +26,10 @@ class TopControlAppBar extends HookConsumerWidget {
final Function onMoreInfoPressed;
final VoidCallback? onUploadPressed;
final VoidCallback? onDownloadPressed;
final VoidCallback onToggleMotionVideo;
final VoidCallback onAddToAlbumPressed;
final VoidCallback onRestorePressed;
final VoidCallback onActivitiesPressed;
final Function(Asset) onFavorite;
final bool isPlayingMotionVideo;
final bool isOwner;
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() {
return IconButton(
onPressed: () {
@@ -175,13 +155,11 @@ class TopControlAppBar extends HookConsumerWidget {
foregroundColor: Colors.grey[100],
backgroundColor: Colors.transparent,
leading: buildBackButton(),
actionsIconTheme: const IconThemeData(
size: iconSize,
),
actionsIconTheme: const IconThemeData(size: iconSize),
shape: const Border(),
actions: [
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.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)

View File

@@ -1,125 +1,20 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
/// The video controls for the [videPlayerControlsProvider]
/// The video controls for the [videoPlayerControlsProvider]
class VideoControls extends ConsumerWidget {
const VideoControls({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final duration =
ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
final position =
ref.watch(videoPlaybackValueProvider.select((v) => v.position));
return AnimatedOpacity(
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
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;
final isPortrait =
MediaQuery.orientationOf(context) == Orientation.portrait;
return isPortrait
? const VideoPosition()
: const Padding(
padding: EdgeInsets.symmetric(horizontal: 60.0),
child: VideoPosition(),
);
}
}

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