mirror of
https://github.com/immich-app/immich.git
synced 2026-05-16 20:42:12 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c9263b69f |
@@ -116,6 +116,7 @@ jobs:
|
||||
~/.gradle/wrapper
|
||||
~/.android/sdk
|
||||
mobile/android/.gradle
|
||||
mobile/.dart_tool
|
||||
key: build-mobile-gradle-${{ runner.os }}-main
|
||||
|
||||
- name: Setup Android SDK
|
||||
@@ -188,6 +189,7 @@ jobs:
|
||||
~/.gradle/wrapper
|
||||
~/.android/sdk
|
||||
mobile/android/.gradle
|
||||
mobile/.dart_tool
|
||||
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
|
||||
|
||||
build-sign-ios:
|
||||
|
||||
@@ -565,7 +565,7 @@ jobs:
|
||||
run: mise //mobile:codegen:translation
|
||||
|
||||
- name: Run tests
|
||||
run: mise //mobile:test
|
||||
run: mise //mobile:test -j 1
|
||||
|
||||
ml-unit-tests:
|
||||
name: Unit Test ML
|
||||
|
||||
+30
-30
@@ -2,37 +2,37 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.7"
|
||||
constraints = "4.52.7"
|
||||
version = "4.52.5"
|
||||
constraints = "4.52.5"
|
||||
hashes = [
|
||||
"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",
|
||||
"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",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
||||
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
|
||||
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
|
||||
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
|
||||
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
|
||||
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
|
||||
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.7"
|
||||
version = "4.52.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-30
@@ -2,37 +2,37 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.7"
|
||||
constraints = "4.52.7"
|
||||
version = "4.52.5"
|
||||
constraints = "4.52.5"
|
||||
hashes = [
|
||||
"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",
|
||||
"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",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
||||
"zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54",
|
||||
"zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852",
|
||||
"zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0",
|
||||
"zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5",
|
||||
"zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036",
|
||||
"zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.7"
|
||||
version = "4.52.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,8 @@ 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
|
||||
|
||||
@@ -105,22 +102,6 @@ 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
|
||||
|
||||
```
|
||||
@@ -145,12 +126,3 @@ 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
|
||||
```
|
||||
|
||||
@@ -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 to allow communication between the Immich containers.
|
||||
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.
|
||||
|
||||
Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**"
|
||||
|
||||
@@ -74,7 +74,6 @@ 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
|
||||
@@ -111,7 +110,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack,
|
||||
|
||||
## Step 5. Update firewall rule
|
||||
|
||||
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.
|
||||
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.
|
||||

|
||||
@@ -124,67 +123,4 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi
|
||||
|
||||

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

|
||||
|
||||
## 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, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/database-backups', () => {
|
||||
let cookie: string | undefined;
|
||||
@@ -13,9 +13,6 @@ describe('/admin/database-backups', () => {
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
|
||||
@@ -568,8 +568,6 @@ 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,
|
||||
|
||||
+6
-3
@@ -693,6 +693,7 @@
|
||||
"backup_settings_subtitle": "Manage upload settings",
|
||||
"backup_upload_details_page_more_details": "Tap for more details",
|
||||
"backward": "Backward",
|
||||
"battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
|
||||
"biometric_auth_enabled": "Biometric authentication enabled",
|
||||
"biometric_locked_out": "You are locked out of biometric authentication",
|
||||
"biometric_no_options": "No biometric options available",
|
||||
@@ -885,13 +886,15 @@
|
||||
"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_of_birth": "Date of birth",
|
||||
"date_format": "E, LLL d, y • h:mm a",
|
||||
"date_of_birth_saved": "Date of birth saved successfully",
|
||||
"date_range": "Date range",
|
||||
"day": "Day",
|
||||
@@ -1401,7 +1404,6 @@
|
||||
"link_to_oauth": "Link to OAuth",
|
||||
"linked_oauth_account": "Linked OAuth account",
|
||||
"list": "List",
|
||||
"live": "Live",
|
||||
"loading": "Loading",
|
||||
"loading_search_results_failed": "Loading search results failed",
|
||||
"local": "Local",
|
||||
@@ -1581,8 +1583,8 @@
|
||||
"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",
|
||||
"move_down": "Move down",
|
||||
"move_off_locked_folder": "Move out of locked folder",
|
||||
@@ -1666,6 +1668,7 @@
|
||||
"not_selected": "Not selected",
|
||||
"notes": "Notes",
|
||||
"nothing_here_yet": "Nothing here yet",
|
||||
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
|
||||
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
|
||||
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
|
||||
"notification_permission_list_tile_enable_button": "Enable Notifications",
|
||||
|
||||
@@ -17,6 +17,8 @@ import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
import app.alextran.immich.images.RemoteImagesImpl
|
||||
import app.alextran.immich.permission.PermissionApi
|
||||
import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
@@ -50,6 +52,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
PermissionApi.setUp(messenger, PermissionApiImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||
|
||||
@@ -23,8 +23,6 @@ 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 {
|
||||
@@ -230,6 +228,7 @@ 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) {
|
||||
@@ -243,16 +242,15 @@ 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
|
||||
// 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())
|
||||
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())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error = e
|
||||
return request.cancel()
|
||||
@@ -265,14 +263,14 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
byteBuffer: ByteBuffer
|
||||
) {
|
||||
try {
|
||||
// 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()
|
||||
val buf = if (wrapped == null) {
|
||||
buffer!!.run {
|
||||
advance(byteBuffer.position())
|
||||
ensureHeadroom()
|
||||
wrapRemaining()
|
||||
}
|
||||
} else {
|
||||
wrapped
|
||||
}
|
||||
request.read(buf)
|
||||
} catch (e: Exception) {
|
||||
@@ -282,6 +280,7 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
}
|
||||
|
||||
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
|
||||
wrapped?.let { buffer!!.advance(it.position()) }
|
||||
onSuccess(buffer!!)
|
||||
onComplete()
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object PermissionApiPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : RuntimeException()
|
||||
|
||||
enum class PermissionStatus(val raw: Int) {
|
||||
GRANTED(0),
|
||||
DENIED(1),
|
||||
PERMANENTLY_DENIED(2);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): PermissionStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
PermissionStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is PermissionStatus -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface PermissionApi {
|
||||
fun isIgnoringBatteryOptimizations(): PermissionStatus
|
||||
|
||||
companion object {
|
||||
/** The codec used by PermissionApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
PermissionApiPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.isIgnoringBatteryOptimizations())
|
||||
} catch (exception: Throwable) {
|
||||
PermissionApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
|
||||
class PermissionApiImpl(context: Context) : PermissionApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private val powerManager =
|
||||
ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
|
||||
override fun isIgnoringBatteryOptimizations(): PermissionStatus {
|
||||
if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
|
||||
return PermissionStatus.GRANTED
|
||||
}
|
||||
return PermissionStatus.DENIED
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
|
||||
var domainAlbum = PlatformAlbum(
|
||||
id: album.localIdentifier,
|
||||
name: album.localizedTitle ?? album.localIdentifier,
|
||||
name: album.localizedTitle!,
|
||||
updatedAt: nil,
|
||||
isCloud: isCloud,
|
||||
assetCount: Int64(assets.count)
|
||||
|
||||
@@ -18,7 +18,3 @@ 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,7 +11,6 @@ class RemoteAsset extends BaseAsset {
|
||||
final String ownerId;
|
||||
final String? stackId;
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? deletedAt;
|
||||
|
||||
const RemoteAsset({
|
||||
required this.id,
|
||||
@@ -32,7 +31,6 @@ class RemoteAsset extends BaseAsset {
|
||||
super.livePhotoVideoId,
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
@@ -50,8 +48,6 @@ class RemoteAsset extends BaseAsset {
|
||||
@override
|
||||
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
|
||||
|
||||
bool get isTrashed => deletedAt != null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
@@ -90,8 +86,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility &&
|
||||
stackId == other.stackId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt;
|
||||
uploadedAt == other.uploadedAt;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -103,8 +98,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash.hashCode ^
|
||||
visibility.hashCode ^
|
||||
stackId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode;
|
||||
uploadedAt.hashCode;
|
||||
|
||||
RemoteAsset copyWith({
|
||||
String? id,
|
||||
@@ -125,7 +119,6 @@ class RemoteAsset extends BaseAsset {
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -146,7 +139,6 @@ class RemoteAsset extends BaseAsset {
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -164,7 +156,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
required super.createdAt,
|
||||
required super.updatedAt,
|
||||
super.uploadedAt,
|
||||
super.deletedAt,
|
||||
super.width,
|
||||
super.height,
|
||||
super.durationMs,
|
||||
@@ -202,7 +193,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? uploadedAt,
|
||||
DateTime? deletedAt,
|
||||
int? width,
|
||||
int? height,
|
||||
int? durationMs,
|
||||
@@ -224,7 +214,6 @@ 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,
|
||||
|
||||
@@ -1,46 +1,26 @@
|
||||
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
|
||||
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;
|
||||
final CleanupConfig cleanup;
|
||||
final MapConfig map;
|
||||
final TimelineConfig timeline;
|
||||
final ImageConfig image;
|
||||
final ViewerConfig viewer;
|
||||
final SlideshowConfig slideshow;
|
||||
|
||||
const AppConfig({
|
||||
this.theme = const .new(),
|
||||
this.cleanup = const .new(),
|
||||
this.map = const .new(),
|
||||
this.timeline = const .new(),
|
||||
this.image = const .new(),
|
||||
this.viewer = const .new(),
|
||||
this.slideshow = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
ThemeConfig? theme,
|
||||
CleanupConfig? cleanup,
|
||||
MapConfig? map,
|
||||
TimelineConfig? timeline,
|
||||
ImageConfig? image,
|
||||
ViewerConfig? viewer,
|
||||
SlideshowConfig? slideshow,
|
||||
}) => .new(
|
||||
AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map, TimelineConfig? timeline}) => .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
|
||||
@@ -50,15 +30,11 @@ class AppConfig {
|
||||
other.theme == theme &&
|
||||
other.cleanup == cleanup &&
|
||||
other.map == map &&
|
||||
other.timeline == timeline &&
|
||||
other.image == image &&
|
||||
other.viewer == viewer &&
|
||||
other.slideshow == slideshow);
|
||||
other.timeline == timeline);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow);
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow)';
|
||||
String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline)';
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
class ImageConfig {
|
||||
final bool preferRemote;
|
||||
final bool loadOriginal;
|
||||
|
||||
const ImageConfig({this.preferRemote = false, this.loadOriginal = false});
|
||||
|
||||
ImageConfig copyWith({bool? preferRemote, bool? loadOriginal}) =>
|
||||
ImageConfig(preferRemote: preferRemote ?? this.preferRemote, loadOriginal: loadOriginal ?? this.loadOriginal);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is ImageConfig && other.preferRemote == preferRemote && other.loadOriginal == loadOriginal);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(preferRemote, loadOriginal);
|
||||
|
||||
@override
|
||||
String toString() => 'ImageConfig(preferRemoteImage: $preferRemote, loadOriginal: $loadOriginal)';
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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)';
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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)';
|
||||
}
|
||||
@@ -24,16 +24,6 @@ enum MetadataKey<T extends Object> {
|
||||
themeDynamic<bool>(.appConfig, 'theme.dynamic', false),
|
||||
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true),
|
||||
|
||||
// Image
|
||||
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>(
|
||||
@@ -64,19 +54,7 @@ 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),
|
||||
|
||||
// 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),
|
||||
);
|
||||
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false);
|
||||
|
||||
final MetadataDomain domain;
|
||||
final String name;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
|
||||
enum Setting<T> {
|
||||
loadOriginal<bool>(StoreKey.loadOriginal, false),
|
||||
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, false),
|
||||
autoPlayVideo<bool>(StoreKey.autoPlayVideo, true),
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
|
||||
enableBackup<bool>(StoreKey.enableBackup, false);
|
||||
|
||||
|
||||
@@ -4,15 +4,29 @@ 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),
|
||||
thumbnailCacheSize<int>._(110),
|
||||
imageCacheSize<int>._(111),
|
||||
albumThumbnailCacheSize<int>._(112),
|
||||
selectedAlbumSortOrder<int>._(113),
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
preferRemoteImage<bool>._(116),
|
||||
selfSignedCert<bool>._(120),
|
||||
selectedAlbumSortReverse<bool>._(123),
|
||||
enableHapticFeedback<bool>._(126),
|
||||
customHeaders<String>._(127),
|
||||
@@ -28,8 +42,12 @@ 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
|
||||
@@ -39,12 +57,6 @@ 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),
|
||||
legacyDynamicTheme<bool>._(129),
|
||||
legacyColorfulInterface<bool>._(130),
|
||||
|
||||
@@ -74,6 +74,5 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
localId: localId,
|
||||
stackId: stackId,
|
||||
isEdited: isEdited,
|
||||
deletedAt: deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,20 +132,6 @@ extension<T extends Object> on MetadataDomain<T> {
|
||||
groupAssetsBy: repo._read(.timelineGroupAssetsBy),
|
||||
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));
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
@@ -15,11 +16,16 @@ import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.w
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/providers/permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -162,11 +168,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
),
|
||||
),
|
||||
},
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||
label: Text("view_details".t(context: context)),
|
||||
),
|
||||
const _BackupFooter(),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -177,6 +179,137 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupFooter extends ConsumerStatefulWidget {
|
||||
const _BackupFooter();
|
||||
|
||||
@override
|
||||
ConsumerState<_BackupFooter> createState() => _BackupFooterState();
|
||||
}
|
||||
|
||||
class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
|
||||
unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
|
||||
unawaited(ref.read(batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
|
||||
}
|
||||
}
|
||||
|
||||
void showPermissionsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
content: Text(context.t.notification_permission_dialog_content),
|
||||
actions: [
|
||||
ImmichTextButton(
|
||||
labelText: context.t.cancel,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () => ContextHelper(ctx).pop(),
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: context.t.settings,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () {
|
||||
ContextHelper(context).pop();
|
||||
openAppSettings();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showBatteryOptimizationInfo() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.backup_controller_page_background_battery_info_title),
|
||||
content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
|
||||
actions: [
|
||||
ImmichTextButton(
|
||||
labelText: context.t.backup_controller_page_background_battery_info_link,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
|
||||
),
|
||||
ImmichTextButton(
|
||||
labelText: context.t.backup_controller_page_background_battery_info_ok,
|
||||
variant: .ghost,
|
||||
expanded: false,
|
||||
onPressed: () => ContextHelper(ctx).pop(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBackupEnabled = ref.watch(_backupStatusProvider).valueOrNull ?? false;
|
||||
final notificationStatus = ref.watch(notificationPermissionProvider);
|
||||
final batteryOptimizationStatus = ref.watch(batteryOptimizationProvider).valueOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
|
||||
if (notificationStatus != PermissionStatus.granted)
|
||||
TextButton.icon(
|
||||
iconAlignment: .end,
|
||||
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
|
||||
label: Text(
|
||||
context.t.notification_backup_reliability,
|
||||
textAlign: TextAlign.left,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
|
||||
if (p == PermissionStatus.permanentlyDenied) {
|
||||
showPermissionsDialog();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (notificationStatus != PermissionStatus.granted && batteryOptimizationStatus != PermissionStatus.granted)
|
||||
const Divider(indent: 32, endIndent: 32),
|
||||
if (batteryOptimizationStatus != PermissionStatus.granted)
|
||||
TextButton.icon(
|
||||
iconAlignment: .end,
|
||||
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
|
||||
label: Text(
|
||||
context.t.battery_optimization_backup_reliability,
|
||||
textAlign: TextAlign.left,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onPressed: showBatteryOptimizationInfo,
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||
label: Text(context.t.view_details),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupAlbumSelectionCard extends ConsumerWidget {
|
||||
const _BackupAlbumSelectionCard();
|
||||
|
||||
@@ -527,3 +660,7 @@ class _PreparingStatusState extends ConsumerState {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _backupStatusProvider = StreamProvider.autoDispose<bool?>((ref) async* {
|
||||
yield* ref.watch(storeServiceProvider).watch(StoreKey.enableBackup);
|
||||
});
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: unused_import, unused_shown_name
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
}
|
||||
return replyList.firstOrNull;
|
||||
}
|
||||
|
||||
enum PermissionStatus { granted, denied, permanentlyDenied }
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is PermissionStatus) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : PermissionStatus.values[value];
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionApi {
|
||||
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<PermissionStatus> isIgnoringBatteryOptimizations() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as PermissionStatus;
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
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,11 +35,10 @@ 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),
|
||||
@@ -47,21 +46,17 @@ class BaseActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (menuItem) {
|
||||
final iconColor = this.iconColor;
|
||||
final theme = context.themeData;
|
||||
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return MenuItemButton(
|
||||
style: MenuItemButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
leadingIcon: Icon(iconData, color: iconColor, size: 20),
|
||||
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
|
||||
leadingIcon: Icon(iconData, color: effectiveIconColor),
|
||||
onPressed: onPressed,
|
||||
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
|
||||
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
|
||||
);
|
||||
}
|
||||
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: MaterialButton(
|
||||
|
||||
+2
-9
@@ -18,15 +18,8 @@ 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,
|
||||
this.useShortLabel = false,
|
||||
});
|
||||
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
@@ -71,7 +64,7 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
||||
return BaseActionButton(
|
||||
maxWidth: 110.0,
|
||||
iconData: Icons.delete_forever,
|
||||
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
|
||||
label: "delete_permanently".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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,10 +17,11 @@ 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';
|
||||
|
||||
@@ -230,7 +231,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final tapToNavigate = ref.read(metadataProvider).appConfig.viewer.tapToNavigate;
|
||||
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
|
||||
if (!tapToNavigate) {
|
||||
_viewer.toggleControls();
|
||||
return;
|
||||
|
||||
@@ -2,19 +2,15 @@ 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';
|
||||
@@ -37,31 +33,23 @@ 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>[
|
||||
if (isInTrash && isOwner && asset.hasRemote)
|
||||
const RestoreActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
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 (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) ...[
|
||||
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),
|
||||
asset.isLocalOnly
|
||||
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
|
||||
class MotionPhotoPlayButton extends ConsumerWidget {
|
||||
const MotionPhotoPlayButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
|
||||
final isPlaying = ref.watch(isPlayingMotionVideoProvider);
|
||||
final showControls = ref.watch(assetViewerProvider.select((state) => state.showingControls));
|
||||
final isShowingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
|
||||
if (asset == null || !asset.isMotionPhoto || isShowingDetails) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: !showControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Center(
|
||||
child: _MotionButton(
|
||||
isPlaying: isPlaying,
|
||||
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MotionButton extends StatelessWidget {
|
||||
final bool isPlaying;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _MotionButton({required this.isPlaying, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.grey[900]!.withValues(alpha: 0.4),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
CurrentPlatform.isAndroid ? 'motion'.t(context: context) : 'live'.t(context: context),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,21 @@ 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/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.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';
|
||||
|
||||
@@ -128,7 +132,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
final remoteId = (videoAsset as RemoteAsset).id;
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final isOriginalVideo = ref.read(metadataProvider).appConfig.viewer.loadOriginalVideo;
|
||||
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||
@@ -161,7 +165,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
return;
|
||||
}
|
||||
|
||||
final autoPlayVideo = ref.read(metadataProvider).appConfig.viewer.autoPlayVideo;
|
||||
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
|
||||
if (autoPlayVideo || widget.asset.isMotionPhoto) {
|
||||
await _notifier.play();
|
||||
}
|
||||
@@ -212,7 +216,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
}
|
||||
|
||||
await _notifier.load(source);
|
||||
final loopVideo = ref.read(metadataProvider).appConfig.viewer.loopVideo;
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
||||
await _notifier.setVolume(1);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
@@ -11,13 +10,11 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
|
||||
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
const ViewerTopAppBar({super.key});
|
||||
@@ -98,17 +95,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
),
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: SizedBox(
|
||||
height: preferredSize.height,
|
||||
child: SizedBox.square(
|
||||
child: Theme(
|
||||
data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)),
|
||||
child: NavigationToolbar(
|
||||
centerMiddle: true,
|
||||
leading: const _AppBarBackButton(),
|
||||
middle: showingDetails ? null : _AssetInfoTitle(asset: asset),
|
||||
trailing: !showingDetails && !isReadonlyModeEnabled
|
||||
? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
const _AppBarBackButton(),
|
||||
const Spacer(),
|
||||
if (!showingDetails && !isReadonlyModeEnabled)
|
||||
if (isInLockedView) ...lockedViewActions else ...actions,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -143,32 +139,3 @@ class _AppBarBackButton extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetInfoTitle extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const _AssetInfoTitle({required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
DateTime dateTime = asset.createdAt.toLocal();
|
||||
final currentYear = DateTime.now().year;
|
||||
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
|
||||
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
(dateTime, _) = applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo.timeZone);
|
||||
}
|
||||
|
||||
final isCurrentYear = dateTime.year == currentYear;
|
||||
final dateFormatted = isCurrentYear ? DateFormat.MMMd().format(dateTime) : DateFormat.yMMMd().format(dateTime);
|
||||
final timeFormatted = DateFormat.jm().format(dateTime);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(dateFormatted, style: context.textTheme.labelLarge?.copyWith(color: Colors.white)),
|
||||
Text(timeFormatted, style: context.textTheme.labelMedium?.copyWith(color: Colors.white70)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import 'dart:ui' as ui;
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/widgets.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/services/setting.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
@@ -188,6 +189,4 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
|
||||
}
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
asset.hasLocal &&
|
||||
(!asset.hasRemote || !MetadataRepository.instance.appConfig.image.preferRemote) &&
|
||||
!asset.isEdited;
|
||||
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.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/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
@@ -104,7 +105,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
return;
|
||||
}
|
||||
|
||||
final loadOriginal = MetadataRepository.instance.appConfig.image.loadOriginal;
|
||||
final loadOriginal = Store.get(StoreKey.loadOriginal, false);
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
var request = this.request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.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/services/setting.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
@@ -122,7 +123,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
edited: key.edited,
|
||||
),
|
||||
);
|
||||
final loadOriginal = assetType == AssetType.image && MetadataRepository.instance.appConfig.image.loadOriginal;
|
||||
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
|
||||
|
||||
if (!loadOriginal) {
|
||||
|
||||
@@ -120,9 +120,6 @@ 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);
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
@@ -11,11 +11,12 @@ 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';
|
||||
|
||||
@@ -143,6 +144,7 @@ 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) {
|
||||
|
||||
@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/network_api.g.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||
|
||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||
@@ -18,6 +19,8 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
||||
|
||||
final localImageApi = LocalImageApi();
|
||||
|
||||
final remoteImageApi = RemoteImageApi();
|
||||
|
||||
+32
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart' as pm;
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
|
||||
@@ -39,3 +42,32 @@ class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
|
||||
final notificationPermissionProvider = StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>(
|
||||
(ref) => NotificationPermissionNotifier(),
|
||||
);
|
||||
|
||||
final batteryOptimizationProvider = AsyncNotifierProvider<BatteryOptimizationNotifier, PermissionStatus>(
|
||||
BatteryOptimizationNotifier.new,
|
||||
);
|
||||
|
||||
class BatteryOptimizationNotifier extends AsyncNotifier<PermissionStatus> {
|
||||
Future<PermissionStatus> getBatteryOptimizationPermission() async {
|
||||
final PermissionStatus status;
|
||||
final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations().then((p) => p.toStatus());
|
||||
if (isIgnoring == PermissionStatus.granted) {
|
||||
status = PermissionStatus.granted;
|
||||
} else {
|
||||
status = PermissionStatus.denied;
|
||||
}
|
||||
state = AsyncValue.data(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<PermissionStatus> build() => getBatteryOptimizationPermission();
|
||||
}
|
||||
|
||||
extension on pm.PermissionStatus {
|
||||
PermissionStatus toStatus() => switch (this) {
|
||||
pm.PermissionStatus.granted => PermissionStatus.granted,
|
||||
pm.PermissionStatus.denied => PermissionStatus.denied,
|
||||
pm.PermissionStatus.permanentlyDenied => PermissionStatus.permanentlyDenied,
|
||||
};
|
||||
}
|
||||
@@ -15,62 +15,37 @@ 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) {
|
||||
// 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 {
|
||||
Store.get(StoreKey.accessToken);
|
||||
} on StoreKeyNotFoundException catch (_) {
|
||||
_log.warning('No access token in the store.');
|
||||
resolver.next(false);
|
||||
unawaited(router.replaceAll([const LoginRoute()]));
|
||||
return;
|
||||
}
|
||||
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) async {
|
||||
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 {
|
||||
// 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) {
|
||||
// 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;
|
||||
}
|
||||
// If the access token is invalid, take user back to login
|
||||
_log.fine('User token is invalid. Redirecting to login');
|
||||
await router.replaceAll([const LoginRoute()]);
|
||||
await _authService.clearLocalData();
|
||||
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.');
|
||||
unawaited(router.replaceAll([const LoginRoute()]));
|
||||
return;
|
||||
} on ApiException catch (e) {
|
||||
if (e.code != HttpStatus.unauthorized) {
|
||||
// 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()));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ 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';
|
||||
@@ -190,7 +189,6 @@ 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: '/'),
|
||||
|
||||
@@ -1095,53 +1095,6 @@ 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,9 +2,24 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
enum AppSettingsEnum<T> {
|
||||
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
|
||||
uploadErrorNotificationGracePeriod<int>(
|
||||
StoreKey.uploadErrorNotificationGracePeriod,
|
||||
"uploadErrorNotificationGracePeriod",
|
||||
2,
|
||||
),
|
||||
thumbnailCacheSize<int>(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000),
|
||||
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
|
||||
albumThumbnailCacheSize<int>(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200),
|
||||
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, 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),
|
||||
|
||||
@@ -123,6 +123,7 @@ 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,13 +394,9 @@ 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,8 +5,6 @@ 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';
|
||||
@@ -321,12 +319,8 @@ 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(),
|
||||
@@ -432,9 +426,6 @@ 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',
|
||||
|
||||
@@ -21,13 +21,11 @@ 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';
|
||||
@@ -74,7 +72,6 @@ enum ActionButtonType {
|
||||
similarPhotos,
|
||||
setProfilePicture,
|
||||
viewInTimeline,
|
||||
slideshow,
|
||||
download,
|
||||
upload,
|
||||
openInBrowser,
|
||||
@@ -84,7 +81,6 @@ enum ActionButtonType {
|
||||
moveToLockFolder,
|
||||
removeFromLockFolder,
|
||||
removeFromAlbum,
|
||||
restoreTrash,
|
||||
trash,
|
||||
deleteLocal,
|
||||
deletePermanent,
|
||||
@@ -116,17 +112,12 @@ enum ActionButtonType {
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.isTrashEnabled && //
|
||||
context.timelineOrigin != TimelineOrigin.trash,
|
||||
ActionButtonType.restoreTrash =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.timelineOrigin == TimelineOrigin.trash,
|
||||
context.isTrashEnabled,
|
||||
ActionButtonType.deletePermanent =>
|
||||
context.isOwner && //
|
||||
context.asset.hasRemote && //
|
||||
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
|
||||
context.asset.hasRemote && //
|
||||
!context.isTrashEnabled ||
|
||||
context.isInLockedView,
|
||||
ActionButtonType.delete =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
@@ -181,7 +172,6 @@ enum ActionButtonType {
|
||||
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
||||
context.isOwner,
|
||||
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
|
||||
ActionButtonType.slideshow => true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,7 +193,6 @@ 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,
|
||||
@@ -212,11 +201,6 @@ 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,
|
||||
@@ -308,7 +292,6 @@ enum ActionButtonType {
|
||||
ActionButtonType.moveToLockFolder => 10,
|
||||
ActionButtonType.deleteLocal => 10,
|
||||
ActionButtonType.delete => 10,
|
||||
ActionButtonType.restoreTrash => 10,
|
||||
// 90: advancedInfo
|
||||
ActionButtonType.advancedInfo => 90,
|
||||
// 1: others
|
||||
@@ -326,8 +309,6 @@ class ActionButtonBuilder {
|
||||
ActionButtonType.delete,
|
||||
ActionButtonType.archive,
|
||||
ActionButtonType.unarchive,
|
||||
ActionButtonType.restoreTrash,
|
||||
ActionButtonType.deletePermanent,
|
||||
};
|
||||
|
||||
static List<Widget> build(ActionButtonContext context) {
|
||||
|
||||
@@ -88,14 +88,6 @@ Future<void> _migrateTo26(Drift drift) async {
|
||||
GroupAssetsBy.values,
|
||||
);
|
||||
await migrator.migrateBool(StoreKey.legacyStorageIndicator, MetadataKey.timelineStorageIndicator);
|
||||
// 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,7 +18,6 @@ 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 {
|
||||
@@ -90,10 +89,6 @@ 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),
|
||||
|
||||
@@ -32,11 +32,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
|
||||
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
|
||||
useValueChanged(
|
||||
preferRemote.value,
|
||||
(_, __) => ref.read(metadataProvider).write(.imagePreferRemote, preferRemote.value),
|
||||
);
|
||||
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
||||
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
|
||||
|
||||
final logLevel = Level.LEVELS[levelId.value].name;
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 {
|
||||
@@ -14,7 +13,6 @@ class AssetViewerSettings extends StatelessWidget {
|
||||
const ImageViewerQualitySetting(),
|
||||
const ImageViewerTapToNavigateSetting(),
|
||||
const VideoViewerSettings(),
|
||||
const SlideshowSettings(),
|
||||
];
|
||||
|
||||
return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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/providers/infrastructure/metadata.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/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
|
||||
@@ -12,10 +12,7 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isOriginal = useState(ref.read(appConfigProvider).image.loadOriginal);
|
||||
useValueChanged<bool, void>(isOriginal.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.imageLoadOriginal, isOriginal.value);
|
||||
});
|
||||
final isOriginal = useAppSettingsState(AppSettingsEnum.loadOriginal);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
+5
-6
@@ -1,20 +1,18 @@
|
||||
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/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.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 = useState(ref.read(appConfigProvider).viewer.tapToNavigate);
|
||||
useValueChanged<bool, void>(tapToNavigate.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.viewerTapToNavigate, tapToNavigate.value);
|
||||
});
|
||||
final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -24,6 +22,7 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
|
||||
valueNotifier: tapToNavigate,
|
||||
title: "setting_image_navigation_enable_title".tr(),
|
||||
subtitle: "setting_image_navigation_enable_subtitle".tr(),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/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,30 +1,20 @@
|
||||
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/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.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 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);
|
||||
});
|
||||
final useLoopVideo = useAppSettingsState(AppSettingsEnum.loopVideo);
|
||||
final useOriginalVideo = useAppSettingsState(AppSettingsEnum.loadOriginalVideo);
|
||||
final useAutoPlayVideo = useAppSettingsState(AppSettingsEnum.autoPlayVideo);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -37,16 +27,19 @@ 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2,8 +2,11 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/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';
|
||||
|
||||
@@ -13,6 +16,9 @@ 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) {
|
||||
@@ -35,6 +41,8 @@ class NotificationSetting extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final String formattedValue = _formatSliderValue(sliderValue.value.toDouble());
|
||||
|
||||
final notificationSettings = [
|
||||
if (!hasPermission)
|
||||
SettingsButtonListTile(
|
||||
@@ -49,8 +57,32 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
+8
-14
@@ -30,14 +30,8 @@ run = "dart run build_runner watch --delete-conflicting-outputs"
|
||||
alias = "pigeon"
|
||||
description = "Generate pigeon platform code"
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/native_sync_api.dart",
|
||||
"dart run pigeon --input pigeon/local_image_api.dart",
|
||||
"dart run pigeon --input pigeon/remote_image_api.dart",
|
||||
"dart run pigeon --input pigeon/background_worker_api.dart",
|
||||
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
||||
"dart run pigeon --input pigeon/connectivity_api.dart",
|
||||
"dart run pigeon --input pigeon/network_api.dart",
|
||||
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart",
|
||||
"ls pigeon/*.dart | xargs -n1 -P4 -I{} dart run pigeon --input {}",
|
||||
"dart format lib/platform/",
|
||||
]
|
||||
|
||||
[tasks."codegen:translation"]
|
||||
@@ -131,10 +125,10 @@ run = "dcm fix lib"
|
||||
|
||||
[tasks.checklist]
|
||||
run = [
|
||||
{task = "codegen:pigeon" },
|
||||
{task = "codegen:dart" },
|
||||
{task = "codegen:translation" },
|
||||
{task = "analyze" },
|
||||
{task = "format" },
|
||||
{task = "test" },
|
||||
{ task = "codegen:pigeon" },
|
||||
{ task = "codegen:dart" },
|
||||
{ task = "codegen:translation" },
|
||||
{ task = "analyze" },
|
||||
{ task = "format" },
|
||||
{ task = "test" },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
enum PermissionStatus { granted, denied, permanentlyDenied }
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/permission_api.g.dart',
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
@HostApi()
|
||||
abstract class PermissionApi {
|
||||
PermissionStatus isIgnoringBatteryOptimizations();
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import 'package:mocktail/mocktail.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
|
||||
const _kAccessToken = '#ThisIsAToken';
|
||||
const _kEnableBackup = false;
|
||||
const _kBackgroundBackup = false;
|
||||
const _kVersion = 2;
|
||||
final _kBackupFailedSince = DateTime.utc(2023);
|
||||
|
||||
void main() {
|
||||
late StoreService sut;
|
||||
@@ -23,13 +24,15 @@ 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.enableBackup);
|
||||
registerFallbackValue(StoreKey.backgroundBackup);
|
||||
registerFallbackValue(StoreKey.backupFailedSince);
|
||||
|
||||
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
|
||||
(_) async => [
|
||||
const StoreDto(StoreKey.accessToken, _kAccessToken),
|
||||
const StoreDto(StoreKey.enableBackup, _kEnableBackup),
|
||||
const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup),
|
||||
const StoreDto(StoreKey.version, _kVersion),
|
||||
StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince),
|
||||
],
|
||||
);
|
||||
when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
|
||||
@@ -46,8 +49,9 @@ void main() {
|
||||
test('Populates the internal cache on init', () {
|
||||
verify(() => mockDriftStoreRepo.getAll()).called(1);
|
||||
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
|
||||
expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup);
|
||||
expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup);
|
||||
expect(sut.tryGet(StoreKey.version), _kVersion);
|
||||
expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince);
|
||||
// Other keys should be null
|
||||
expect(sut.tryGet(StoreKey.currentUser), isNull);
|
||||
});
|
||||
@@ -147,8 +151,9 @@ void main() {
|
||||
await sut.clear();
|
||||
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
|
||||
expect(sut.tryGet(StoreKey.accessToken), isNull);
|
||||
expect(sut.tryGet(StoreKey.enableBackup), isNull);
|
||||
expect(sut.tryGet(StoreKey.backgroundBackup), isNull);
|
||||
expect(sut.tryGet(StoreKey.version), isNull);
|
||||
expect(sut.tryGet(StoreKey.backupFailedSince), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ 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 _kTestBackupRequireCharging = false;
|
||||
const _kTestBackupRequireWifi = false;
|
||||
final _kTestUser = UserStub.admin;
|
||||
|
||||
Future<void> _populateStore(Drift db) async {
|
||||
@@ -21,8 +22,16 @@ Future<void> _populateStore(Drift db) async {
|
||||
batch.insert(
|
||||
db.storeEntity,
|
||||
StoreEntityCompanion(
|
||||
id: Value(StoreKey.backupRequireCharging.id),
|
||||
intValue: const Value(_kTestBackupRequireCharging ? 1 : 0),
|
||||
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),
|
||||
stringValue: const Value(null),
|
||||
),
|
||||
);
|
||||
@@ -75,12 +84,20 @@ 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? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, isNull);
|
||||
await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging);
|
||||
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, _kTestBackupRequireCharging);
|
||||
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);
|
||||
});
|
||||
|
||||
test('converts user', () async {
|
||||
@@ -98,11 +115,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('delete()', () async {
|
||||
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, isFalse);
|
||||
await sut.delete(StoreKey.backupRequireCharging);
|
||||
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, isNull);
|
||||
bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
|
||||
expect(backupRequireWifi, isFalse);
|
||||
await sut.delete(StoreKey.backupRequireWifi);
|
||||
backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
|
||||
expect(backupRequireWifi, isNull);
|
||||
});
|
||||
|
||||
test('deleteAll()', () async {
|
||||
@@ -147,12 +164,14 @@ void main() {
|
||||
emitsInOrder([
|
||||
[
|
||||
const StoreDto<Object>(StoreKey.version, _kTestVersion),
|
||||
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
|
||||
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
|
||||
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
|
||||
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
|
||||
],
|
||||
[
|
||||
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
|
||||
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
|
||||
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
|
||||
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
|
||||
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
|
||||
],
|
||||
]),
|
||||
|
||||
@@ -79,6 +79,7 @@ void main() {
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
});
|
||||
|
||||
@@ -89,6 +90,7 @@ void main() {
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
|
||||
await MetadataRepository.refresh();
|
||||
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
@@ -133,4 +135,5 @@ void main() {
|
||||
await expectation;
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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({
|
||||
@@ -38,7 +37,6 @@ RemoteAsset createRemoteAsset({
|
||||
DateTime? updatedAt,
|
||||
DateTime? uploadedAt,
|
||||
bool isFavorite = false,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: 'remote-id',
|
||||
@@ -52,7 +50,6 @@ RemoteAsset createRemoteAsset({
|
||||
uploadedAt: uploadedAt ?? DateTime.now(),
|
||||
isFavorite: isFavorite,
|
||||
isEdited: false,
|
||||
deletedAt: deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -461,62 +458,6 @@ 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', () {
|
||||
@@ -553,24 +494,6 @@ 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', () {
|
||||
|
||||
Generated
+145
-90
@@ -6,7 +6,7 @@ settings:
|
||||
injectWorkspacePackages: true
|
||||
|
||||
overrides:
|
||||
canvas: 3.2.3
|
||||
canvas: 2.11.2
|
||||
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@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))
|
||||
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))
|
||||
|
||||
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@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))
|
||||
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))
|
||||
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@3.2.3))(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@2.11.2))(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@3.2.3))(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@2.11.2))(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@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))
|
||||
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))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -6182,9 +6182,9 @@ packages:
|
||||
caniuse-lite@1.0.30001790:
|
||||
resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==}
|
||||
|
||||
canvas@3.2.3:
|
||||
resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==}
|
||||
engines: {node: ^18.12.0 || >= 20.9.0}
|
||||
canvas@2.11.2:
|
||||
resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
@@ -6964,6 +6964,10 @@ 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'}
|
||||
@@ -7563,10 +7567,6 @@ 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,9 +7866,6 @@ 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==}
|
||||
|
||||
@@ -8594,7 +8591,7 @@ packages:
|
||||
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
canvas: 3.2.3
|
||||
canvas: 2.11.2
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
@@ -9311,6 +9308,10 @@ 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'}
|
||||
@@ -9478,9 +9479,6 @@ 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==}
|
||||
|
||||
@@ -9554,10 +9552,6 @@ 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==}
|
||||
|
||||
@@ -10505,12 +10499,6 @@ 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'}
|
||||
@@ -11188,8 +11176,8 @@ packages:
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
simple-get@3.1.1:
|
||||
resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==}
|
||||
|
||||
simple-icons@16.17.0:
|
||||
resolution: {integrity: sha512-bRrGtzM6NLgxeMWmRcfDdrRksECk101lRrCn6jjj6qzUB6lQ+E5smnr52rqS1kLPmbLpS/g6iF463j50M4BT7A==}
|
||||
@@ -11898,9 +11886,6 @@ 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==}
|
||||
|
||||
@@ -15814,6 +15799,22 @@ 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
|
||||
@@ -17247,7 +17248,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@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))
|
||||
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))
|
||||
|
||||
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
|
||||
dependencies:
|
||||
@@ -17963,7 +17964,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@3.2.3))(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@2.11.2))(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
|
||||
@@ -17978,7 +17979,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@3.2.3))(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@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -17994,7 +17995,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@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: 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/expect@3.2.4':
|
||||
dependencies:
|
||||
@@ -18761,10 +18762,24 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001790: {}
|
||||
|
||||
canvas@3.2.3:
|
||||
canvas@2.11.2:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
prebuild-install: 7.1.3
|
||||
'@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
|
||||
optional: true
|
||||
|
||||
ccount@2.0.1: {}
|
||||
@@ -19567,6 +19582,11 @@ 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
|
||||
@@ -20311,9 +20331,6 @@ 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: {}
|
||||
@@ -20399,10 +20416,11 @@ snapshots:
|
||||
|
||||
fabric@7.3.1:
|
||||
optionalDependencies:
|
||||
canvas: 3.2.3
|
||||
jsdom: 26.1.0(canvas@3.2.3)
|
||||
canvas: 2.11.2
|
||||
jsdom: 26.1.0(canvas@2.11.2)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- encoding
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
@@ -20694,9 +20712,6 @@ 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: {}
|
||||
@@ -21518,7 +21533,7 @@ snapshots:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsdom@26.1.0(canvas@3.2.3):
|
||||
jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)):
|
||||
dependencies:
|
||||
cssstyle: 4.6.0
|
||||
data-urls: 5.0.0
|
||||
@@ -21541,7 +21556,37 @@ snapshots:
|
||||
ws: 8.20.0
|
||||
xml-name-validator: 5.0.0
|
||||
optionalDependencies:
|
||||
canvas: 3.2.3
|
||||
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
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
@@ -22544,6 +22589,9 @@ snapshots:
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
|
||||
mimic-response@2.1.0:
|
||||
optional: true
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
mimic-response@4.0.0: {}
|
||||
@@ -22697,9 +22745,6 @@ snapshots:
|
||||
|
||||
nanoid@5.1.9: {}
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
optional: true
|
||||
|
||||
natural-compare-lite@1.4.0: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -22772,11 +22817,6 @@ 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: {}
|
||||
@@ -22797,6 +22837,11 @@ 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
|
||||
@@ -23763,22 +23808,6 @@ 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:
|
||||
@@ -24702,9 +24731,9 @@ snapshots:
|
||||
simple-concat@1.0.1:
|
||||
optional: true
|
||||
|
||||
simple-get@4.0.1:
|
||||
simple-get@3.1.1:
|
||||
dependencies:
|
||||
decompress-response: 6.0.0
|
||||
decompress-response: 4.2.1
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
optional: true
|
||||
@@ -25546,11 +25575,6 @@ 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:
|
||||
@@ -25960,9 +25984,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@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: 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@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):
|
||||
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):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
@@ -25991,7 +26015,7 @@ snapshots:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 24.12.2
|
||||
happy-dom: 20.9.0
|
||||
jsdom: 26.1.0(canvas@3.2.3)
|
||||
jsdom: 26.1.0(canvas@2.11.2)
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
@@ -26006,7 +26030,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@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@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)):
|
||||
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))
|
||||
@@ -26033,11 +26057,42 @@ 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@3.2.3)
|
||||
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
|
||||
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@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)):
|
||||
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)):
|
||||
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))
|
||||
@@ -26064,7 +26119,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@3.2.3)
|
||||
jsdom: 26.1.0(canvas@2.11.2)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
- bcrypt
|
||||
overrides:
|
||||
canvas: 3.2.3
|
||||
canvas: 2.11.2
|
||||
sharp: ^0.34.5
|
||||
# pending docusaurus 3.10.1
|
||||
webpackbar: ^7.0.0
|
||||
|
||||
+16
-11
@@ -37,24 +37,29 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> ⚠️ اتبع دائمًا خطة النسخ الاحتياطي [١-٢-٣](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) لصورك ومقاطع الفيديو الثمينة الخاصة بك
|
||||
>
|
||||
## تنصل
|
||||
|
||||
> [!NOTE]
|
||||
> يمكنك العثور على الوثائق الرئيسية، بما في ذلك أدلة التثبيت، على https://immich.app/
|
||||
- ⚠️ هذا التطبيق قيد التطوير النشط للغاية
|
||||
- ⚠️ توقع الأخطاء والتغييرات العاجلة
|
||||
- ⚠️ **لا تستخدم التطبيق باعتباره الطريقة الوحيدة لتخزين الصور ومقاطع الفيديو الخاصة بك**
|
||||
- ⚠️ اتبع دائمًا خطة النسخ الاحتياطي [١-٢-٣](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) لصورك ومقاطع الفيديو الثمينة الخاصة بك
|
||||
|
||||
## روابط
|
||||
|
||||
- [الوثائق الرسمية](https://docs.immich.app/)
|
||||
## محتوى
|
||||
|
||||
- [الوثائق الرسمية](https://docs.immich.app)
|
||||
- [خريطة الطريق](https://github.com/orgs/immich-app/projects/1)
|
||||
- [تجريبي](#demo)
|
||||
- [سمات](#features)
|
||||
- [مقدمة](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
|
||||
|
||||
## تجريبي
|
||||
|
||||
يمكنك الوصول إلى العرض التوضيحي على الويب على
|
||||
|
||||
+12
-10
@@ -37,24 +37,26 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
> [!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!
|
||||
>
|
||||
## Avís legal
|
||||
|
||||
> [!NOTE]
|
||||
> Podeu trobar la documentació principal, incloent les guies d'instal·lació, a https://immich.app/.
|
||||
- ⚠️ 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!**
|
||||
|
||||
## Contingut
|
||||
|
||||
- [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)
|
||||
- [Documentació oficial](https://docs.immich.app)
|
||||
- [Mapa de ruta](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Funcionalitats](#funcionalitats)
|
||||
- [Traduccions](https://docs.immich.app/developer/translations)
|
||||
- [Introducció](https://docs.immich.app/overview/introduction)
|
||||
- [Instal·lació](https://docs.immich.app/install/requirements)
|
||||
- [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".
|
||||
|
||||
@@ -38,9 +38,7 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
> [!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!
|
||||
>
|
||||
- ⚠️ 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/.
|
||||
@@ -51,7 +49,7 @@
|
||||
- [Offizielle Dokumentation](https://docs.immich.app)
|
||||
- [Über Immich](https://docs.immich.app/overview/introduction)
|
||||
- [Installation](https://docs.immich.app/install/requirements)
|
||||
- [Roadmap](https://immich.app/roadmap)
|
||||
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Funktionen](#funktionen)
|
||||
- [Übersetzungen](https://docs.immich.app/developer/translations)
|
||||
|
||||
+13
-10
@@ -37,24 +37,27 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
> [!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.
|
||||
>
|
||||
## Advertencia
|
||||
|
||||
> [!NOTE]
|
||||
> Puedes encontrar la documentación oficial, incluidas las guías de instalación, en <https://immich.app/>.
|
||||
- ⚠️ 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.
|
||||
|
||||
## Contenido
|
||||
|
||||
- [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)
|
||||
- [Documentación oficial](https://docs.immich.app)
|
||||
- [Hoja de ruta](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Funciones](#funciones)
|
||||
- [Traducciones](https://docs.immich.app/developer/translations)
|
||||
- [Introducción](https://docs.immich.app/overview/introduction)
|
||||
- [Instalación](https://docs.immich.app/install/requirements)
|
||||
- [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`.
|
||||
|
||||
+13
-10
@@ -37,24 +37,27 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
> [!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 !
|
||||
>
|
||||
## Clause de non-responsabilité
|
||||
|
||||
> [!NOTE]
|
||||
> Vous pouvez trouver la documentation principale ainsi que les guides d'installation sur https://immich.app/.
|
||||
- ⚠️ 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 !
|
||||
|
||||
## Sommaire
|
||||
|
||||
- [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)
|
||||
- [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)
|
||||
- [Traductions](https://docs.immich.app/developer/translations)
|
||||
- [Introduction](https://docs.immich.app/overview/introduction)
|
||||
- [Installation](https://docs.immich.app/install/requirements)
|
||||
- [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`
|
||||
|
||||
@@ -38,9 +38,12 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
> [!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!
|
||||
>
|
||||
## Avvertenze
|
||||
|
||||
- ⚠️ Il progetto è in fase di sviluppo **molto attivo**.
|
||||
- ⚠️ Possono esserci bug o cambiamenti radicali, che possono non essere retrocompatibili (breaking changes).
|
||||
- ⚠️ **Non usare l’app 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!
|
||||
|
||||
> [!NOTE]
|
||||
> La documentazione principale, comprese le guide all’installazione, si trova su https://immich.app/.
|
||||
|
||||
+13
-10
@@ -36,24 +36,27 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> ⚠️ 大切な写真やビデオは、常に [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) のバックアッププランに従ってください!
|
||||
>
|
||||
## 免責事項
|
||||
|
||||
> [!NOTE]
|
||||
> インストールガイドを含む主なドキュメントは、https://immich.app/ です。
|
||||
- ⚠️ このプロジェクトは **非常に活発に** 開発中です。
|
||||
- ⚠️ バグの存在や変更が入ることも予想されます。
|
||||
- ⚠️ **写真やビデオを保存する唯一の方法としてこのアプリを使用しないでください。**
|
||||
- ⚠️ 大切な写真やビデオは、常に [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) のバックアッププランに従ってください!
|
||||
|
||||
## コンテンツ
|
||||
|
||||
- [公式ドキュメント](https://docs.immich.app/)
|
||||
- [紹介](https://docs.immich.app/overview/introduction)
|
||||
- [インストール](https://docs.immich.app/install/requirements)
|
||||
- [ロードマップ](https://immich.app/roadmap)
|
||||
- [公式ドキュメント](https://docs.immich.app)
|
||||
- [ロードマップ](https://github.com/orgs/immich-app/projects/1)
|
||||
- [デモ](#デモ)
|
||||
- [機能](#機能)
|
||||
- [翻訳](https://docs.immich.app/developer/translations)
|
||||
- [紹介](https://docs.immich.app/overview/introduction)
|
||||
- [インストール](https://docs.immich.app/install/requirements)
|
||||
- [コントリビューションガイド](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## ドキュメント
|
||||
|
||||
インストールガイドを含む主なドキュメントは、https://immich.app/ です。
|
||||
|
||||
## デモ
|
||||
|
||||
web デモは https://demo.immich.app からアクセスできます。モバイルアプリの場合、`Server Endpoint URL` には `https://demo.immich.app` を使用することができます
|
||||
|
||||
@@ -39,9 +39,12 @@
|
||||
|
||||
</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/ 에서 확인할 수 있습니다.
|
||||
@@ -54,7 +57,7 @@
|
||||
- [로드맵](https://immich.app/roadmap)
|
||||
- [데모](#데모)
|
||||
- [기능](#기능)
|
||||
- [번역](https://docs.immich.app/developer/translations)
|
||||
- [번역](https://docs.immich.app/developer/tranlations)
|
||||
- [기여](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## 데모
|
||||
|
||||
+13
-10
@@ -37,24 +37,27 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
> [!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!
|
||||
>
|
||||
## Disclaimer
|
||||
|
||||
> [!NOTE]
|
||||
> De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vinden op https://immich.app/.
|
||||
- ⚠️ 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!
|
||||
|
||||
## Inhoud
|
||||
|
||||
- [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)
|
||||
- [Officiële documentatie](https://docs.immich.app)
|
||||
- [Toekomstplannen](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Functies](#functies)
|
||||
- [Vertalingen](https://docs.immich.app/developer/translations)
|
||||
- [Introductie](https://docs.immich.app/overview/introduction)
|
||||
- [Installatie](https://docs.immich.app/install/requirements)
|
||||
- [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`.
|
||||
|
||||
@@ -40,9 +40,16 @@
|
||||
|
||||
</p>
|
||||
|
||||
> [!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!
|
||||
>
|
||||
## 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!
|
||||
|
||||
> [!NOTE]
|
||||
> Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/.
|
||||
|
||||
@@ -39,9 +39,13 @@
|
||||
<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/ "Стратегии резервного копирования: Почему стратегия резервного копирования «3-2-1» — лучшая") для ваших драгоценных фотографий и видео!
|
||||
|
||||
|
||||
> [!NOTE]
|
||||
> Инструкции по установке и документация по ссылке https://immich.app/
|
||||
@@ -51,7 +55,7 @@
|
||||
- [Официальная документация](https://docs.immich.app)
|
||||
- [Введение](https://docs.immich.app/overview/introduction)
|
||||
- [Установка](https://docs.immich.app/install/requirements)
|
||||
- [План разработки](https://immich.app/roadmap)
|
||||
- [План разработки](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Демо](#demo)
|
||||
- [Возможности](#features)
|
||||
- [Перевод](https://docs.immich.app/developer/translations)
|
||||
|
||||
+13
-10
@@ -38,24 +38,27 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
> [!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!
|
||||
>
|
||||
## Ansvarsfriskrivning
|
||||
|
||||
> [!NOTE]
|
||||
> Dokumentation och installationsguider hittas på https://immich.app/.
|
||||
- ⚠️ 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!
|
||||
|
||||
## Innehåll
|
||||
|
||||
- [Officiell Dokumentation](https://docs.immich.app/)
|
||||
- [Officiell Dokumentation](https://docs.immich.app)
|
||||
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Funktioner](#features)
|
||||
- [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`
|
||||
|
||||
+13
-11
@@ -37,24 +37,26 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
> [!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!
|
||||
>
|
||||
## Feragatname
|
||||
|
||||
> [!NOTE]
|
||||
> Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
|
||||
- ⚠️ 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!**
|
||||
|
||||
## Bağlantılar
|
||||
## Content
|
||||
|
||||
- [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)
|
||||
- [Resmi Belgeler](https://docs.immich.app)
|
||||
- [Yol Haritası](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Özellikler](#özellikler)
|
||||
- [Çeviriler](https://docs.immich.app/developer/translations)
|
||||
- [Giriş](https://docs.immich.app/overview/introduction)
|
||||
- [Kurulum](https://docs.immich.app/install/requirements)
|
||||
- [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.
|
||||
|
||||
@@ -39,9 +39,12 @@
|
||||
<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/.
|
||||
|
||||
@@ -41,9 +41,12 @@
|
||||
|
||||
</p>
|
||||
|
||||
> [!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!
|
||||
>
|
||||
## 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!
|
||||
|
||||
> [!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/.
|
||||
|
||||
@@ -43,9 +43,12 @@
|
||||
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> ⚠️ 为了您宝贵的照片与视频,请始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案!
|
||||
>
|
||||
## 免责声明
|
||||
|
||||
- ⚠️ 本项目正在 **非常活跃** 地开发中。
|
||||
- ⚠️ 可能存在 bug 或者随时有重大变更。
|
||||
- ⚠️ **不要把本软件作为您存储照片或视频的唯一方式。**
|
||||
- ⚠️ 为了您宝贵的照片与视频,请始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案!
|
||||
|
||||
> [!NOTE]
|
||||
> 完整的项目文档以及安装教程请参见:<https://immich.app/>。
|
||||
|
||||
@@ -171,8 +171,8 @@ export class JobRepository {
|
||||
options: this.getJobOptions(item) || undefined,
|
||||
} as JobItem & { data: any; options: JobsOptions | undefined };
|
||||
|
||||
if (job.options?.jobId || job.options?.deduplication) {
|
||||
// need to use add() instead of addBulk() for jobId/deduplication to take effect
|
||||
if (job.options?.jobId) {
|
||||
// need to use add() instead of addBulk() for jobId deduplication
|
||||
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
|
||||
} else {
|
||||
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
|
||||
@@ -230,13 +230,10 @@ export class JobRepository {
|
||||
return { priority: 1 };
|
||||
}
|
||||
case JobName.FacialRecognitionQueueAll: {
|
||||
return { deduplication: { id: JobName.FacialRecognitionQueueAll } };
|
||||
return { jobId: JobName.FacialRecognitionQueueAll };
|
||||
}
|
||||
case JobName.VersionCheck: {
|
||||
return { deduplication: { id: JobName.VersionCheck } };
|
||||
}
|
||||
case JobName.DatabaseBackup: {
|
||||
return { deduplication: { id: JobName.DatabaseBackup } };
|
||||
return { jobId: JobName.VersionCheck };
|
||||
}
|
||||
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, ReadTaskOptions, Tags } from 'exiftool-vendored';
|
||||
import { BinaryField, DefaultReadTaskOptions, ExifTool, 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', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'],
|
||||
readArgs: ['-api', 'largefilesupport=1'],
|
||||
writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'],
|
||||
taskTimeoutMillis: 2 * 60 * 1000,
|
||||
});
|
||||
@@ -107,8 +107,8 @@ export class MetadataRepository {
|
||||
}
|
||||
|
||||
readTags(path: string): Promise<ImmichTags> {
|
||||
const options: ReadTaskOptions | undefined = mimeTypes.isVideo(path) ? { readArgs: ['-ee'] } : undefined;
|
||||
return this.exiftool.read(path, options).catch((error) => {
|
||||
const args = mimeTypes.isVideo(path) ? ['-ee'] : [];
|
||||
return this.exiftool.read(path, { readArgs: args }).catch((error) => {
|
||||
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
|
||||
return {};
|
||||
}) as Promise<ImmichTags>;
|
||||
|
||||
@@ -1,54 +1,3 @@
|
||||
<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';
|
||||
@@ -149,27 +98,14 @@
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const { insetInlineStart, top, rasterWidth, rasterHeight, rasterScale } = $derived.by(() => {
|
||||
const { width, height, insetInlineStart, top } = $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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -216,14 +152,11 @@
|
||||
{@render backdrop?.()}
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute"
|
||||
class="pointer-events-none absolute inset-0"
|
||||
style:inset-inline-start={insetInlineStart}
|
||||
style:top
|
||||
style:width={rasterWidth}
|
||||
style:height={rasterHeight}
|
||||
style:transform="scale({rasterScale})"
|
||||
style:transform-origin="0 0"
|
||||
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
|
||||
style:width
|
||||
style:height
|
||||
>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
@@ -241,8 +174,8 @@
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
{width}
|
||||
{height}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
@@ -259,8 +192,8 @@
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
@@ -272,8 +205,8 @@
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
width={rasterWidth}
|
||||
height={rasterHeight}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
|
||||
@@ -212,10 +212,16 @@
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fileCreatedAfter: dateAfter?.toUTC().toISO(),
|
||||
fileCreatedBefore: dateBefore?.toUTC().toISO(),
|
||||
};
|
||||
try {
|
||||
return {
|
||||
fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined,
|
||||
fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined,
|
||||
};
|
||||
} catch {
|
||||
$mapSettings.dateAfter = '';
|
||||
$mapSettings.dateBefore = '';
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMapMarkers() {
|
||||
@@ -231,7 +237,7 @@
|
||||
{
|
||||
isArchived: includeArchived || undefined,
|
||||
isFavorite: onlyFavorites || undefined,
|
||||
fileCreatedAfter,
|
||||
fileCreatedAfter: fileCreatedAfter || undefined,
|
||||
fileCreatedBefore,
|
||||
withPartners: withPartners || undefined,
|
||||
withSharedAlbums: withSharedAlbums || undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import type { MapSettings } from '$lib/stores/preferences.store';
|
||||
import { Button, DatePicker, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
|
||||
import { Button, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
|
||||
import { Duration } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -40,21 +41,29 @@
|
||||
|
||||
{#if customDateRange}
|
||||
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
|
||||
<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">
|
||||
<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">
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
customDateRange = false;
|
||||
settings.dateAfter = undefined;
|
||||
settings.dateBefore = undefined;
|
||||
settings.dateAfter = '';
|
||||
settings.dateBefore = '';
|
||||
}}
|
||||
>
|
||||
{$t('remove_custom_date_range')}
|
||||
@@ -62,7 +71,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-2">
|
||||
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
|
||||
<Field label={$t('date_range')}>
|
||||
<Select
|
||||
bind:value={settings.relativeDate}
|
||||
@@ -73,38 +82,40 @@
|
||||
},
|
||||
{
|
||||
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>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
customDateRange = true;
|
||||
settings.relativeDate = '';
|
||||
}}
|
||||
>
|
||||
{$t('use_custom_date_range')}
|
||||
</Button>
|
||||
<div class="text-xs">
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
customDateRange = true;
|
||||
settings.relativeDate = '';
|
||||
}}
|
||||
>
|
||||
{$t('use_custom_date_range')}
|
||||
</Button>
|
||||
</div>
|
||||
</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, DatePicker, Field, FormModal, HelperText } from '@immich/ui';
|
||||
import { Button, FormModal, Text } from '@immich/ui';
|
||||
import { mdiCake } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
@@ -12,25 +12,32 @@
|
||||
};
|
||||
|
||||
let { person, onClose }: Props = $props();
|
||||
let birthDate = $derived(person.birthDate ? DateTime.fromISO(person.birthDate) : undefined);
|
||||
let birthDate = $derived(person.birthDate ?? '');
|
||||
|
||||
const onSubmit = async () => {
|
||||
const success = await handleUpdatePersonBirthDate(person, birthDate?.toISODate() ?? '');
|
||||
const success = await handleUpdatePersonBirthDate(person, birthDate);
|
||||
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}>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
{#if person.birthDate}
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = undefined)}>
|
||||
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = '')}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { DateTime } from 'luxon';
|
||||
import { persisted } from 'svelte-persisted-store';
|
||||
import { browser } from '$app/environment';
|
||||
import { defaultLang } from '$lib/constants';
|
||||
@@ -27,8 +26,8 @@ export interface MapSettings {
|
||||
withPartners: boolean;
|
||||
withSharedAlbums: boolean;
|
||||
relativeDate: string;
|
||||
dateAfter?: DateTime<true>;
|
||||
dateBefore?: DateTime<true>;
|
||||
dateAfter: string;
|
||||
dateBefore: string;
|
||||
}
|
||||
|
||||
const defaultMapSettings = {
|
||||
@@ -38,6 +37,8 @@ const defaultMapSettings = {
|
||||
withPartners: false,
|
||||
withSharedAlbums: false,
|
||||
relativeDate: '',
|
||||
dateAfter: '',
|
||||
dateBefore: '',
|
||||
};
|
||||
|
||||
const persistedObject = <T>(key: string, defaults: T) =>
|
||||
|
||||
@@ -31,7 +31,4 @@ 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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,18 +4,15 @@
|
||||
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 { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
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;
|
||||
@@ -43,15 +40,6 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onViewAsset = async (id: string) => {
|
||||
const asset = await getAssetInfo({ ...authManager.params, id });
|
||||
assetViewerManager.setAsset(asset);
|
||||
};
|
||||
|
||||
const assetCursor = $derived({
|
||||
current: assetViewerManager.asset!,
|
||||
});
|
||||
</script>
|
||||
|
||||
<OnEvents {onPersonThumbnailReady} />
|
||||
@@ -134,20 +122,15 @@
|
||||
draggable="false">{$t('view_all')}</a
|
||||
>
|
||||
</div>
|
||||
<div class="flex h-24 max-w-fit flex-wrap gap-x-1 overflow-hidden md:h-42">
|
||||
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
|
||||
{#each recents as item (item.data.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-full flex-auto"
|
||||
onclick={() => onViewAsset(item.data.id)}
|
||||
draggable="false"
|
||||
>
|
||||
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: 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"
|
||||
/>
|
||||
</button>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,15 +140,3 @@
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user