Compare commits

..

25 Commits

Author SHA1 Message Date
Yaros 061a9ab120 chore: update readmes to match main 2026-05-16 23:15:17 +02:00
Min Idzelis 02581e81a7 fix(web): work around Chrome HDR image seam lines during zoom (#27715)
Change-Id: Ic5a5b1a476c2af93b465ef23dabc601a6a6a6964

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-05-16 02:15:24 +00:00
Santo Shakil 3ab3d5cf43 fix(mobile): don't force-unwrap nil localizedTitle in ios getAlbums (#28452)
crashes on ios 26 when a PHAssetCollection returns nil for
localizedTitle. fall back to localIdentifier. ref #28428
2026-05-15 18:12:28 -05:00
Ben Beckford 0ef04d9baa feat(mobile): slideshow view (#28421)
* feat(mobile): slideshow view

* move slideshow settings to metadata store

* remove watch in initState

* wrap progress bar in safearea

* show slideshow button on remote albums

* fix crash on unknown assets

* always show slideshow option

* add zoom effect

* add padding to slideshow settings

* chore: styling tweak

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-05-15 18:12:04 -05:00
Santo Shakil df016f9228 fix(mobile): mounted check in ThumbnailTile hero flight listener (#28451)
When the user pops back from the asset viewer mid-flight, the hero
animation can fire its status listener after _ThumbnailTileState has
been disposed. setState then throws a null check on State._element.

Guard the listener with `if (!mounted) return;` — same pattern as
#28300 in the album sync action.
2026-05-15 21:41:04 +00:00
Santo Shakil 17779c1e74 fix(mobile): cronet thumbnail buffer overflow regression from #28439 (#28450)
The hybrid added in onReadCompleted reuses Cronet's ByteBuffer between
reads to save a JNI wrap call when no grow is needed. That reuse breaks
advance() — Cronet's position() is cumulative across reads, so the same
K bytes get counted on every subsequent iteration. b.offset overshoots
b.capacity, the reuse branch keeps firing on a now-empty buffer, and
request.read() throws the original IllegalArgumentException again.

Always pass a fresh wrap from wrapRemaining() so byteBuffer.position()
reflects only this iteration's bytes. Same shape as the original PR
had before the broken optimization was layered on top.
2026-05-15 17:25:31 -04:00
Santo Shakil 01d6a244d8 fix(mobile): cronet buffer overflow on compressed thumbnails (#28439)
CronetImageFetcher sized the response buffer from Content-Length, which is
the compressed wire size. Cronet auto-decompresses gzip/br responses and
writes decompressed bytes into the buffer, exceeding it and throwing
IllegalArgumentException: ByteBuffer is already full on the next read. Use
the growable path; Content-Length becomes an initial alloc hint only,
capped at 128 MB so an untrusted server can't overflow Int.MAX_VALUE or
OOM us upfront. Reuse Cronet's ByteBuffer between reads when no grow is
needed.
2026-05-15 14:48:23 -04:00
Ben Beckford 21d6755f39 fix(web): recently added ux (#28435) 2026-05-14 22:22:23 -05:00
Robert Deaton e91c017dd0 fix(server): dedupe database backup jobs (#28341)
* fix(server): dedupe database backup jobs via jobId

#27268 shows backup jobs piling up in the queue across upgrades; one pending
backup is always enough.

* fix(tests): Avoid stale backup files from previous test runs being erroneously returned from createBackup

* fix(jobs): Use bullmq's deduplication over jobId to avoid failed jobs from blocking future executions.

---------

Co-authored-by: Robert Deaton <immich@rdeaton.space>
2026-05-14 20:59:15 -04:00
Alex 43687cd8b4 fix: kebab menu icon colors and actions (#28433) 2026-05-14 22:23:50 +00:00
shenlong 06729ee5a5 chore: cleanup unused store keys (#28415)
cleanup unused store keys

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-14 16:21:06 -05:00
Nojus Gudinavičius b0c9743d9a feat(server): allow subpaths for machine learning URL (#28427)
This allows to use a machine learning server URL under a subpath,
such as "http://example.com/ml-server/".
2026-05-14 12:46:31 +00:00
Marius 37cc028868 fix(mobile): use correct delete action (#26575)
fix(mobile): use correct delete for trashed assets

When viewing a trashed asset, the viewer bottom bar now shows the permanent delete button instead of the trash button, which had no effect on already-trashed assets.
2026-05-14 11:57:19 +00:00
Inês Costa 84a2b7a3c8 fix(mobile): add restore option to trashed assets (#27442) 2026-05-14 07:19:00 +00:00
racehd 89b3433346 feat(docs): add fixed subnet guide for Synology to prevent firewall issues (#26554)
* - Add Set Fixed Subnet section
- Add newline after details summary to properly render summary with mdx

* pnpm run format --write

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-05-13 23:54:13 +00:00
shenlong 3ff0d47ee3 chore: do not cache dart_tool (#28409)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-13 19:46:24 -04:00
shenlong aeaf846482 chore: cleanup unused store keys (#28415)
cleanup unused store keys

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-13 18:03:57 -05:00
Santo Shakil b031548791 fix(mobile): don't block app open on slow validateAccessToken (#28405)
* fix(mobile): don't block app open on slow validateAccessToken

AuthGuard.onNavigation was async so auto_route awaited the body through validateAccessToken's OS timeout. now it's sync and the validate runs in bg. kicks to login on 401.

* fix(mobile): handle re-login race in AuthGuard validate

if user logs out + logs back in during a slow validate, the old 401 was logging them out again. now we check the token hasn't changed before redirecting, and dedupe in-flight calls.

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-05-13 11:52:43 -05:00
Jason Rasmussen fcea617313 fix: ignore icc profile make and model (#28412) 2026-05-13 12:07:35 -04:00
Mees Frensel 024f20ea26 chore(web): use DatePicker component from UI lib (#28406) 2026-05-13 09:37:07 -05:00
shenlong 0a4ed6fd71 refactor: migrate viewer config to metadata table (#28396)
* refactor: app metadata

* refactor to per row store

* cleanup

* more test

* review changes

* more refactor

* refactor

* migrate primary color

* migrate dynamic theme

* migrate colorfulInterface

* cleanup providers

* migrate cleanup

* migrate mapconfig

* remove unused keys

* migrate timeline config

* migrate image config

* migrate viewer config

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-13 09:36:19 -05:00
Alex b6e2ce1f35 fix(mobile): revert drop deprecated deviceAssetId / deviceId from upload fields (#28384) (#28400)
* Revert "chore(mobile): drop deprecated deviceAssetId / deviceId from upload fields (#28384)"

This reverts commit 571e6a8560.

* chore(mobile): add note on kept deprecated upload fields

---------

Co-authored-by: Santo Shakil <shakil.mezbah@gmail.com>
2026-05-13 09:36:16 -05:00
bo0tzz e323e778cd fix: update server-commands subcommand list (#28402) 2026-05-13 09:27:25 -04:00
renovate[bot] 6a87797649 chore(deps): update terraform cloudflare to v4.52.7 (#28370)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 23:50:23 -04:00
renovate[bot] f4a4649bbc chore(deps): update dependency canvas to v3 (#28376)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 23:49:22 -04:00
82 changed files with 1591 additions and 701 deletions
-2
View File
@@ -116,7 +116,6 @@ jobs:
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Android SDK
@@ -189,7 +188,6 @@ jobs:
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
build-sign-ios:
+30 -30
View File
@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.5"
constraints = "4.52.5"
version = "4.52.7"
constraints = "4.52.7"
hashes = [
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
]
}
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.5"
version = "4.52.7"
}
}
}
+30 -30
View File
@@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.5"
constraints = "4.52.5"
version = "4.52.7"
constraints = "4.52.7"
hashes = [
"h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=",
"h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=",
"h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=",
"h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=",
"h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=",
"h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=",
"h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=",
"h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=",
"h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=",
"h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=",
"h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=",
"h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=",
"h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=",
"h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=",
"zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b",
"zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a",
"zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7",
"zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238",
"zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b",
"zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072",
"zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661",
"zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c",
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
]
}
+1 -1
View File
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.5"
version = "4.52.7"
}
}
}
@@ -13,8 +13,11 @@ The `immich-server` docker image comes preinstalled with an administrative CLI (
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `grant-admin` | Grant admin privileges to a user (by email) |
| `revoke-admin` | Revoke admin privileges from a user (by email) |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
| `schema-check` | Verify database migrations and check for schema drift |
## How to run a command
@@ -102,6 +105,22 @@ immich-admin list-users
]
```
Grant Admin
```
immich-admin grant-admin
? Please enter the user email: user@example.com
Admin access has been granted to user@example.com
```
Revoke Admin
```
immich-admin revoke-admin
? Please enter the user email: user@example.com
Admin access has been revoked from user@example.com
```
Print Immich Version
```
@@ -126,3 +145,12 @@ immich-admin change-media-location
Database file paths updated successfully! 🎉
...
```
Schema Check
```
immich-admin schema-check
Migrations are up to date
No schema drift detected
```
+66 -2
View File
@@ -52,7 +52,7 @@ Scroll to the bottom of the "**Details**" section and find the `IP Address` list
## Step 4 - Configure Firewall Settings
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS.
Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS to allow communication between the Immich containers.
Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**"
@@ -74,6 +74,7 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
<details>
<summary>Updating Immich using Container Manager</summary>
Check the post installation and upgrade instructions at the links above before proceeding with this section.
## Step 1. Backup
@@ -110,7 +111,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack,
## Step 5. Update firewall rule
The default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
Without a fixed subnet, the default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address.
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
![Container IP](../../static/img/synology-container-ip.png)
@@ -123,4 +124,67 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi
![Edit IP](../../static/img/synology-fw-ipedit.png)
To prevent future firewall issues, you may set a fixed subnet. [See Set Fixed Subnet](#set-fixed-subnet) for instructions.
</details>
<details id="set-fixed-subnet">
<summary>Set Fixed Subnet</summary>
Docker by default assigns dynamic subnets to bridge networks which can change when rebuilding containers and can cause firewall rules to break. To avoid this, define a fixed subnet in your `docker-compose.yml`:
## Step 1. Determine current subnet
Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address.
![Container IP](../../static/img/synology-container-ip.png)
## Step 2. Add network configuration
Add the following network configuration at the end of your `docker-compose.yml` file:
```yaml
networks:
immich-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
```
If your docker container is running on a different subnet then update accordingly.
## Step 3. Add network to each service
Add the network to each service (immich-server, immich-machine-learning, redis, database):
```yaml
services:
immich-server:
# other config options
networks:
- immich-network
immich-machine-learning:
# other config options
networks:
- immich-network
redis:
# other config options
networks:
- immich-network
database:
# other config options
networks:
- immich-network
```
Save your changes. Synology will ask if you want to save changes only or rebuild containers. Select rebuild containers.
## Step 4. Update Firewall Rules, if necessary
If your firewall rules were not already set for this subnet, the firewall rules will need to be updated. See [Step 4 - Configure Firewall Settings](#step-4---configure-firewall-settings).
</details>
@@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/admin/database-backups', () => {
let cookie: string | undefined;
@@ -13,6 +13,9 @@ describe('/admin/database-backups', () => {
admin = await utils.adminSetup({
onboarding: false,
});
});
beforeEach(async () => {
await utils.resetBackups(admin.accessToken);
});
+2
View File
@@ -568,6 +568,8 @@ export const utils = {
name: ManualJobName.BackupDatabase,
});
await utils.waitForQueueFinish(accessToken, 'backupDatabase');
return utils.poll(
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
({ status, body }) => status === 200 && body.backups.length === 1,
+1 -4
View File
@@ -885,15 +885,13 @@
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"dark": "Dark",
"dark_theme": "Switch to dark theme",
"date": "Date",
"date_after": "Date after",
"date_and_time": "Date and Time",
"date_before": "Date before",
"date_format": "E, LLL d, y • h:mm a",
"date_of_birth": "Date of birth",
"date_of_birth_saved": "Date of birth saved successfully",
"date_range": "Date range",
"day": "Day",
@@ -1583,7 +1581,6 @@
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
"model": "Model",
"month": "Month",
"monthly_title_text_date_format": "MMMM y",
"more": "More",
"motion": "Motion",
"move": "Move",
@@ -23,6 +23,8 @@ import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
class RemoteImagesImpl(context: Context) : RemoteImageApi {
@@ -228,7 +230,6 @@ private class CronetImageFetcher : ImageFetcher {
private val onComplete: () -> Unit,
) : UrlRequest.Callback() {
private var buffer: NativeByteBuffer? = null
private var wrapped: ByteBuffer? = null
private var error: Exception? = null
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
@@ -242,15 +243,16 @@ private class CronetImageFetcher : ImageFetcher {
}
try {
// Content-Length is a size hint only. With Content-Encoding (gzip/br/...),
// Cronet auto-decompresses and writes decompressed bytes to our buffer, which
// may exceed the wire/compressed Content-Length. Always use the growable
// buffer path so we can't overflow.
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
if (contentLength > 0) {
buffer = NativeByteBuffer(contentLength + 1)
wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
request.read(wrapped)
} else {
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
request.read(buffer!!.wrapRemaining())
}
// Cap the up-front alloc: Content-Length is untrusted and can be huge or near
// Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over.
val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE
buffer = NativeByteBuffer(initialSize)
request.read(buffer!!.wrapRemaining())
} catch (e: Exception) {
error = e
return request.cancel()
@@ -263,14 +265,14 @@ private class CronetImageFetcher : ImageFetcher {
byteBuffer: ByteBuffer
) {
try {
val buf = if (wrapped == null) {
buffer!!.run {
advance(byteBuffer.position())
ensureHeadroom()
wrapRemaining()
}
} else {
wrapped
// Always pass a fresh wrap so byteBuffer.position() represents only the
// bytes Cronet wrote in this iteration. Reusing the caller-supplied
// ByteBuffer breaks advance(): Cronet's position keeps accumulating
// across reads, which would double-count previous iterations' bytes.
val buf = buffer!!.run {
advance(byteBuffer.position())
ensureHeadroom()
wrapRemaining()
}
request.read(buf)
} catch (e: Exception) {
@@ -280,7 +282,6 @@ private class CronetImageFetcher : ImageFetcher {
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
wrapped?.let { buffer!!.advance(it.position()) }
onSuccess(buffer!!)
onComplete()
}
+1 -1
View File
@@ -110,7 +110,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
var domainAlbum = PlatformAlbum(
id: album.localIdentifier,
name: album.localizedTitle!,
name: album.localizedTitle ?? album.localIdentifier,
updatedAt: nil,
isCloud: isCloud,
assetCount: Int64(assets.count)
+4
View File
@@ -18,3 +18,7 @@ enum CleanupStep { selectDate, scan, delete }
enum AssetKeepType { none, photosOnly, videosOnly }
enum AssetDateAggregation { start, end }
enum SlideshowLook { contain, cover, blurredBackground }
enum SlideshowDirection { forward, backward, shuffle }
@@ -11,6 +11,7 @@ class RemoteAsset extends BaseAsset {
final String ownerId;
final String? stackId;
final DateTime? uploadedAt;
final DateTime? deletedAt;
const RemoteAsset({
required this.id,
@@ -31,6 +32,7 @@ class RemoteAsset extends BaseAsset {
super.livePhotoVideoId,
this.stackId,
required super.isEdited,
this.deletedAt,
}) : localAssetId = localId;
@override
@@ -48,6 +50,8 @@ class RemoteAsset extends BaseAsset {
@override
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
bool get isTrashed => deletedAt != null;
@override
String toString() {
return '''Asset {
@@ -86,7 +90,8 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash &&
visibility == other.visibility &&
stackId == other.stackId &&
uploadedAt == other.uploadedAt;
uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
}
@override
@@ -98,7 +103,8 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^
visibility.hashCode ^
stackId.hashCode ^
uploadedAt.hashCode;
uploadedAt.hashCode ^
deletedAt.hashCode;
RemoteAsset copyWith({
String? id,
@@ -119,6 +125,7 @@ class RemoteAsset extends BaseAsset {
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -139,6 +146,7 @@ class RemoteAsset extends BaseAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}
@@ -156,6 +164,7 @@ class RemoteAssetExif extends RemoteAsset {
required super.createdAt,
required super.updatedAt,
super.uploadedAt,
super.deletedAt,
super.width,
super.height,
super.durationMs,
@@ -193,6 +202,7 @@ class RemoteAssetExif extends RemoteAsset {
DateTime? createdAt,
DateTime? updatedAt,
DateTime? uploadedAt,
DateTime? deletedAt,
int? width,
int? height,
int? durationMs,
@@ -214,6 +224,7 @@ class RemoteAssetExif extends RemoteAsset {
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt,
deletedAt: deletedAt ?? this.deletedAt,
width: width ?? this.width,
height: height ?? this.height,
durationMs: durationMs ?? this.durationMs,
@@ -3,6 +3,8 @@ import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart';
import 'package:immich_mobile/domain/models/config/theme_config.dart';
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
class AppConfig {
final ThemeConfig theme;
@@ -10,6 +12,8 @@ class AppConfig {
final MapConfig map;
final TimelineConfig timeline;
final ImageConfig image;
final ViewerConfig viewer;
final SlideshowConfig slideshow;
const AppConfig({
this.theme = const .new(),
@@ -17,6 +21,8 @@ class AppConfig {
this.map = const .new(),
this.timeline = const .new(),
this.image = const .new(),
this.viewer = const .new(),
this.slideshow = const .new(),
});
AppConfig copyWith({
@@ -25,12 +31,16 @@ class AppConfig {
MapConfig? map,
TimelineConfig? timeline,
ImageConfig? image,
ViewerConfig? viewer,
SlideshowConfig? slideshow,
}) => .new(
theme: theme ?? this.theme,
cleanup: cleanup ?? this.cleanup,
map: map ?? this.map,
timeline: timeline ?? this.timeline,
image: image ?? this.image,
viewer: viewer ?? this.viewer,
slideshow: slideshow ?? this.slideshow,
);
@override
@@ -41,11 +51,14 @@ class AppConfig {
other.cleanup == cleanup &&
other.map == map &&
other.timeline == timeline &&
other.image == image);
other.image == image &&
other.viewer == viewer &&
other.slideshow == slideshow);
@override
int get hashCode => Object.hash(theme, cleanup, map, timeline, image);
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow);
@override
String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image)';
String toString() =>
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow)';
}
@@ -0,0 +1,48 @@
import 'package:immich_mobile/constants/enums.dart';
class SlideshowConfig {
final bool transition;
final bool repeat;
final int duration;
final SlideshowLook look;
final SlideshowDirection direction;
const SlideshowConfig({
this.transition = true,
this.repeat = true,
this.duration = 5,
this.look = SlideshowLook.contain,
this.direction = SlideshowDirection.forward,
});
SlideshowConfig copyWith({
bool? transition,
bool? repeat,
int? duration,
SlideshowLook? look,
SlideshowDirection? direction,
}) => SlideshowConfig(
transition: transition ?? this.transition,
repeat: repeat ?? this.repeat,
duration: duration ?? this.duration,
look: look ?? this.look,
direction: direction ?? this.direction,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is SlideshowConfig &&
other.transition == transition &&
other.repeat == repeat &&
other.duration == duration &&
other.look == look &&
other.direction == direction);
@override
int get hashCode => Object.hash(transition, repeat, duration, look, direction);
@override
String toString() =>
'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
}
@@ -0,0 +1,37 @@
class ViewerConfig {
final bool loopVideo;
final bool loadOriginalVideo;
final bool autoPlayVideo;
final bool tapToNavigate;
const ViewerConfig({
this.loopVideo = true,
this.loadOriginalVideo = false,
this.autoPlayVideo = true,
this.tapToNavigate = false,
});
ViewerConfig copyWith({bool? loopVideo, bool? loadOriginalVideo, bool? autoPlayVideo, bool? tapToNavigate}) =>
ViewerConfig(
loopVideo: loopVideo ?? this.loopVideo,
loadOriginalVideo: loadOriginalVideo ?? this.loadOriginalVideo,
autoPlayVideo: autoPlayVideo ?? this.autoPlayVideo,
tapToNavigate: tapToNavigate ?? this.tapToNavigate,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ViewerConfig &&
other.loopVideo == loopVideo &&
other.loadOriginalVideo == loadOriginalVideo &&
other.autoPlayVideo == autoPlayVideo &&
other.tapToNavigate == tapToNavigate);
@override
int get hashCode => Object.hash(loopVideo, loadOriginalVideo, autoPlayVideo, tapToNavigate);
@override
String toString() =>
'ViewerConfig(loopVideo: $loopVideo, loadOriginalVideo: $loadOriginalVideo, autoPlayVideo: $autoPlayVideo, tapToNavigate: $tapToNavigate)';
}
+19 -1
View File
@@ -28,6 +28,12 @@ enum MetadataKey<T extends Object> {
imagePreferRemote<bool>(.appConfig, 'image.preferRemote', false),
imageLoadOriginal<bool>(.appConfig, 'image.loadOriginal', false),
// Viewer
viewerLoopVideo<bool>(.appConfig, 'viewer.loopVideo', true),
viewerLoadOriginalVideo<bool>(.appConfig, 'viewer.loadOriginalVideo', false),
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
// Timeline
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
timelineGroupAssetsBy<GroupAssetsBy>(
@@ -58,7 +64,19 @@ enum MetadataKey<T extends Object> {
),
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false);
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false),
// Slideshow
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(
.appConfig,
'slideshow.direction',
SlideshowDirection.forward,
_EnumCodec(SlideshowDirection.values),
);
final MetadataDomain domain;
final String name;
@@ -1,8 +1,6 @@
import 'package:immich_mobile/domain/models/store.model.dart';
enum Setting<T> {
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, false),
autoPlayVideo<bool>(StoreKey.autoPlayVideo, true),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
enableBackup<bool>(StoreKey.enableBackup, false);
+4 -14
View File
@@ -4,25 +4,15 @@ import 'package:immich_mobile/domain/models/user.model.dart';
/// Defines the data type for each value
enum StoreKey<T> {
version<int>._(0),
assetETag<String>._(1),
currentUser<UserDto>._(2),
deviceIdHash<int>._(3),
deviceId<String>._(4),
backupFailedSince<DateTime>._(5),
backupRequireWifi<bool>._(6),
backupRequireCharging<bool>._(7),
backupTriggerDelay<int>._(8),
serverUrl<String>._(10),
accessToken<String>._(11),
serverEndpoint<String>._(12),
autoBackup<bool>._(13),
backgroundBackup<bool>._(14),
sslClientCertData<String>._(15),
sslClientPasswd<String>._(16),
uploadErrorNotificationGracePeriod<int>._(106),
selectedAlbumSortOrder<int>._(113),
advancedTroubleshooting<bool>._(114),
selfSignedCert<bool>._(120),
selectedAlbumSortReverse<bool>._(123),
enableHapticFeedback<bool>._(126),
customHeaders<String>._(127),
@@ -38,12 +28,8 @@ enum StoreKey<T> {
// Read-only Mode settings
readonlyModeEnabled<bool>._(138),
albumGridView<bool>._(140),
loadOriginal<bool>._(101),
// Image viewer navigation settings
loopVideo<bool>._(117),
loadOriginalVideo<bool>._(136),
autoPlayVideo<bool>._(139),
tapToNavigate<bool>._(141),
// Experimental stuff
@@ -53,6 +39,10 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013),
// Legacy keys that have been migrated to the new metadata store
legacyLoopVideo<bool>._(117),
legacyLoadOriginalVideo<bool>._(136),
legacyAutoPlayVideo<bool>._(139),
legacyTapToNavigate<bool>._(141),
legacyPreferRemoteImage<bool>._(116),
legacyLoadOriginal<bool>._(101),
legacyPrimaryColor<String>._(128),
@@ -74,5 +74,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
localId: localId,
stackId: stackId,
isEdited: isEdited,
deletedAt: deletedAt,
);
}
@@ -133,6 +133,19 @@ extension<T extends Object> on MetadataDomain<T> {
storageIndicator: repo._read(.timelineStorageIndicator),
),
image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)),
viewer: .new(
loopVideo: repo._read(.viewerLoopVideo),
loadOriginalVideo: repo._read(.viewerLoadOriginalVideo),
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
tapToNavigate: repo._read(.viewerTapToNavigate),
),
slideshow: .new(
transition: repo._read(.slideshowTransition),
repeat: repo._read(.slideshowRepeat),
duration: repo._read(.slideshowDuration),
look: repo._read(.slideshowLook),
direction: repo._read(.slideshowDirection),
),
);
case .systemConfig:
repo._systemConfig = .new(logLevel: repo._read(.logLevel));
@@ -0,0 +1,350 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@RoutePage()
class DriftSlideshowPage extends ConsumerStatefulWidget {
final TimelineService timeline;
const DriftSlideshowPage({super.key, required this.timeline});
@override
ConsumerState<DriftSlideshowPage> createState() => _DriftSlideshowPageState();
}
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
late final SlideshowConfig _config;
late final PageController _pageController;
late final Stopwatch _stopwatch;
late Timer _timer;
late int _index;
late int _nextIndex;
bool _paused = false;
bool _showAppBar = false;
@override
initState() {
super.initState();
_config = ref.read(appConfigProvider.select((s) => s.slideshow));
final asset = ref.read(assetViewerProvider).currentAsset;
_index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0;
_pageController = PageController(initialPage: _index);
_stopwatch = Stopwatch();
_createTimer();
_updateNextIndex();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
@override
dispose() {
_timer.cancel();
_stopwatch.stop();
_pageController.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
void _play() {
final asset = widget.timeline.getAssetSafe(_index)!;
if (asset.isImage) {
_createTimer();
} else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) {
ref.read(videoPlayerProvider(asset.heroTag).notifier).play();
} else {
_nextPage();
}
_updateNextIndex();
setState(() {
_paused = false;
});
}
void _pause() {
_timer.cancel();
_stopwatch.stop();
final asset = widget.timeline.getAssetSafe(_index)!;
if (!asset.isImage) {
ref.read(videoPlayerProvider(asset.heroTag).notifier).pause();
}
setState(() {
_paused = true;
});
}
void _updateNextIndex() {
_nextIndex = switch (_config.direction) {
SlideshowDirection.forward => _index + 1,
SlideshowDirection.backward => _index - 1,
SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!,
};
if (!widget.timeline.hasRange(_nextIndex, 1)) {
widget.timeline.preloadAssets(_nextIndex);
}
}
void _nextPage() async {
if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) {
if (_config.repeat) {
final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1;
await widget.timeline.preloadAssets(wrapped);
_pageController.jumpToPage(wrapped);
}
return;
}
if (!widget.timeline.hasRange(_nextIndex, 1)) {
await widget.timeline.preloadAssets(_nextIndex);
}
if (_config.direction == SlideshowDirection.shuffle || !_config.transition) {
_pageController.jumpToPage(_nextIndex);
} else {
unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn));
}
}
void _createTimer() {
_timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () {
_stopwatch.stop();
_stopwatch.reset();
_nextPage();
});
_stopwatch.start();
}
void _pageChanged(int page) {
final asset = widget.timeline.getAssetSafe(page)!;
setState(() {
_index = page;
if (!asset.isImage) {
_paused = false;
}
});
_timer.cancel();
_stopwatch.stop();
_stopwatch.reset();
if (!_paused && asset.isImage) {
_createTimer();
}
_updateNextIndex();
}
void _onTapUp() async {
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_showAppBar = !_showAppBar;
});
});
}
Widget _getProgressBar(BuildContext context) {
final asset = widget.timeline.getAssetSafe(_index);
if (asset == null) {
return Container();
}
if (asset.isImage) {
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
return TweenAnimationBuilder(
key: Key(_index.toString()),
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => LinearProgressIndicator(
color: context.colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value: value,
),
);
} else {
return LinearProgressIndicator(
color: context.colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value:
ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds /
asset.duration.inMilliseconds,
);
}
}
Widget _getBlur(BuildContext context, int index) {
final asset = widget.timeline.getAssetSafe(index);
if (asset == null) {
return Container();
}
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
fit: BoxFit.cover,
),
),
child: Container(color: Colors.black.withValues(alpha: 0.2)),
),
);
}
Widget _getPhotoView(BuildContext context, int index) {
final asset = widget.timeline.getAssetSafe(index);
if (asset == null) {
return const Center(child: ImmichLoadingIndicator());
}
final scale = _config.look == SlideshowLook.cover
? PhotoViewComputedScale.covered
: PhotoViewComputedScale.contained;
final isCurrent = _index == index;
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
if (asset.isImage) {
final zoomOut = index % 2 == 1;
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble();
return TweenAnimationBuilder(
tween: Tween<double>(
begin: progress,
end: _paused
? progress
: zoomOut
? 0.0
: 1.0,
),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => PhotoView(
imageProvider: imageProvider,
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: scale * (1.0 + value / 10.0),
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
),
);
} else {
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
final position = ref.read(videoPlayerProvider(asset.heroTag)).position;
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
_nextPage();
} else if (status == VideoPlaybackStatus.playing) {
ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false);
}
return PhotoView.customChild(
onTapUp: (_, _, _) => _onTapUp(),
disableScaleGestures: true,
filterQuality: FilterQuality.high,
initialScale: scale,
child: NativeVideoViewer(
asset: asset,
isCurrent: isCurrent,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5),
child: IgnorePointer(
ignoring: !_showAppBar,
child: AnimatedOpacity(
opacity: _showAppBar ? 1.0 : 0.0,
duration: Durations.short2,
child: Column(
children: [
AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("slideshow".t(context: context)),
actions: [
IconButton(
onPressed: _paused ? _play : _pause,
icon: Icon(_paused ? Icons.play_arrow : Icons.pause),
),
IconButton(
onPressed: () {
_pause();
context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer));
},
icon: const Icon(Icons.settings),
),
],
),
_getProgressBar(context),
],
),
),
),
),
extendBody: true,
extendBodyBehindAppBar: true,
backgroundColor: Colors.black,
body: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: const FastClampingScrollPhysics(),
itemCount: widget.timeline.totalAssets,
onPageChanged: _pageChanged,
itemBuilder: (context, index) => Stack(
children: [
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
_getPhotoView(context, index),
],
),
),
),
);
}
}
@@ -35,10 +35,11 @@ class BaseActionButton extends ConsumerWidget {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color;
if (iconOnly) {
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return IconButton(
onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor),
@@ -46,17 +47,21 @@ class BaseActionButton extends ConsumerWidget {
}
if (menuItem) {
final theme = context.themeData;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
final iconColor = this.iconColor;
return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
style: MenuItemButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
leadingIcon: Icon(iconData, color: iconColor, size: 20),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
);
}
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton(
@@ -18,8 +18,15 @@ class DeletePermanentActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
final bool useShortLabel;
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
const DeletePermanentActionButton({
super.key,
required this.source,
this.iconOnly = false,
this.menuItem = false,
this.useShortLabel = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
@@ -64,7 +71,7 @@ class DeletePermanentActionButton extends ConsumerWidget {
return BaseActionButton(
maxWidth: 110.0,
iconData: Icons.delete_forever,
label: "delete_permanently".t(context: context),
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RestoreActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.history_rounded,
label: 'restore'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100.0,
);
}
}
@@ -0,0 +1,34 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SlideshowActionButton extends ConsumerWidget {
final bool iconOnly;
final bool menuItem;
const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) {
if (!context.mounted) {
return;
}
context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.slideshow,
label: "slideshow".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
@@ -17,11 +17,10 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widg
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -231,7 +230,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
return;
}
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
final tapToNavigate = ref.read(metadataProvider).appConfig.viewer.tapToNavigate;
if (!tapToNavigate) {
_viewer.toggleControls();
return;
@@ -2,15 +2,19 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -33,23 +37,31 @@ class ViewerBottomBar extends ConsumerWidget {
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final originalTheme = context.themeData;
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
],
];
@@ -3,21 +3,17 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
@@ -132,7 +128,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
final isOriginalVideo = ref.read(metadataProvider).appConfig.viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
@@ -165,7 +161,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
return;
}
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
final autoPlayVideo = ref.read(metadataProvider).appConfig.viewer.autoPlayVideo;
if (autoPlayVideo || widget.asset.isMotionPhoto) {
await _notifier.play();
}
@@ -216,7 +212,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
await _notifier.load(source);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
final loopVideo = ref.read(metadataProvider).appConfig.viewer.loopVideo;
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1);
}
@@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
timelineOrigin: timelineOrigin,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
return MenuAnchor(
consumeOutsideTap: true,
@@ -120,6 +120,9 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
},
flightShuttleBuilder: (context, animation, direction, from, to) {
void animationStatusListener(AnimationStatus status) {
if (!mounted) {
return;
}
final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse;
if (_hideIndicators != heroInFlight) {
setState(() => _hideIndicators = heroInFlight);
+1 -3
View File
@@ -11,12 +11,11 @@ import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -144,7 +143,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
// Due to the flow of the code, this will always happen on first login
user = serverUser;
await Store.put(StoreKey.deviceId, deviceId);
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
}
} on ApiException catch (error, stackTrace) {
if (error.code == 401) {
+43 -18
View File
@@ -15,37 +15,62 @@ class AuthGuard extends AutoRouteGuard {
final ApiService _apiService;
final AuthService _authService;
final _log = Logger("AuthGuard");
bool _validateInFlight = false;
AuthGuard(this._apiService, this._authService);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) async {
resolver.next(true);
void onNavigation(NavigationResolver resolver, StackRouter router) {
// Synchronously check for the access token. auto_route awaits async
// guards, so we keep this function fully sync and validate the token in
// the background — otherwise a slow validateAccessToken() request would
// block the route transition for as long as the OS-level HTTP timeout.
try {
// Look in the store for an access token
Store.get(StoreKey.accessToken);
// Validate the access token with the server
final res = await _apiService.authenticationApi.validateAccessToken();
if (res == null || res.authStatus != true) {
// If the access token is invalid, take user back to login
_log.fine('User token is invalid. Redirecting to login');
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
}
} on StoreKeyNotFoundException catch (_) {
// If there is no access token, take us to the login page
_log.warning('No access token in the store.');
resolver.next(false);
unawaited(router.replaceAll([const LoginRoute()]));
return;
}
resolver.next(true);
unawaited(_validateAccessTokenInBackground(router));
}
Future<void> _validateAccessTokenInBackground(StackRouter router) async {
if (_validateInFlight) {
return;
}
final token = Store.tryGet(StoreKey.accessToken);
if (token == null) {
return;
}
_validateInFlight = true;
try {
final res = await _apiService.authenticationApi.validateAccessToken();
if (res == null || res.authStatus != true) {
// Token may have changed during validation (user logged out + logged in
// again); only act if it still applies to the current session.
if (Store.tryGet(StoreKey.accessToken) != token) {
return;
}
_log.fine('User token is invalid. Redirecting to login');
await router.replaceAll([const LoginRoute()]);
await _authService.clearLocalData();
}
} on ApiException catch (e) {
// On an unauthorized request, take us to the login page
if (e.code == HttpStatus.unauthorized) {
_log.warning("Unauthorized access token.");
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
if (e.code != HttpStatus.unauthorized) {
return;
}
if (Store.tryGet(StoreKey.accessToken) != token) {
return;
}
_log.warning("Unauthorized access token.");
await router.replaceAll([const LoginRoute()]);
await _authService.clearLocalData();
} catch (e) {
// Otherwise, this is not fatal, but we still log the warning
_log.warning('Error validating access token from server: $e');
} finally {
_validateInFlight = false;
}
}
}
+2
View File
@@ -60,6 +60,7 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
@@ -189,6 +190,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),
+47
View File
@@ -1095,6 +1095,53 @@ class DriftSearchRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftSlideshowPage]
class DriftSlideshowRoute extends PageRouteInfo<DriftSlideshowRouteArgs> {
DriftSlideshowRoute({
Key? key,
required TimelineService timeline,
List<PageRouteInfo>? children,
}) : super(
DriftSlideshowRoute.name,
args: DriftSlideshowRouteArgs(key: key, timeline: timeline),
initialChildren: children,
);
static const String name = 'DriftSlideshowRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftSlideshowRouteArgs>();
return DriftSlideshowPage(key: args.key, timeline: args.timeline);
},
);
}
class DriftSlideshowRouteArgs {
const DriftSlideshowRouteArgs({this.key, required this.timeline});
final Key? key;
final TimelineService timeline;
@override
String toString() {
return 'DriftSlideshowRouteArgs{key: $key, timeline: $timeline}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DriftSlideshowRouteArgs) return false;
return key == other.key && timeline == other.timeline;
}
@override
int get hashCode => key.hashCode ^ timeline.hashCode;
}
/// generated route for
/// [DriftTrashPage]
class DriftTrashRoute extends PageRouteInfo<void> {
@@ -2,19 +2,9 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
uploadErrorNotificationGracePeriod<int>(
StoreKey.uploadErrorNotificationGracePeriod,
"uploadErrorNotificationGracePeriod",
2,
),
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),
autoPlayVideo<bool>(StoreKey.autoPlayVideo, "autoPlayVideo", true),
tapToNavigate<bool>(StoreKey.tapToNavigate, "tapToNavigate", false),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
-1
View File
@@ -123,7 +123,6 @@ class AuthService {
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.autoEndpointSwitching),
Store.delete(StoreKey.preferredWifiName),
Store.delete(StoreKey.localEndpoint),
@@ -394,9 +394,13 @@ class BackgroundUploadService {
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString();
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
final fieldsMap = {
'filename': originalFileName ?? filename,
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': deviceAssetId ?? '',
'deviceId': deviceId,
'fileCreatedAt': createdAt.toUtc().toIso8601String(),
'fileModifiedAt': modifiedAt.toUtc().toIso8601String(),
'isFavorite': isFavorite?.toString() ?? 'false',
@@ -5,6 +5,8 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -319,8 +321,12 @@ class ForegroundUploadService {
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId);
final fields = {
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': asset.localId!,
'deviceId': deviceId,
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(),
@@ -426,6 +432,9 @@ class ForegroundUploadService {
final filename = p.basename(file.path);
final fields = {
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': deviceAssetId,
'deviceId': Store.get(StoreKey.deviceId),
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': 'false',
+25 -6
View File
@@ -21,11 +21,13 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
@@ -72,6 +74,7 @@ enum ActionButtonType {
similarPhotos,
setProfilePicture,
viewInTimeline,
slideshow,
download,
upload,
openInBrowser,
@@ -81,6 +84,7 @@ enum ActionButtonType {
moveToLockFolder,
removeFromLockFolder,
removeFromAlbum,
restoreTrash,
trash,
deleteLocal,
deletePermanent,
@@ -112,12 +116,17 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.isTrashEnabled,
context.isTrashEnabled && //
context.timelineOrigin != TimelineOrigin.trash,
ActionButtonType.restoreTrash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash,
ActionButtonType.deletePermanent =>
context.isOwner && //
context.asset.hasRemote && //
!context.isTrashEnabled ||
context.isInLockedView,
context.asset.hasRemote && //
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
@@ -172,6 +181,7 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
ActionButtonType.slideshow => true,
};
}
@@ -193,6 +203,7 @@ enum ActionButtonType {
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unarchive => UnArchiveActionButton(
source: context.source,
@@ -201,6 +212,11 @@ enum ActionButtonType {
),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => RestoreActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source,
iconOnly: iconOnly,
@@ -292,6 +308,7 @@ enum ActionButtonType {
ActionButtonType.moveToLockFolder => 10,
ActionButtonType.deleteLocal => 10,
ActionButtonType.delete => 10,
ActionButtonType.restoreTrash => 10,
// 90: advancedInfo
ActionButtonType.advancedInfo => 90,
// 1: others
@@ -309,13 +326,15 @@ class ActionButtonBuilder {
ActionButtonType.delete,
ActionButtonType.archive,
ActionButtonType.unarchive,
ActionButtonType.restoreTrash,
ActionButtonType.deletePermanent,
};
static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = defaultViewerKebabMenuOrder
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
.toList();
@@ -331,7 +350,7 @@ class ActionButtonBuilder {
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext, false, true));
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
lastGroup = type.kebabMenuGroup;
}
+5
View File
@@ -91,6 +91,11 @@ Future<void> _migrateTo26(Drift drift) async {
// Image
await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, MetadataKey.imagePreferRemote);
await migrator.migrateBool(StoreKey.legacyLoadOriginal, MetadataKey.imageLoadOriginal);
// Viewer
await migrator.migrateBool(StoreKey.legacyLoopVideo, MetadataKey.viewerLoopVideo);
await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo);
await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo);
await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate);
await migrator.complete();
}
@@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart';
class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
@@ -89,6 +90,10 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
onPressed: () => context.maybePop(),
),
actions: [
IconButton(
onPressed: () => context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))),
icon: Icon(Icons.slideshow_outlined, color: actionIconColor, shadows: actionIconShadows),
),
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/slideshow_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
class AssetViewerSettings extends StatelessWidget {
@@ -13,6 +14,7 @@ class AssetViewerSettings extends StatelessWidget {
const ImageViewerQualitySetting(),
const ImageViewerTapToNavigateSetting(),
const VideoViewerSettings(),
const SlideshowSettings(),
];
return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true);
@@ -1,18 +1,20 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
const ImageViewerTapToNavigateSetting({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate);
final tapToNavigate = useState(ref.read(appConfigProvider).viewer.tapToNavigate);
useValueChanged<bool, void>(tapToNavigate.value, (_, __) {
ref.read(metadataProvider).write(.viewerTapToNavigate, tapToNavigate.value);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -22,7 +24,6 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
valueNotifier: tapToNavigate,
title: "setting_image_navigation_enable_title".tr(),
subtitle: "setting_image_navigation_enable_subtitle".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
class SlideshowSettings extends HookConsumerWidget {
const SlideshowSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final slideshow = ref.read(appConfigProvider).slideshow;
final useTransition = useState(slideshow.transition);
final useRepeat = useState(slideshow.repeat);
final useDuration = useState(slideshow.duration);
final useLook = useState(slideshow.look);
final useDirection = useState(slideshow.direction);
useValueChanged<bool, void>(useTransition.value, (_, __) {
ref.read(metadataProvider).write(.slideshowTransition, useTransition.value);
});
useValueChanged<bool, void>(useRepeat.value, (_, __) {
ref.read(metadataProvider).write(.slideshowRepeat, useRepeat.value);
});
useValueChanged<int, void>(useDuration.value, (_, __) {
ref.read(metadataProvider).write(.slideshowDuration, useDuration.value);
});
useValueChanged<SlideshowLook, void>(useLook.value, (_, __) {
ref.read(metadataProvider).write(.slideshowLook, useLook.value);
});
useValueChanged<SlideshowDirection, void>(useDirection.value, (_, __) {
ref.read(metadataProvider).write(.slideshowDirection, useDirection.value);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingGroupTitle(
title: 'slideshow'.t(context: context),
icon: Icons.slideshow_outlined,
),
SettingsSwitchListTile(
valueNotifier: useTransition,
title: "show_slideshow_transition".t(context: context),
enabled: useDirection.value != SlideshowDirection.shuffle,
),
SettingsSwitchListTile(
valueNotifier: useRepeat,
title: "slideshow_repeat".t(context: context),
subtitle: "slideshow_repeat_description".t(context: context),
),
SettingsSliderListTile(
valueNotifier: useDuration,
text: "duration".t(context: context),
minValue: 5,
noDivisons: 5,
maxValue: 30,
),
Padding(
padding: const EdgeInsets.only(top: 20),
child: SettingsSubTitle(title: 'look'.t(context: context)),
),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'contain'.t(context: context),
value: SlideshowLook.contain,
),
SettingsRadioGroup(
title: 'cover'.t(context: context),
value: SlideshowLook.cover,
),
SettingsRadioGroup(
title: 'blurred_background'.t(context: context),
value: SlideshowLook.blurredBackground,
),
],
groupBy: useLook.value,
onRadioChanged: (value) {
if (value != null) {
useLook.value = value;
}
},
),
Padding(
padding: const EdgeInsets.only(top: 20),
child: SettingsSubTitle(title: 'direction'.t(context: context)),
),
Padding(
padding: const EdgeInsets.only(bottom: 32),
child: SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'forward'.t(context: context),
value: SlideshowDirection.forward,
),
SettingsRadioGroup(
title: 'backward'.t(context: context),
value: SlideshowDirection.backward,
),
SettingsRadioGroup(
title: 'shuffle'.t(context: context),
value: SlideshowDirection.shuffle,
),
],
groupBy: useDirection.value,
onRadioChanged: (value) {
if (value != null) {
useDirection.value = value;
}
},
),
),
],
);
}
}
@@ -1,20 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class VideoViewerSettings extends HookConsumerWidget {
const VideoViewerSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final useLoopVideo = useAppSettingsState(AppSettingsEnum.loopVideo);
final useOriginalVideo = useAppSettingsState(AppSettingsEnum.loadOriginalVideo);
final useAutoPlayVideo = useAppSettingsState(AppSettingsEnum.autoPlayVideo);
final viewer = ref.read(appConfigProvider).viewer;
final useAutoPlayVideo = useState(viewer.autoPlayVideo);
final useLoopVideo = useState(viewer.loopVideo);
final useOriginalVideo = useState(viewer.loadOriginalVideo);
useValueChanged<bool, void>(useAutoPlayVideo.value, (_, __) {
ref.read(metadataProvider).write(.viewerAutoPlayVideo, useAutoPlayVideo.value);
});
useValueChanged<bool, void>(useLoopVideo.value, (_, __) {
ref.read(metadataProvider).write(.viewerLoopVideo, useLoopVideo.value);
});
useValueChanged<bool, void>(useOriginalVideo.value, (_, __) {
ref.read(metadataProvider).write(.viewerLoadOriginalVideo, useOriginalVideo.value);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -27,19 +37,16 @@ class VideoViewerSettings extends HookConsumerWidget {
valueNotifier: useAutoPlayVideo,
title: "setting_video_viewer_auto_play_title".t(context: context),
subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSwitchListTile(
valueNotifier: useLoopVideo,
title: "setting_video_viewer_looping_title".t(context: context),
subtitle: "loop_videos_description".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSwitchListTile(
valueNotifier: useOriginalVideo,
title: "setting_video_viewer_original_video_title".t(context: context),
subtitle: "setting_video_viewer_original_video_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);
@@ -3,10 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -16,9 +13,6 @@ class NotificationSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final permissionService = ref.watch(notificationPermissionProvider);
final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod);
final hasPermission = permissionService == PermissionStatus.granted;
openAppNotificationSettings(BuildContext ctx) {
@@ -41,8 +35,6 @@ class NotificationSetting extends HookConsumerWidget {
);
}
final String formattedValue = _formatSliderValue(sliderValue.value.toDouble());
final notificationSettings = [
if (!hasPermission)
SettingsButtonListTile(
@@ -57,32 +49,8 @@ class NotificationSetting extends HookConsumerWidget {
}
}),
),
SettingsSliderListTile(
enabled: hasPermission,
valueNotifier: sliderValue,
text: 'setting_notifications_notify_failures_grace_period'.tr(namedArgs: {'duration': formattedValue}),
maxValue: 5.0,
noDivisons: 5,
label: formattedValue,
),
];
return SettingsSubPageScaffold(settings: notificationSettings);
}
}
String _formatSliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr();
} else if (v == 1.0) {
return 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '30'});
} else if (v == 2.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '2'});
} else if (v == 3.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '8'});
} else if (v == 4.0) {
return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '24'});
} else {
return 'setting_notifications_notify_never'.tr();
}
}
@@ -9,9 +9,8 @@ import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
const _kAccessToken = '#ThisIsAToken';
const _kBackgroundBackup = false;
const _kEnableBackup = false;
const _kVersion = 2;
final _kBackupFailedSince = DateTime.utc(2023);
void main() {
late StoreService sut;
@@ -24,15 +23,13 @@ void main() {
// For generics, we need to provide fallback to each concrete type to avoid runtime errors
registerFallbackValue(StoreKey.accessToken);
registerFallbackValue(StoreKey.backupTriggerDelay);
registerFallbackValue(StoreKey.backgroundBackup);
registerFallbackValue(StoreKey.backupFailedSince);
registerFallbackValue(StoreKey.enableBackup);
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
(_) async => [
const StoreDto(StoreKey.accessToken, _kAccessToken),
const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup),
const StoreDto(StoreKey.enableBackup, _kEnableBackup),
const StoreDto(StoreKey.version, _kVersion),
StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince),
],
);
when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
@@ -49,9 +46,8 @@ void main() {
test('Populates the internal cache on init', () {
verify(() => mockDriftStoreRepo.getAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup);
expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup);
expect(sut.tryGet(StoreKey.version), _kVersion);
expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince);
// Other keys should be null
expect(sut.tryGet(StoreKey.currentUser), isNull);
});
@@ -151,9 +147,8 @@ void main() {
await sut.clear();
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
expect(sut.tryGet(StoreKey.accessToken), isNull);
expect(sut.tryGet(StoreKey.backgroundBackup), isNull);
expect(sut.tryGet(StoreKey.enableBackup), isNull);
expect(sut.tryGet(StoreKey.version), isNull);
expect(sut.tryGet(StoreKey.backupFailedSince), isNull);
});
});
}
@@ -12,9 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import '../../fixtures/user.stub.dart';
const _kTestAccessToken = "#TestToken";
final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45);
const _kTestVersion = 10;
const _kTestBackupRequireWifi = false;
const _kTestBackupRequireCharging = false;
final _kTestUser = UserStub.admin;
Future<void> _populateStore(Drift db) async {
@@ -22,16 +21,8 @@ Future<void> _populateStore(Drift db) async {
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.backupRequireWifi.id),
intValue: const Value(_kTestBackupRequireWifi ? 1 : 0),
stringValue: const Value(null),
),
);
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.backupFailedSince.id),
intValue: Value(_kTestBackupFailed.millisecondsSinceEpoch),
id: Value(StoreKey.backupRequireCharging.id),
intValue: const Value(_kTestBackupRequireCharging ? 1 : 0),
stringValue: const Value(null),
),
);
@@ -84,20 +75,12 @@ void main() {
expect(accessToken, _kTestAccessToken);
});
test('converts datetime', () async {
DateTime? backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince);
expect(backupFailedSince, isNull);
await sut.upsert(StoreKey.backupFailedSince, _kTestBackupFailed);
backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince);
expect(backupFailedSince, _kTestBackupFailed);
});
test('converts bool', () async {
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, isNull);
await sut.upsert(StoreKey.backupRequireWifi, _kTestBackupRequireWifi);
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, _kTestBackupRequireWifi);
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, isNull);
await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging);
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, _kTestBackupRequireCharging);
});
test('converts user', () async {
@@ -115,11 +98,11 @@ void main() {
});
test('delete()', () async {
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, isFalse);
await sut.delete(StoreKey.backupRequireWifi);
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(backupRequireWifi, isNull);
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, isFalse);
await sut.delete(StoreKey.backupRequireCharging);
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
expect(backupRequireCharging, isNull);
});
test('deleteAll()', () async {
@@ -164,14 +147,12 @@ void main() {
emitsInOrder([
[
const StoreDto<Object>(StoreKey.version, _kTestVersion),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
],
[
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
],
]),
@@ -79,7 +79,6 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.system);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.dark);
});
@@ -90,7 +89,6 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.dark);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
@@ -135,5 +133,4 @@ void main() {
await expectation;
});
});
}
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
LocalAsset createLocalAsset({
@@ -37,6 +38,7 @@ RemoteAsset createRemoteAsset({
DateTime? updatedAt,
DateTime? uploadedAt,
bool isFavorite = false,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: 'remote-id',
@@ -50,6 +52,7 @@ RemoteAsset createRemoteAsset({
uploadedAt: uploadedAt ?? DateTime.now(),
isFavorite: isFavorite,
isEdited: false,
deletedAt: deletedAt,
);
}
@@ -458,6 +461,62 @@ void main() {
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
test('should not show when asset is already trashed', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
});
group('restoreTrash button', () {
test('should show when owner, not locked, has remote, and is in trash timeline', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.restoreTrash.shouldShow(context), isTrue);
});
test('should not show when not in trash timeline', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
timelineOrigin: TimelineOrigin.main,
);
expect(ActionButtonType.restoreTrash.shouldShow(context), isFalse);
});
});
group('deletePermanent button', () {
@@ -494,6 +553,24 @@ void main() {
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
});
test('should show when asset is trashed even with trash enabled', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
});
});
group('delete button', () {
+90 -145
View File
@@ -6,7 +6,7 @@ settings:
injectWorkspacePackages: true
overrides:
canvas: 2.11.2
canvas: 3.2.3
sharp: ^0.34.5
webpackbar: ^7.0.0
@@ -198,7 +198,7 @@ importers:
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages/cli:
dependencies:
@@ -289,7 +289,7 @@ importers:
version: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@4.1.5)
@@ -663,7 +663,7 @@ importers:
version: 13.15.10
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
eslint:
specifier: ^10.0.0
version: 10.2.1(jiti@2.6.1)
@@ -717,7 +717,7 @@ importers:
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
web:
dependencies:
@@ -973,7 +973,7 @@ importers:
version: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages:
@@ -6182,9 +6182,9 @@ packages:
caniuse-lite@1.0.30001790:
resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==}
canvas@2.11.2:
resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==}
engines: {node: '>=6'}
canvas@3.2.3:
resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==}
engines: {node: ^18.12.0 || >= 20.9.0}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -6964,10 +6964,6 @@ packages:
decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
decompress-response@4.2.1:
resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==}
engines: {node: '>=8'}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@@ -7567,6 +7563,10 @@ packages:
resolution: {integrity: sha512-Yn66dSBaWGcUaSbm5Nl4G28rxtceLlWf4PstqJMbLix9sN7w0okWHPEvdudiP56Q5Cjl7v3TLyKKwowUFlbD8g==}
engines: {node: '>=20.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -7866,6 +7866,9 @@ packages:
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
github-slugger@1.5.0:
resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==}
@@ -8591,7 +8594,7 @@ packages:
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
engines: {node: '>=18'}
peerDependencies:
canvas: 2.11.2
canvas: 3.2.3
peerDependenciesMeta:
canvas:
optional: true
@@ -9308,10 +9311,6 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
mimic-response@2.1.0:
resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==}
engines: {node: '>=8'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
@@ -9479,6 +9478,9 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
natural-compare-lite@1.4.0:
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
@@ -9552,6 +9554,10 @@ packages:
no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
node-abi@3.92.0:
resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
engines: {node: '>=10'}
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
@@ -10499,6 +10505,12 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -11176,8 +11188,8 @@ packages:
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@3.1.1:
resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-icons@16.17.0:
resolution: {integrity: sha512-bRrGtzM6NLgxeMWmRcfDdrRksECk101lRrCn6jjj6qzUB6lQ+E5smnr52rqS1kLPmbLpS/g6iF463j50M4BT7A==}
@@ -11886,6 +11898,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
@@ -15799,22 +15814,6 @@ snapshots:
'@mapbox/mapbox-gl-rtl-text@0.4.0': {}
'@mapbox/node-pre-gyp@1.0.11':
dependencies:
detect-libc: 2.1.2
https-proxy-agent: 5.0.1
make-dir: 3.1.0
node-fetch: 2.7.0
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.4
tar: 6.2.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)':
dependencies:
detect-libc: 2.1.2
@@ -17248,7 +17247,7 @@ snapshots:
svelte: 5.55.2
optionalDependencies:
vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
@@ -17964,7 +17963,7 @@ snapshots:
'@vercel/oidc@3.0.5': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -17979,7 +17978,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -17995,7 +17994,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@3.2.4':
dependencies:
@@ -18762,24 +18761,10 @@ snapshots:
caniuse-lite@1.0.30001790: {}
canvas@2.11.2:
canvas@3.2.3:
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
nan: 2.26.2
simple-get: 3.1.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
canvas@2.11.2(encoding@0.1.13):
dependencies:
'@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13)
nan: 2.26.2
simple-get: 3.1.1
transitivePeerDependencies:
- encoding
- supports-color
node-addon-api: 7.1.1
prebuild-install: 7.1.3
optional: true
ccount@2.0.1: {}
@@ -19582,11 +19567,6 @@ snapshots:
dependencies:
character-entities: 2.0.2
decompress-response@4.2.1:
dependencies:
mimic-response: 2.1.0
optional: true
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
@@ -20331,6 +20311,9 @@ snapshots:
optionalDependencies:
exiftool-vendored.exe: 13.58.0
expand-template@2.0.3:
optional: true
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
@@ -20416,11 +20399,10 @@ snapshots:
fabric@7.3.1:
optionalDependencies:
canvas: 2.11.2
jsdom: 26.1.0(canvas@2.11.2)
canvas: 3.2.3
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
- bufferutil
- encoding
- supports-color
- utf-8-validate
@@ -20712,6 +20694,9 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-from-package@0.0.0:
optional: true
github-slugger@1.5.0: {}
gl-matrix@3.4.4: {}
@@ -21533,7 +21518,7 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)):
jsdom@26.1.0(canvas@3.2.3):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
@@ -21556,37 +21541,7 @@ snapshots:
ws: 8.20.0
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 2.11.2(encoding@0.1.13)
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
optional: true
jsdom@26.1.0(canvas@2.11.2):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.23
parse5: 7.3.0
rrweb-cssom: 0.8.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 5.1.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
ws: 8.20.0
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 2.11.2
canvas: 3.2.3
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -22589,9 +22544,6 @@ snapshots:
mimic-function@5.0.1: {}
mimic-response@2.1.0:
optional: true
mimic-response@3.1.0: {}
mimic-response@4.0.0: {}
@@ -22745,6 +22697,9 @@ snapshots:
nanoid@5.1.9: {}
napi-build-utils@2.0.0:
optional: true
natural-compare-lite@1.4.0: {}
natural-compare@1.4.0: {}
@@ -22817,6 +22772,11 @@ snapshots:
lower-case: 2.0.2
tslib: 2.8.1
node-abi@3.92.0:
dependencies:
semver: 7.7.4
optional: true
node-abort-controller@3.1.1: {}
node-addon-api@4.3.0: {}
@@ -22837,11 +22797,6 @@ snapshots:
emojilib: 2.4.0
skin-tone: 2.0.0
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
optional: true
node-fetch@2.7.0(encoding@0.1.13):
dependencies:
whatwg-url: 5.0.0
@@ -23808,6 +23763,22 @@ snapshots:
powershell-utils@0.1.0: {}
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.92.0
pump: 3.0.4
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
optional: true
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1:
@@ -24731,9 +24702,9 @@ snapshots:
simple-concat@1.0.1:
optional: true
simple-get@3.1.1:
simple-get@4.0.1:
dependencies:
decompress-response: 4.2.1
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
optional: true
@@ -25575,6 +25546,11 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
optional: true
tweetnacl@0.14.5: {}
type-check@0.4.0:
@@ -25984,9 +25960,9 @@ snapshots:
vitest-fetch-mock@0.4.5(vitest@4.1.5):
dependencies:
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -26015,7 +25991,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 24.12.2
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2)
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
- jiti
- less
@@ -26030,7 +26006,7 @@ snapshots:
- tsx
- yaml
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -26057,42 +26033,11 @@ snapshots:
'@types/node': 24.12.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
- msw
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
'@vitest/spy': 4.1.5
'@vitest/utils': 4.1.5
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.12.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- msw
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -26119,7 +26064,7 @@ snapshots:
'@types/node': 25.6.0
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@2.11.2)
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
- msw
+1 -1
View File
@@ -28,7 +28,7 @@ onlyBuiltDependencies:
- '@tailwindcss/oxide'
- bcrypt
overrides:
canvas: 2.11.2
canvas: 3.2.3
sharp: ^0.34.5
# pending docusaurus 3.10.1
webpackbar: ^7.0.0
+11 -16
View File
@@ -37,29 +37,24 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## تنصل
> [!WARNING]
> ⚠️ اتبع دائمًا خطة النسخ الاحتياطي [١-٢-٣](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) لصورك ومقاطع الفيديو الثمينة الخاصة بك
>
- ⚠️ هذا التطبيق قيد التطوير النشط للغاية
- ⚠️ توقع الأخطاء والتغييرات العاجلة
- ⚠️ **لا تستخدم التطبيق باعتباره الطريقة الوحيدة لتخزين الصور ومقاطع الفيديو الخاصة بك**
- ⚠️ اتبع دائمًا خطة النسخ الاحتياطي [١-٢-٣](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) لصورك ومقاطع الفيديو الثمينة الخاصة بك
> [!NOTE]
> يمكنك العثور على الوثائق الرئيسية، بما في ذلك أدلة التثبيت، على https://immich.app/
## روابط
## محتوى
- [الوثائق الرسمية](https://docs.immich.app)
- [خريطة الطريق](https://github.com/orgs/immich-app/projects/1)
- [تجريبي](#demo)
- [سمات](#features)
- [الوثائق الرسمية](https://docs.immich.app/)
- [مقدمة](https://docs.immich.app/overview/introduction)
- [تعليمات التحميل](https://docs.immich.app/install/requirements)
- [خريطة الطريق](https://immich.app/roadmap)
- [تجريبي](#تجريبي)
- [سمات](#سمات)
- [الترجمات](https://docs.immich.app/developer/translations)
- [قواعد المساهمة](https://docs.immich.app/overview/support-the-project)
## توثيق
يمكنك العثور على الوثائق الرئيسية، بما في ذلك أدلة التثبيت، هنا
https://immich.app
## تجريبي
يمكنك الوصول إلى العرض التوضيحي على الويب على
+10 -12
View File
@@ -37,26 +37,24 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## Avís legal
> [!WARNING]
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
>
- ⚠️ El projecte està en desenvolupament **molt actiu**.
- ⚠️ Espereu errors i canvis que poden trencar coses.
- ⚠️ **No utilitzeu l'aplicació com a única manera de guardar les vostres fotos i vídeos!**
> [!NOTE]
> Podeu trobar la documentació principal, incloent les guies d'instal·lació, a https://immich.app/.
## Contingut
- [Documentació oficial](https://docs.immich.app)
- [Mapa de ruta](https://github.com/orgs/immich-app/projects/1)
- [Demo](#demo)
- [Funcionalitats](#funcionalitats)
- [Documentació](https://docs.immich.app/)
- [Introducció](https://docs.immich.app/overview/introduction)
- [Instal·lació](https://docs.immich.app/install/requirements)
- [Mapa de ruta](https://immich.app/roadmap)
- [Demo](#demo)
- [Funcionalitats](#funcionalitats)
- [Traduccions](https://docs.immich.app/developer/translations)
- [Directrius de contribució](https://docs.immich.app/overview/support-the-project)
## Documentació
Podeu trobar la documentació principal, incloent les guies d'instal·lació, a https://immich.app/.
## Demo
Podeu accedir a la demostració web a https://demo.immich.app. Per a l'aplicació mòbil, podeu utilitzar `https://demo.immich.app` com a "URL de punt final del servidor".
+4 -2
View File
@@ -38,7 +38,9 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
> [!WARNING]
> ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
>
> [!NOTE]
> Die Hauptdokumentation, einschließlich der Installationsanleitungen, befinden sich unter https://immich.app/.
@@ -49,7 +51,7 @@
- [Offizielle Dokumentation](https://docs.immich.app)
- [Über Immich](https://docs.immich.app/overview/introduction)
- [Installation](https://docs.immich.app/install/requirements)
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
- [Roadmap](https://immich.app/roadmap)
- [Demo](#demo)
- [Funktionen](#funktionen)
- [Übersetzungen](https://docs.immich.app/developer/translations)
+10 -13
View File
@@ -37,27 +37,24 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## Advertencia
> [!WARNING]
> ⚠️ Siempre sigue el plan de backups [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) para tus fotos y videos.
>
- ⚠️ El proyecto está en **activo desarrollo**.
- ⚠️ Es probable que haya errores y cambios disruptivos.
- ⚠️ **¡No utilices la aplicación como única forma de almacenar tus fotos y videos!**
- ⚠️ Siempre sigue el plan de backups [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) para tus fotos y videos.
> [!NOTE]
> Puedes encontrar la documentación oficial, incluidas las guías de instalación, en <https://immich.app/>.
## Contenido
- [Documentación oficial](https://docs.immich.app)
- [Hoja de ruta](https://github.com/orgs/immich-app/projects/1)
- [Demo](#demo)
- [Funciones](#funciones)
- [Documentación](https://docs.immich.app/)
- [Introducción](https://docs.immich.app/overview/introduction)
- [Instalación](https://docs.immich.app/install/requirements)
- [Hoja de ruta](https://immich.app/roadmap)
- [Demo](#demo)
- [Funciones](#funciones)
- [Traducciones](https://docs.immich.app/developer/translations)
- [Directrices para contribuir](https://docs.immich.app/overview/support-the-project)
## Documentación
Puedes encontrar la documentación oficial, incluidas las guías de instalación, en <https://immich.app/>.
## Demo
Puedes acceder a la demostración web en <https://demo.immich.app>. Para la aplicación móvil, puedes usar `https://demo.immich.app` en la `URL del servidor`.
+10 -13
View File
@@ -37,27 +37,24 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## Clause de non-responsabilité
> [!WARNING]
> ⚠️ Ayez toujours un plan de sauvegarde en [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) pour vos précieuses photos et vidéos !
>
- ⚠️ Le projet est en **très fort** développement.
- ⚠️ Attendez-vous à rencontrer des bogues et des changements importants.
- ⚠️ **N'utilisez pas cette application comme seul support de sauvegarde de vos photos et vos vidéos.**
- ⚠️ Ayez toujours un plan de sauvegarde en [3-2-1](https://www.seagate.com/fr/fr/blog/what-is-a-3-2-1-backup-strategy/) pour vos précieuses photos et vidéos !
> [!NOTE]
> Vous pouvez trouver la documentation principale ainsi que les guides d'installation sur https://immich.app/.
## Sommaire
- [Documentation officielle](https://docs.immich.app)
- [Feuille de route](https://github.com/orgs/immich-app/projects/1)
- [Démo](#démo)
- [Fonctionnalités](#fonctionnalités)
- [Documentation](https://docs.immich.app/)
- [Introduction](https://docs.immich.app/overview/introduction)
- [Installation](https://docs.immich.app/install/requirements)
- [Feuille de route](https://immich.app/roadmap)
- [Démo](#démo)
- [Fonctionnalités](#fonctionnalités)
- [Traductions](https://docs.immich.app/developer/translations)
- [Contribution](https://docs.immich.app/overview/support-the-project)
## Documentation
Vous pouvez trouver la documentation principale ainsi que les guides d'installation sur https://immich.app/.
## Démo
Vous pouvez accéder à la démo en ligne sur https://demo.immich.app. Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app` dans le champ `URL du point d'accès au serveur`
+3 -6
View File
@@ -38,12 +38,9 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## Avvertenze
- ⚠️ Il progetto è in fase di sviluppo **molto attivo**.
- ⚠️ Possono esserci bug o cambiamenti radicali, che possono non essere retrocompatibili (breaking changes).
- ⚠️ **Non usare lapp come unico modo per archiviare le tue foto e i tuoi video.**
- ⚠️ Segui sempre la regola di backup [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) per proteggere i tuoi ricordi e le foto a cui tieni!
> [!WARNING]
> ⚠️ Segui sempre la regola di backup [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) per proteggere i tuoi ricordi e le foto a cui tieni!
>
> [!NOTE]
> La documentazione principale, comprese le guide allinstallazione, si trova su https://immich.app/.
+10 -13
View File
@@ -36,27 +36,24 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## 免責事項
> [!WARNING]
> ⚠️ 大切な写真やビデオは、常に [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) のバックアッププランに従ってください!
>
- ⚠️ このプロジェクトは **非常に活発に** 開発中です。
- ⚠️ バグの存在や変更が入ることも予想されます。
- ⚠️ **写真やビデオを保存する唯一の方法としてこのアプリを使用しないでください。**
- ⚠️ 大切な写真やビデオは、常に [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) のバックアッププランに従ってください!
> [!NOTE]
> インストールガイドを含む主なドキュメントは、https://immich.app/ です。
## コンテンツ
- [公式ドキュメント](https://docs.immich.app)
- [ロードマップ](https://github.com/orgs/immich-app/projects/1)
- [デモ](#デモ)
- [機能](#機能)
- [公式ドキュメント](https://docs.immich.app/)
- [紹介](https://docs.immich.app/overview/introduction)
- [インストール](https://docs.immich.app/install/requirements)
- [ロードマップ](https://immich.app/roadmap)
- [デモ](#デモ)
- [機能](#機能)
- [翻訳](https://docs.immich.app/developer/translations)
- [コントリビューションガイド](https://docs.immich.app/overview/support-the-project)
## ドキュメント
インストールガイドを含む主なドキュメントは、https://immich.app/ です。
## デモ
web デモは https://demo.immich.app からアクセスできます。モバイルアプリの場合、`Server Endpoint URL` には `https://demo.immich.app` を使用することができます
+4 -7
View File
@@ -39,12 +39,9 @@
</p>
## 주의 사항
- ⚠️ 이 프로젝트는 **매우 활발하게** 개발 중입니다.
- ⚠️ 버그와 잦은 변경 사항이 있을 것으로 예상됩니다.
- ⚠️ **사진과 동영상을 이 앱에만 단독으로 저장하지 마세요.**
- ⚠️ 중요한 사진과 동영상을 위해 항상 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 백업 계획을 따르세요!
> [!WARNING]
> ⚠️ 중요한 사진과 동영상을 위해 항상 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 백업 계획을 따르세요!
>
> [!NOTE]
> 설치하는 방법을 포함한 주요 문서는 https://immich.app/ 에서 확인할 수 있습니다.
@@ -57,7 +54,7 @@
- [로드맵](https://immich.app/roadmap)
- [데모](#데모)
- [기능](#기능)
- [번역](https://docs.immich.app/developer/tranlations)
- [번역](https://docs.immich.app/developer/translations)
- [기여](https://docs.immich.app/overview/support-the-project)
## 데모
+10 -13
View File
@@ -37,27 +37,24 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## Disclaimer
> [!WARNING]
> ⚠️ Volg altijd het [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan voor je kostbare foto's en video's!
>
- ⚠️ Het project wordt momenteel **zeer actief** ontwikkeld.
- ⚠️ Verwacht bugs en ingrijpende wijzigingen.
- ⚠️ **Gebruik de app niet als de enige manier om uw foto's en video's op te slaan.**
- ⚠️ Volg altijd het [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan voor je kostbare foto's en video's!
> [!NOTE]
> De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vinden op https://immich.app/.
## Inhoud
- [Officiële documentatie](https://docs.immich.app)
- [Toekomstplannen](https://github.com/orgs/immich-app/projects/1)
- [Demo](#demo)
- [Functies](#functies)
- [Officiële documentatie](https://docs.immich.app/)
- [Introductie](https://docs.immich.app/overview/introduction)
- [Installatie](https://docs.immich.app/install/requirements)
- [Toekomstplannen](https://immich.app/roadmap)
- [Demo](#demo)
- [Functies](#functies)
- [Vertalingen](https://docs.immich.app/developer/translations)
- [Richtlijnen voor bijdragen](https://docs.immich.app/overview/support-the-project)
## Documentatie
De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vinden op https://immich.app/.
## Demo
Je kunt de demo [hier](https://demo.immich.app/) bekijken. Voor de mobiele app kun je gebruik maken van `https://demo.immich.app` voor de `Server Endpoint URL`.
+3 -10
View File
@@ -40,16 +40,9 @@
</p>
## Avisos
- ⚠️ Este projeto está sob **desenvolvimento constante**.
- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a
compatibilidade com versões anteriores).
- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e
vídeos.**
- ⚠️ Sempre siga o plano
[3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup
para as suas mídias preciosas!
> [!WARNING]
> ⚠️ Sempre siga o plano [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup para as suas mídias preciosas!
>
> [!NOTE]
> Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/.
+4 -8
View File
@@ -39,13 +39,9 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## Предупреждение
- ⚠️ Этот проект находится **в очень активной** разработке.
- ⚠️ Ожидайте недоработки и глобальные изменения.
- ⚠️ **Не используйте это приложение как единственное хранилище своих фото и видео.**
- ⚠️ Всегда следуйте [плану резервного копирования «3-2-1»](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/ "Стратегии резервного копирования: Почему стратегия резервного копирования «3-2-1» — лучшая") для ваших драгоценных фотографий и видео!
> [!WARNING]
> ⚠️ Всегда следуйте [плану резервного копирования «3-2-1»](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших драгоценных фотографий и видео!
>
> [!NOTE]
> Инструкции по установке и документация по ссылке https://immich.app/
@@ -55,7 +51,7 @@
- [Официальная документация](https://docs.immich.app)
- [Введение](https://docs.immich.app/overview/introduction)
- [Установка](https://docs.immich.app/install/requirements)
- [План разработки](https://github.com/orgs/immich-app/projects/1)
- [План разработки](https://immich.app/roadmap)
- [Демо](#demo)
- [Возможности](#features)
- [Перевод](https://docs.immich.app/developer/translations)
+10 -13
View File
@@ -38,27 +38,24 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## Ansvarsfriskrivning
> [!WARNING]
> ⚠️ Tillämpa alltid [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/)-strategin för säkerhetskopiering av dina foton och videor!
>
- ⚠️ Projektet är under **mycket aktiv** utveckling.
- ⚠️ Förvänta dig buggar och brytande förändringar.
- ⚠️ **Använd inte appen som enda lagringssätt för dina foton och videor.**
- ⚠️ Tillämpa alltid [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/)-strategin för säkerhetskopiering av dina foton och videor!
> [!NOTE]
> Dokumentation och installationsguider hittas på https://immich.app/.
## Innehåll
- [Officiell Dokumentation](https://docs.immich.app)
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
- [Demo](#demo)
- [Funktioner](#features)
- [Officiell Dokumentation](https://docs.immich.app/)
- [Introduktion](https://docs.immich.app/overview/introduction)
- [Installation](https://docs.immich.app/install/requirements)
- [Roadmap](https://immich.app/roadmap)
- [Demo](#demo)
- [Funktioner](#funktioner)
- [Översättningar](https://docs.immich.app/developer/translations)
- [Riktlinjer för Bidrag](https://docs.immich.app/overview/support-the-project)
## Dokumentation
Dokumentation och installationsguider hittas på https://imiich.app/.
## Demo
Ett webb-demo finns att testa på https://demo.immich.app. Använd `https://demo.immich.app` i mobilappen som `Server Endpoint URL`
+11 -13
View File
@@ -37,26 +37,24 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## Feragatname
> [!WARNING]
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
>
- ⚠️ Proje **çok aktif** bir şekilde geliştirilmektedir.
- ⚠️ Hatalar ve uygulama yapısını bozan değişiklikler olabilir.
- ⚠️ **Uygulamayı, fotoğraflarınızı ve videolarınızı saklamanın tek yöntemi olarak kullanmayın!**
> [!NOTE]
> Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
## Content
## Bağlantılar
- [Resmi Belgeler](https://docs.immich.app)
- [Yol Haritası](https://github.com/orgs/immich-app/projects/1)
- [Demo](#demo)
- [Özellikler](#özellikler)
- [Resmi Belgeler](https://docs.immich.app/)
- [Giriş](https://docs.immich.app/overview/introduction)
- [Kurulum](https://docs.immich.app/install/requirements)
- [Yol Haritası](https://immich.app/roadmap)
- [Demo](#demo)
- [Özellikler](#özellikler)
- [Çeviriler](https://docs.immich.app/developer/translations)
- [Katkı Sağlama Rehberi](https://docs.immich.app/overview/support-the-project)
## Belgeler
Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
## Demo
Web demo adresi: https://demo.immich.app. Mobil uygulama için `Server Endpoint URL` olarak `https://demo.immich.app` adresini kullanabilirsiniz.
+3 -6
View File
@@ -39,12 +39,9 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## Застереження
- ⚠️ Цей проєкт перебуває **в дуже активній** розробці.
- ⚠️ Очікуйте безліч помилок і глобальних змін.
- ⚠️ **Не використовуйте цей застосунок як єдине сховище своїх фото та відео.**
- ⚠️ Завжди дотримуйтесь [плану резервного копіювання 3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших дорогоцінних фотографій та відео!
> [!WARNING]
> ⚠️ Завжди дотримуйтесь [плану резервного копіювання 3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) для ваших дорогоцінних фотографій та відео!
>
> [!NOTE]
> Основну документацію, зокрема посібники зі встановлення, можна знайти за адресою https://immich.app/.
+3 -6
View File
@@ -41,12 +41,9 @@
</p>
## Tuyên bố miễn trừ trách nhiệm
- ⚠️ Dự án đang được phát triển **rất tích cực**.
- ⚠️ Dự kiến sẽ có lỗi và thay đổi đột ngột.
- ⚠️ **Không sử dụng ứng dụng như là cách duy nhất để lưu trữ ảnh và video của bạn.**
- ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn!
> [!WARNING]
> ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn!
>
> [!NOTE]
> Bạn có thể tìm thấy tài liệu chính, bao gồm hướng dẫn cài đặt, tại https://immich.app/.
+3 -6
View File
@@ -43,12 +43,9 @@
</p>
## 免责声明
- ⚠️ 本项目正在 **非常活跃** 地开发中。
- ⚠️ 可能存在 bug 或者随时有重大变更。
- ⚠️ **不要把本软件作为您存储照片或视频的唯一方式。**
- ⚠️ 为了您宝贵的照片与视频,请始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案!
> [!WARNING]
> ⚠️ 为了您宝贵的照片与视频,请始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案!
>
> [!NOTE]
> 完整的项目文档以及安装教程请参见:<https://immich.app/>。
+7 -4
View File
@@ -171,8 +171,8 @@ export class JobRepository {
options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined };
if (job.options?.jobId) {
// need to use add() instead of addBulk() for jobId deduplication
if (job.options?.jobId || job.options?.deduplication) {
// need to use add() instead of addBulk() for jobId/deduplication to take effect
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
} else {
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
@@ -230,10 +230,13 @@ export class JobRepository {
return { priority: 1 };
}
case JobName.FacialRecognitionQueueAll: {
return { jobId: JobName.FacialRecognitionQueueAll };
return { deduplication: { id: JobName.FacialRecognitionQueueAll } };
}
case JobName.VersionCheck: {
return { jobId: JobName.VersionCheck };
return { deduplication: { id: JobName.VersionCheck } };
}
case JobName.DatabaseBackup: {
return { deduplication: { id: JobName.DatabaseBackup } };
}
default: {
return null;
@@ -132,7 +132,7 @@ export class MachineLearningRepository {
private async check(url: string) {
let healthy = false;
try {
const response = await fetch(new URL('/ping', url), {
const response = await fetch(new URL('ping', url), {
signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
});
if (response.ok) {
@@ -170,7 +170,7 @@ export class MachineLearningRepository {
...this.config.urls.filter((url) => !this.isHealthy(url)),
]) {
try {
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
const response = await fetch(new URL('predict', url), { method: 'POST', body: formData });
if (response.ok) {
this.setHealthy(url, true);
return response.json();
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
import { BinaryField, DefaultReadTaskOptions, ExifTool, ReadTaskOptions, Tags } from 'exiftool-vendored';
import geotz from 'geo-tz';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { mimeTypes } from 'src/utils/mime-types';
@@ -89,7 +89,7 @@ export class MetadataRepository {
geoTz: (lat, lon) => geotz.find(lat, lon)[0],
geolocation: true,
// Enable exiftool LFS to parse metadata for files larger than 2GB.
readArgs: ['-api', 'largefilesupport=1'],
readArgs: ['-api', 'largefilesupport=1', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'],
writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'],
taskTimeoutMillis: 2 * 60 * 1000,
});
@@ -107,8 +107,8 @@ export class MetadataRepository {
}
readTags(path: string): Promise<ImmichTags> {
const args = mimeTypes.isVideo(path) ? ['-ee'] : [];
return this.exiftool.read(path, { readArgs: args }).catch((error) => {
const options: ReadTaskOptions | undefined = mimeTypes.isVideo(path) ? { readArgs: ['-ee'] } : undefined;
return this.exiftool.read(path, options).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
return {};
}) as Promise<ImmichTags>;
+79 -12
View File
@@ -1,3 +1,54 @@
<script module lang="ts">
import { TUNABLES } from '$lib/utils/tunables';
// Chrome renders HDR images with normally invisible seam lines in a regular
// grid pattern. When the user pinch/scroll zooms, these seams become visible
// and grow more prominent at higher zoom levels.
//
// Adding `will-change: transform` prevents the seams by converting the
// element into a GPU texture that Chrome rasterizes once and reuses. But
// this texture is frozen at a fixed resolution and never re-renders from
// the source image, so zooming in magnifies the frozen texture rather than
// the source, which can appear blurry.
//
// To keep the texture sharp, we size this div closer to the image's native
// dimensions and apply a CSS counter-scale. Chrome renders these textures
// as a grid of small tiles backed by a shared GPU memory budget — if the
// texture is too large, tiles go missing and show up as transparent gaps.
// We cap the texture size based on the device's GPU capability.
//
// This workaround is only needed in Chromium-based browsers. Firefox and
// Safari use different rasterization pipelines and don't exhibit this bug.
// See https://issues.chromium.org/issues/40084005
const isChromium = 'chrome' in globalThis;
function getMaxRasterPixels() {
const override = TUNABLES.IMAGE_RASTER.MAX_PIXELS;
if (override > 0) {
return override;
}
if (override < 0 || !isChromium) {
return 0;
}
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) ?? 0;
if (maxTextureSize >= 16_384) {
return 16_000_000;
}
if (maxTextureSize >= 8192) {
return 10_000_000;
}
return 4_000_000;
} catch {
return 4_000_000;
}
}
const maxRasterPixels = getMaxRasterPixels();
</script>
<script lang="ts">
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
import BrokenAsset from '$lib/components/assets/BrokenAsset.svelte';
@@ -98,14 +149,27 @@
return { width: 1, height: 1 };
});
const { width, height, insetInlineStart, top } = $derived.by(() => {
const { insetInlineStart, top, rasterWidth, rasterHeight, rasterScale } = $derived.by(() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
if (maxRasterPixels === 0) {
return {
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
rasterWidth: width + 'px',
rasterHeight: height + 'px',
rasterScale: 1,
};
}
const nativeRatio = imageDimensions.width / width;
const budgetRatio = Math.sqrt(maxRasterPixels / Math.max(width * height, 1));
const rasterRatio = Math.max(1, Math.min(nativeRatio, budgetRatio));
return {
width: width + 'px',
height: height + 'px',
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
rasterWidth: width * rasterRatio + 'px',
rasterHeight: height * rasterRatio + 'px',
rasterScale: 1 / rasterRatio,
};
});
@@ -152,11 +216,14 @@
{@render backdrop?.()}
<div
class="pointer-events-none absolute inset-0"
class="pointer-events-none absolute"
style:inset-inline-start={insetInlineStart}
style:top
style:width
style:height
style:width={rasterWidth}
style:height={rasterHeight}
style:transform="scale({rasterScale})"
style:transform-origin="0 0"
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
>
{#if show.alphaBackground}
<AlphaBackground />
@@ -174,8 +241,8 @@
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
{width}
{height}
width={rasterWidth}
height={rasterHeight}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
@@ -192,8 +259,8 @@
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
width={rasterWidth}
height={rasterHeight}
{overlays}
quality="preview"
src={status.urls.preview}
@@ -205,8 +272,8 @@
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
width={rasterWidth}
height={rasterHeight}
{overlays}
quality="original"
src={status.urls.original}
@@ -212,16 +212,10 @@
};
}
try {
return {
fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined,
fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined,
};
} catch {
$mapSettings.dateAfter = '';
$mapSettings.dateBefore = '';
return {};
}
return {
fileCreatedAfter: dateAfter?.toUTC().toISO(),
fileCreatedBefore: dateBefore?.toUTC().toISO(),
};
}
async function loadMapMarkers() {
@@ -237,7 +231,7 @@
{
isArchived: includeArchived || undefined,
isFavorite: onlyFavorites || undefined,
fileCreatedAfter: fileCreatedAfter || undefined,
fileCreatedAfter,
fileCreatedBefore,
withPartners: withPartners || undefined,
withSharedAlbums: withSharedAlbums || undefined,
+27 -38
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import type { MapSettings } from '$lib/stores/preferences.store';
import { Button, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
import { Button, DatePicker, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
import { Duration } from 'luxon';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -41,29 +40,21 @@
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
<label class="shrink-0 text-sm immich-form-label" for="date-after">{$t('date_after')}</label>
<DateInput
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
</div>
<div class="flex items-center justify-between gap-8">
<label class="shrink-0 text-sm immich-form-label" for="date-before">{$t('date_before')}</label>
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<Field label={$t('date_after')}>
<DatePicker bind:value={settings.dateAfter} maxDate={settings.dateBefore} />
</Field>
<Field label={$t('date_before')}>
<DatePicker bind:value={settings.dateBefore} />
</Field>
<div class="flex justify-center">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
settings.dateAfter = undefined;
settings.dateBefore = undefined;
}}
>
{$t('remove_custom_date_range')}
@@ -71,7 +62,7 @@
</div>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-2">
<Field label={$t('date_range')}>
<Select
bind:value={settings.relativeDate}
@@ -82,40 +73,38 @@
},
{
label: $t('past_durations.hours', { values: { hours: 24 } }),
value: Duration.fromObject({ hours: 24 }).toISO() || '',
value: Duration.fromObject({ hours: 24 }).toISO(),
},
{
label: $t('past_durations.days', { values: { days: 7 } }),
value: Duration.fromObject({ days: 7 }).toISO() || '',
value: Duration.fromObject({ days: 7 }).toISO(),
},
{
label: $t('past_durations.days', { values: { days: 30 } }),
value: Duration.fromObject({ days: 30 }).toISO() || '',
value: Duration.fromObject({ days: 30 }).toISO(),
},
{
label: $t('past_durations.years', { values: { years: 1 } }),
value: Duration.fromObject({ years: 1 }).toISO() || '',
value: Duration.fromObject({ years: 1 }).toISO(),
},
{
label: $t('past_durations.years', { values: { years: 3 } }),
value: Duration.fromObject({ years: 3 }).toISO() || '',
value: Duration.fromObject({ years: 3 }).toISO(),
},
]}
/>
</Field>
<div class="text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
{/if}
</Stack>
@@ -1,9 +1,9 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import { handleUpdatePersonBirthDate } from '$lib/services/person.service';
import { type PersonResponseDto } from '@immich/sdk';
import { Button, FormModal, Text } from '@immich/ui';
import { Button, DatePicker, Field, FormModal, HelperText } from '@immich/ui';
import { mdiCake } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
@@ -12,32 +12,25 @@
};
let { person, onClose }: Props = $props();
let birthDate = $derived(person.birthDate ?? '');
let birthDate = $derived(person.birthDate ? DateTime.fromISO(person.birthDate) : undefined);
const onSubmit = async () => {
const success = await handleUpdatePersonBirthDate(person, birthDate);
const success = await handleUpdatePersonBirthDate(person, birthDate?.toISODate() ?? '');
if (success) {
onClose();
}
};
const todayFormatted = new Date().toISOString().split('T')[0];
</script>
<FormModal title={$t('set_date_of_birth')} size="small" icon={mdiCake} {onClose} {onSubmit}>
<Text size="small">{$t('birthdate_set_description')}</Text>
<div class="my-4 flex flex-col gap-2">
<DateInput
class="immich-form-input"
id="birthDate"
name="birthDate"
type="date"
bind:value={birthDate}
max={todayFormatted}
/>
<div class="my-2 flex flex-col gap-2">
<Field label={$t('date_of_birth')}>
<DatePicker bind:value={birthDate} maxDate={DateTime.now()} />
<HelperText>{$t('birthdate_set_description')}</HelperText>
</Field>
{#if person.birthDate}
<div class="flex justify-end">
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = '')}>
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = undefined)}>
{$t('clear')}
</Button>
</div>
+3 -4
View File
@@ -1,3 +1,4 @@
import type { DateTime } from 'luxon';
import { persisted } from 'svelte-persisted-store';
import { browser } from '$app/environment';
import { defaultLang } from '$lib/constants';
@@ -26,8 +27,8 @@ export interface MapSettings {
withPartners: boolean;
withSharedAlbums: boolean;
relativeDate: string;
dateAfter: string;
dateBefore: string;
dateAfter?: DateTime<true>;
dateBefore?: DateTime<true>;
}
const defaultMapSettings = {
@@ -37,8 +38,6 @@ const defaultMapSettings = {
withPartners: false,
withSharedAlbums: false,
relativeDate: '',
dateAfter: '',
dateBefore: '',
};
const persistedObject = <T>(key: string, defaults: T) =>
+3
View File
@@ -31,4 +31,7 @@ export const TUNABLES = {
IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
},
IMAGE_RASTER: {
MAX_PIXELS: getNumber(storage.getItem('IMAGE_RASTER.MAX_PIXELS'), 0),
},
};
+33 -4
View File
@@ -4,15 +4,18 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Icon } from '@immich/ui';
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAltText } from '$lib/utils/thumbnail-util';
import Portal from '$lib/elements/Portal.svelte';
interface Props {
data: PageData;
@@ -40,6 +43,15 @@
}
}
};
const onViewAsset = async (id: string) => {
const asset = await getAssetInfo({ ...authManager.params, id });
assetViewerManager.setAsset(asset);
};
const assetCursor = $derived({
current: assetViewerManager.asset!,
});
</script>
<OnEvents {onPersonThumbnailReady} />
@@ -122,15 +134,20 @@
draggable="false">{$t('view_all')}</a
>
</div>
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
<div class="flex h-24 max-w-fit flex-wrap gap-x-1 overflow-hidden md:h-42">
{#each recents as item (item.data.id)}
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
<button
type="button"
class="relative h-full flex-auto"
onclick={() => onViewAsset(item.data.id)}
draggable="false"
>
<img
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={$getAltText(toTimelineAsset(item.data))}
class="size-full min-w-max rounded-xl object-cover"
/>
</a>
</button>
{/each}
</div>
</div>
@@ -140,3 +157,15 @@
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
{/if}
</UserPageLayout>
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
cursor={assetCursor}
showNavigation={false}
onClose={() => assetViewerManager.showAssetViewer(false)}
/>
</Portal>
{/await}
{/if}
@@ -8,9 +8,8 @@
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { handleError } from '$lib/utils/handle-error';
import { AssetVisibility, updatePartner } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, Field, Switch, Text } from '@immich/ui';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
import { mdiArrowLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -21,8 +20,6 @@
let { data }: Props = $props();
let inTimeline = $derived(data.inTimeline);
const options = $derived({
userId: data.partner.id,
visibility: AssetVisibility.Timeline,
@@ -35,15 +32,6 @@
return;
}
};
const handleToggleInTimeline = async (status: boolean) => {
try {
await updatePartner({ id: data.partner.id, partnerUpdateDto: { inTimeline: status } });
inTimeline = status;
} catch (error) {
handleError(error, $t('errors.unable_to_update_timeline_display_status'));
}
};
</script>
<main class="relative h-dvh overflow-hidden px-2 pt-(--navbar-height) max-md:pt-(--navbar-height-md) md:px-6">
@@ -65,13 +53,5 @@
{$t('partner_list_user_photos', { values: { user: data.partner.name } })}
</p>
{/snippet}
{#snippet trailing()}
<Field class="flex w-full place-content-center place-items-center gap-2">
<Text size="small">
{$t('show_in_timeline')}
</Text>
<Switch bind:checked={inTimeline} onCheckedChange={handleToggleInTimeline} />
</Field>
{/snippet}
</ControlAppBar>
{/if}
@@ -1,4 +1,4 @@
import { getPartners, getUser, PartnerDirection } from '@immich/sdk';
import { getUser } from '@immich/sdk';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
@@ -6,16 +6,11 @@ import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const [partner, sharedWith] = await Promise.all([
getUser({ id: params.userId }),
getPartners({ direction: PartnerDirection.SharedWith }),
]);
const inTimeline = sharedWith.find((p) => p.id === params.userId)?.inTimeline ?? false;
const partner = await getUser({ id: params.userId });
const $t = await getFormatter();
return {
partner,
inTimeline,
meta: {
title: $t('partner'),
},