Compare commits
43 Commits
refactor/m
...
chore/pnpm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a24186e883 | ||
|
|
85c348d87c | ||
|
|
b0778dcc49 | ||
|
|
7b1011c091 | ||
|
|
494384ca8a | ||
|
|
0afe49cd8a | ||
|
|
678ea38f2f | ||
|
|
23cce1ea91 | ||
|
|
0bfc8beec1 | ||
|
|
0992d50699 | ||
|
|
d4f2b43f64 | ||
|
|
f343b0e58f | ||
|
|
a8b4a5e856 | ||
|
|
e7e030279b | ||
|
|
9ff664ed36 | ||
|
|
e00556a34a | ||
|
|
a313e4338e | ||
|
|
257b0c74af | ||
|
|
3d515f5072 | ||
|
|
ec01db5c8b | ||
|
|
cd6d8fcdfe | ||
|
|
1198311d64 | ||
|
|
1a4eab9655 | ||
|
|
1926c90780 | ||
|
|
4d5975b717 | ||
|
|
8cbd6b29c4 | ||
|
|
8c1b630a2b | ||
|
|
c961d2aaf7 | ||
|
|
41c75dc93e | ||
|
|
f92247c99b | ||
|
|
53f9fc2d1c | ||
|
|
bede19a3ca | ||
|
|
aefa62b234 | ||
|
|
b3fb831994 | ||
|
|
0d60199514 | ||
|
|
54960157c0 | ||
|
|
244d097d01 | ||
|
|
adb55f3726 | ||
|
|
5d2777a5c6 | ||
|
|
24db881c14 | ||
|
|
f09bed9ad2 | ||
|
|
e29cc66361 | ||
|
|
669b765662 |
@@ -49,10 +49,11 @@ fix_permissions() {
|
||||
|
||||
log "Fixing permissions for ${IMMICH_WORKSPACE}"
|
||||
|
||||
run_cmd sudo find "${IMMICH_WORKSPACE}/server/upload" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres/*" -not -path "${IMMICH_WORKSPACE}/server/upload/postgres" -exec chown node {} +
|
||||
|
||||
# Change ownership for directories that exist
|
||||
for dir in "${IMMICH_WORKSPACE}/.vscode" \
|
||||
"${IMMICH_WORKSPACE}/server/upload" \
|
||||
"${IMMICH_WORKSPACE}/.pnpm-store" \
|
||||
"${IMMICH_WORKSPACE}/.github/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/cli/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/e2e/node_modules" \
|
||||
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
|
||||
|
||||
@@ -8,21 +8,13 @@ services:
|
||||
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
|
||||
volumes: !override
|
||||
- ..:/workspaces/immich
|
||||
- cli_node_modules:/workspaces/immich/cli/node_modules
|
||||
- e2e_node_modules:/workspaces/immich/e2e/node_modules
|
||||
- open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules
|
||||
- server_node_modules:/workspaces/immich/server/node_modules
|
||||
- web_node_modules:/workspaces/immich/web/node_modules
|
||||
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
|
||||
immich-machine-learning:
|
||||
env_file: !reset []
|
||||
|
||||
database:
|
||||
env_file: !reset []
|
||||
environment: !override
|
||||
@@ -33,17 +25,10 @@ services:
|
||||
POSTGRES_HOST_AUTH_METHOD: md5
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
env_file: !reset []
|
||||
|
||||
volumes:
|
||||
# Node modules for each service to avoid conflicts and ensure consistent dependencies
|
||||
cli_node_modules:
|
||||
e2e_node_modules:
|
||||
open_api_node_modules:
|
||||
server_node_modules:
|
||||
web_node_modules:
|
||||
upload1-devcontainer-volume:
|
||||
upload2-devcontainer-volume:
|
||||
postgres-devcontainer-volume:
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
# shellcheck disable=SC1091
|
||||
source /immich-devcontainer/container-common.sh
|
||||
|
||||
log "Preparing Immich Nest API Server"
|
||||
log ""
|
||||
export CI=1
|
||||
run_cmd pnpm --filter immich install
|
||||
|
||||
log "Starting Nest API Server"
|
||||
log ""
|
||||
cd "${IMMICH_WORKSPACE}/server" || (
|
||||
log "Immich workspace not found"
|
||||
log "Immich workspace not found"jj
|
||||
exit 1
|
||||
)
|
||||
|
||||
while true; do
|
||||
run_cmd node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch
|
||||
run_cmd pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch
|
||||
log "Nest API Server crashed with exit code $?. Respawning in 3s ..."
|
||||
sleep 3
|
||||
done
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
# shellcheck disable=SC1091
|
||||
source /immich-devcontainer/container-common.sh
|
||||
|
||||
export CI=1
|
||||
log "Preparing Immich Web Frontend"
|
||||
log ""
|
||||
run_cmd pnpm --filter @immich/sdk install
|
||||
run_cmd pnpm --filter @immich/sdk build
|
||||
run_cmd pnpm --filter immich-web install
|
||||
|
||||
log "Starting Immich Web Frontend"
|
||||
log ""
|
||||
cd "${IMMICH_WORKSPACE}/web" || (
|
||||
@@ -16,7 +23,7 @@ until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_
|
||||
done
|
||||
|
||||
while true; do
|
||||
run_cmd node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}"
|
||||
run_cmd pnpm --filter immich-web exec vite dev --host 0.0.0.0 --port "${DEV_PORT}"
|
||||
log "Web crashed with exit code $?. Respawning in 3s ..."
|
||||
sleep 3
|
||||
done
|
||||
|
||||
@@ -6,9 +6,6 @@ source /immich-devcontainer/container-common.sh
|
||||
log "Setting up Immich dev container..."
|
||||
fix_permissions
|
||||
|
||||
log "Installing npm dependencies (node_modules)..."
|
||||
install_dependencies
|
||||
|
||||
log "Setup complete, please wait while backend and frontend services automatically start"
|
||||
log
|
||||
log "If necessary, the services may be manually started using"
|
||||
|
||||
28
.github/package-lock.json
generated
vendored
28
.github/package-lock.json
generated
vendored
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": ".github",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"prettier": "^3.5.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
.github/workflows/cli.yml
vendored
25
.github/workflows/cli.yml
vendored
@@ -33,21 +33,24 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Prepare SDK
|
||||
run: npm ci --prefix ../open-api/typescript-sdk/
|
||||
- name: Build SDK
|
||||
run: npm run build --prefix ../open-api/typescript-sdk/
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
- name: Setup typescript-sdk
|
||||
run: pnpm install && pnpm run build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: pnpm publish
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
4
.github/workflows/close-duplicates.yml
vendored
4
.github/workflows/close-duplicates.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f issueId="$NODE_ID" \
|
||||
-f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \
|
||||
-f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
|
||||
-f query='
|
||||
mutation CommentAndCloseIssue($issueId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f discussionId="$NODE_ID" \
|
||||
-f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \
|
||||
-f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
|
||||
-f query='
|
||||
mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) {
|
||||
addDiscussionComment(input: {
|
||||
|
||||
15
.github/workflows/docs-build.yml
vendored
15
.github/workflows/docs-build.yml
vendored
@@ -55,21 +55,24 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './docs/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
- name: Run install
|
||||
run: pnpm install
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format
|
||||
run: pnpm format
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload build output
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
|
||||
4
.github/workflows/fix-format.yml
vendored
4
.github/workflows/fix-format.yml
vendored
@@ -32,8 +32,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Fix formatting
|
||||
run: make install-all && make format-all
|
||||
|
||||
13
.github/workflows/sdk.yml
vendored
13
.github/workflows/sdk.yml
vendored
@@ -20,18 +20,21 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: pnpm build
|
||||
- name: Publish
|
||||
run: npm publish
|
||||
run: pnpm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
459
.github/workflows/test.yml
vendored
459
.github/workflows/test.yml
vendored
@@ -4,13 +4,10 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
pre-job:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,7 +29,6 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- id: found_paths
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
@@ -58,11 +54,9 @@ jobs:
|
||||
- '.github/workflows/test.yml'
|
||||
.github:
|
||||
- '.github/**'
|
||||
|
||||
- name: Check if we should force jobs to run
|
||||
id: should_force
|
||||
run: echo "should_force=${{ steps.found_paths.outputs.workflow == 'true' || github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
server-unit-tests:
|
||||
name: Test & Lint Server
|
||||
needs: pre-job
|
||||
@@ -73,39 +67,33 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run package manager install
|
||||
run: pnpm install
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
run: pnpm lint
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run formatter
|
||||
run: npm run format
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run tsc
|
||||
run: npm run check
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run small tests & coverage
|
||||
run: npm test
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
cli-unit-tests:
|
||||
name: Unit Test CLI
|
||||
needs: pre-job
|
||||
@@ -116,43 +104,36 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./cli
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
run: pnpm install && pnpm run build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
run: pnpm install
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
run: pnpm lint
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run formatter
|
||||
run: npm run format
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run tsc
|
||||
run: npm run check
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run unit tests & coverage
|
||||
run: npm run test
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
cli-unit-tests-win:
|
||||
name: Unit Test CLI (Windows)
|
||||
needs: pre-job
|
||||
@@ -163,36 +144,31 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./cli
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
run: pnpm install --frozen-lockfile
|
||||
# Skip linter & formatter in Windows test.
|
||||
- name: Run tsc
|
||||
run: npm run check
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run unit tests & coverage
|
||||
run: npm run test
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
web-lint:
|
||||
name: Lint Web
|
||||
needs: pre-job
|
||||
@@ -203,39 +179,33 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./web
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Run pnpm install
|
||||
run: pnpm rebuild && pnpm install --frozen-lockfile
|
||||
- name: Run linter
|
||||
run: npm run lint:p
|
||||
run: pnpm lint:p
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run formatter
|
||||
run: npm run format
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run svelte checks
|
||||
run: npm run check:svelte
|
||||
run: pnpm check:svelte
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
web-unit-tests:
|
||||
name: Test Web
|
||||
needs: pre-job
|
||||
@@ -246,35 +216,30 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./web
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run tsc
|
||||
run: npm run check:typescript
|
||||
run: pnpm check:typescript
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run unit tests & coverage
|
||||
run: npm run test
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
i18n-tests:
|
||||
name: Test i18n
|
||||
needs: pre-job
|
||||
@@ -287,27 +252,24 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Install dependencies
|
||||
run: npm --prefix=web ci
|
||||
|
||||
run: pnpm --filter=immich-web install --frozen-lockfile
|
||||
- name: Format
|
||||
run: npm --prefix=web run format:i18n
|
||||
|
||||
run: pnpm --filter=immich-web format:i18n
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-files
|
||||
with:
|
||||
files: |
|
||||
i18n/**
|
||||
|
||||
- name: Verify files have not changed
|
||||
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||
env:
|
||||
@@ -316,7 +278,6 @@ jobs:
|
||||
echo "ERROR: i18n files not up to date!"
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
exit 1
|
||||
|
||||
e2e-tests-lint:
|
||||
name: End-to-End Lint
|
||||
needs: pre-job
|
||||
@@ -327,41 +288,35 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
run: pnpm lint
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run formatter
|
||||
run: npm run format
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run tsc
|
||||
run: npm run check
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
server-medium-tests:
|
||||
name: Medium Tests (Server)
|
||||
needs: pre-job
|
||||
@@ -372,27 +327,24 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run pnpm install
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
- name: Run medium tests
|
||||
run: npm run test:medium
|
||||
run: pnpm test:medium
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
e2e-tests-server-cli:
|
||||
name: End-to-End Tests (Server & CLI)
|
||||
needs: pre-job
|
||||
@@ -406,43 +358,41 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run setup web
|
||||
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
|
||||
working-directory: ./web
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run setup cli
|
||||
run: npm ci && npm run build
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./cli
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Docker build
|
||||
run: docker compose build
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run e2e tests (api & cli)
|
||||
run: npm run test
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
e2e-tests-web:
|
||||
name: End-to-End Tests (Web)
|
||||
needs: pre-job
|
||||
@@ -456,42 +406,36 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: npm ci && npm run build
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --only-shell
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Docker build
|
||||
run: docker compose build
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
- name: Run e2e tests (web)
|
||||
run: npx playwright test
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
success-check-e2e:
|
||||
name: End-to-End Tests Success
|
||||
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||
@@ -502,7 +446,6 @@ jobs:
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
mobile-unit-tests:
|
||||
name: Unit Test Mobile
|
||||
needs: pre-job
|
||||
@@ -514,21 +457,19 @@ jobs:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version-file: ./mobile/pubspec.yaml
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Generate translation file
|
||||
run: make translation
|
||||
working-directory: ./mobile
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./mobile
|
||||
run: flutter test -j 1
|
||||
|
||||
ml-unit-tests:
|
||||
name: Unit Test ML
|
||||
needs: pre-job
|
||||
@@ -543,7 +484,6 @@ jobs:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
@@ -566,7 +506,6 @@ jobs:
|
||||
- name: Run tests and coverage
|
||||
run: |
|
||||
uv run pytest --cov=immich_ml --cov-report term-missing
|
||||
|
||||
github-files-formatting:
|
||||
name: .github Files Formatting
|
||||
needs: pre-job
|
||||
@@ -577,27 +516,24 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./.github
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './.github/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run formatter
|
||||
run: npm run format
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
shellcheck:
|
||||
name: ShellCheck
|
||||
runs-on: ubuntu-latest
|
||||
@@ -607,15 +543,11 @@ jobs:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run ShellCheck
|
||||
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
|
||||
with:
|
||||
ignore_paths: >-
|
||||
**/open-api/**
|
||||
**/openapi**
|
||||
**/node_modules/**
|
||||
|
||||
**/open-api/** **/openapi** **/node_modules/**
|
||||
generated-api-up-to-date:
|
||||
name: OpenAPI Clients
|
||||
runs-on: ubuntu-latest
|
||||
@@ -626,23 +558,20 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Install server dependencies
|
||||
run: npm --prefix=server ci
|
||||
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
|
||||
- name: Build the app
|
||||
run: npm --prefix=server run build
|
||||
|
||||
run: pnpm --filter immich build
|
||||
- name: Run API generation
|
||||
run: make open-api
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-files
|
||||
@@ -651,7 +580,6 @@ jobs:
|
||||
mobile/openapi
|
||||
open-api/typescript-sdk
|
||||
open-api/immich-openapi-specs.json
|
||||
|
||||
- name: Verify files have not changed
|
||||
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||
env:
|
||||
@@ -660,7 +588,6 @@ jobs:
|
||||
echo "ERROR: Generated files not up to date!"
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
exit 1
|
||||
|
||||
sql-schema-up-to-date:
|
||||
name: SQL Schema Checks
|
||||
runs-on: ubuntu-latest
|
||||
@@ -674,45 +601,36 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: immich
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Install server dependencies
|
||||
run: npm ci
|
||||
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
- name: Build the app
|
||||
run: npm run build
|
||||
|
||||
run: pnpm build
|
||||
- name: Run existing migrations
|
||||
run: npm run migrations:run
|
||||
|
||||
run: pnpm migrations:run
|
||||
- name: Test npm run schema:reset command works
|
||||
run: npm run schema:reset
|
||||
|
||||
run: pnpm schema:reset
|
||||
- name: Generate new migrations
|
||||
continue-on-error: true
|
||||
run: npm run migrations:generate src/TestMigration
|
||||
|
||||
run: pnpm migrations:generate src/TestMigration
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-files
|
||||
@@ -728,19 +646,16 @@ jobs:
|
||||
echo "Changed files: ${CHANGED_FILES}"
|
||||
cat ./src/*-TestMigration.ts
|
||||
exit 1
|
||||
|
||||
- name: Run SQL generation
|
||||
run: npm run sync:sql
|
||||
run: pnpm sync:sql
|
||||
env:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/immich
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-sql-files
|
||||
with:
|
||||
files: |
|
||||
server/src/queries
|
||||
|
||||
- name: Verify SQL files have not changed
|
||||
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
|
||||
env:
|
||||
@@ -751,77 +666,77 @@ jobs:
|
||||
git diff
|
||||
exit 1
|
||||
|
||||
# mobile-integration-tests:
|
||||
# name: Run mobile end-to-end integration tests
|
||||
# runs-on: macos-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: actions/setup-java@v3
|
||||
# with:
|
||||
# distribution: 'zulu'
|
||||
# java-version: '12.x'
|
||||
# cache: 'gradle'
|
||||
# - name: Cache android SDK
|
||||
# uses: actions/cache@v3
|
||||
# id: android-sdk
|
||||
# with:
|
||||
# key: android-sdk
|
||||
# path: |
|
||||
# /usr/local/lib/android/
|
||||
# ~/.android
|
||||
# - name: Cache Gradle
|
||||
# uses: actions/cache@v3
|
||||
# with:
|
||||
# path: |
|
||||
# ./mobile/build/
|
||||
# ./mobile/android/.gradle/
|
||||
# key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
|
||||
# - name: Setup Android SDK
|
||||
# if: steps.android-sdk.outputs.cache-hit != 'true'
|
||||
# uses: android-actions/setup-android@v2
|
||||
# - name: AVD cache
|
||||
# uses: actions/cache@v3
|
||||
# id: avd-cache
|
||||
# with:
|
||||
# path: |
|
||||
# ~/.android/avd/*
|
||||
# ~/.android/adb*
|
||||
# key: avd-29
|
||||
# - name: create AVD and generate snapshot for caching
|
||||
# if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
# uses: reactivecircus/android-emulator-runner@v2.27.0
|
||||
# with:
|
||||
# working-directory: ./mobile
|
||||
# cores: 2
|
||||
# api-level: 29
|
||||
# arch: x86_64
|
||||
# profile: pixel
|
||||
# target: default
|
||||
# force-avd-creation: false
|
||||
# emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
# disable-animations: false
|
||||
# script: echo "Generated AVD snapshot for caching."
|
||||
# - name: Setup Flutter SDK
|
||||
# uses: subosito/flutter-action@v2
|
||||
# with:
|
||||
# channel: 'stable'
|
||||
# flutter-version: '3.7.3'
|
||||
# cache: true
|
||||
# - name: Run integration tests
|
||||
# uses: Wandalen/wretry.action@master
|
||||
# with:
|
||||
# action: reactivecircus/android-emulator-runner@v2.27.0
|
||||
# with: |
|
||||
# working-directory: ./mobile
|
||||
# cores: 2
|
||||
# api-level: 29
|
||||
# arch: x86_64
|
||||
# profile: pixel
|
||||
# target: default
|
||||
# force-avd-creation: false
|
||||
# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
# disable-animations: true
|
||||
# script: |
|
||||
# flutter pub get
|
||||
# flutter test integration_test
|
||||
# attempt_limit: 3
|
||||
# mobile-integration-tests:
|
||||
# name: Run mobile end-to-end integration tests
|
||||
# runs-on: macos-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: actions/setup-java@v3
|
||||
# with:
|
||||
# distribution: 'zulu'
|
||||
# java-version: '12.x'
|
||||
# cache: 'gradle'
|
||||
# - name: Cache android SDK
|
||||
# uses: actions/cache@v3
|
||||
# id: android-sdk
|
||||
# with:
|
||||
# key: android-sdk
|
||||
# path: |
|
||||
# /usr/local/lib/android/
|
||||
# ~/.android
|
||||
# - name: Cache Gradle
|
||||
# uses: actions/cache@v3
|
||||
# with:
|
||||
# path: |
|
||||
# ./mobile/build/
|
||||
# ./mobile/android/.gradle/
|
||||
# key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
|
||||
# - name: Setup Android SDK
|
||||
# if: steps.android-sdk.outputs.cache-hit != 'true'
|
||||
# uses: android-actions/setup-android@v2
|
||||
# - name: AVD cache
|
||||
# uses: actions/cache@v3
|
||||
# id: avd-cache
|
||||
# with:
|
||||
# path: |
|
||||
# ~/.android/avd/*
|
||||
# ~/.android/adb*
|
||||
# key: avd-29
|
||||
# - name: create AVD and generate snapshot for caching
|
||||
# if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
# uses: reactivecircus/android-emulator-runner@v2.27.0
|
||||
# with:
|
||||
# working-directory: ./mobile
|
||||
# cores: 2
|
||||
# api-level: 29
|
||||
# arch: x86_64
|
||||
# profile: pixel
|
||||
# target: default
|
||||
# force-avd-creation: false
|
||||
# emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
# disable-animations: false
|
||||
# script: echo "Generated AVD snapshot for caching."
|
||||
# - name: Setup Flutter SDK
|
||||
# uses: subosito/flutter-action@v2
|
||||
# with:
|
||||
# channel: 'stable'
|
||||
# flutter-version: '3.7.3'
|
||||
# cache: true
|
||||
# - name: Run integration tests
|
||||
# uses: Wandalen/wretry.action@master
|
||||
# with:
|
||||
# action: reactivecircus/android-emulator-runner@v2.27.0
|
||||
# with: |
|
||||
# working-directory: ./mobile
|
||||
# cores: 2
|
||||
# api-level: 29
|
||||
# arch: x86_64
|
||||
# profile: pixel
|
||||
# target: default
|
||||
# force-avd-creation: false
|
||||
# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
# disable-animations: true
|
||||
# script: |
|
||||
# flutter pub get
|
||||
# flutter test integration_test
|
||||
# attempt_limit: 3
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
**/.pnpm-store/**
|
||||
|
||||
docker/upload
|
||||
docker/library
|
||||
|
||||
39
.pnpmfile.cjs
Normal file
39
.pnpmfile.cjs
Normal file
@@ -0,0 +1,39 @@
|
||||
module.exports = {
|
||||
hooks: {
|
||||
readPackage: (pkg) => {
|
||||
if (!pkg.name) {
|
||||
return pkg;
|
||||
}
|
||||
switch (pkg.name) {
|
||||
case "exiftool-vendored":
|
||||
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
|
||||
// make exiftool-vendored.pl a regular dependency
|
||||
pkg.dependencies["exiftool-vendored.pl"] =
|
||||
pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||
delete pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||
}
|
||||
break;
|
||||
case "sharp":
|
||||
const optionalDeps = Object.keys(pkg.optionalDependencies).filter(
|
||||
(dep) => dep.startsWith("@img")
|
||||
);
|
||||
for (const dep of optionalDeps) {
|
||||
// remove all optionalDepdencies from sharp (they will be compiled from source), except:
|
||||
// include the precompiled musl version of sharp, for web/Dockerfile
|
||||
// include precompiled linux-x64 version of sharp, for server/Dockerfile, stage: web-prod
|
||||
// include precompiled linux-arm64 version of sharp, for server/Dockerfile, stage: web-prod
|
||||
if (
|
||||
dep.includes("musl") ||
|
||||
dep.includes("linux-x64") ||
|
||||
dep.includes("linux-arm64")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
delete pkg.optionalDependencies[dep];
|
||||
}
|
||||
break;
|
||||
}
|
||||
return pkg;
|
||||
},
|
||||
},
|
||||
};
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -56,7 +56,8 @@
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||
},
|
||||
"svelte.enable-ts-plugin": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
|
||||
74
Makefile
74
Makefile
@@ -8,7 +8,7 @@ dev-update:
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
|
||||
dev-scale:
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||
|
||||
dev-docs:
|
||||
npm --prefix docs run start
|
||||
@@ -43,7 +43,7 @@ open-api-typescript:
|
||||
cd ./open-api && bash ./bin/generate-open-api.sh typescript
|
||||
|
||||
sql:
|
||||
npm --prefix server run sync:sql
|
||||
pnpm --filter immich run sync:sql
|
||||
|
||||
attach-server:
|
||||
docker exec -it docker_immich-server_1 sh
|
||||
@@ -53,31 +53,40 @@ renovate:
|
||||
|
||||
MODULES = e2e server web cli sdk docs .github
|
||||
|
||||
# directory to package name mapping function
|
||||
# cli = @immich/cli
|
||||
# docs = documentation
|
||||
# e2e = immich-e2e
|
||||
# open-api/typescript-sdk = @immich/sdk
|
||||
# server = immich
|
||||
# web = immich-web
|
||||
map-package = $(subst sdk,@immich/sdk,$(subst cli,@immich/cli,$(subst docs,documentation,$(subst e2e,immich-e2e,$(subst server,immich,$(subst web,immich-web,$1))))))
|
||||
|
||||
audit-%:
|
||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
|
||||
pnpm --filter $(call map-package,$*) audit fix
|
||||
install-%:
|
||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i
|
||||
ci-%:
|
||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) ci
|
||||
pnpm --filter $(call map-package,$*) install $(if $(FROZEN),--frozen-lockfile) $(if $(OFFLINE),--offline)
|
||||
build-cli: build-sdk
|
||||
build-web: build-sdk
|
||||
build-%: install-%
|
||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
|
||||
pnpm --filter $(call map-package,$*) run build
|
||||
format-%:
|
||||
npm --prefix $* run format:fix
|
||||
pnpm --filter $(call map-package,$*) run format:fix
|
||||
lint-%:
|
||||
npm --prefix $* run lint:fix
|
||||
pnpm --filter $(call map-package,$*) run lint:fix
|
||||
lint-web:
|
||||
pnpm --filter $(call map-package,$*) run lint:p
|
||||
check-%:
|
||||
npm --prefix $* run check
|
||||
pnpm --filter $(call map-package,$*) run check
|
||||
check-web:
|
||||
npm --prefix web run check:typescript
|
||||
npm --prefix web run check:svelte
|
||||
pnpm --filter immich-web run check:typescript
|
||||
pnpm --filter immich-web run check:svelte
|
||||
test-%:
|
||||
npm --prefix $* run test
|
||||
pnpm --filter $(call map-package,$*) run test
|
||||
test-e2e:
|
||||
docker compose -f ./e2e/docker-compose.yml build
|
||||
npm --prefix e2e run test
|
||||
npm --prefix e2e run test:web
|
||||
pnpm --filter immich-e2e run test
|
||||
pnpm --filter immich-e2e run test:web
|
||||
test-medium:
|
||||
docker run \
|
||||
--rm \
|
||||
@@ -87,25 +96,36 @@ test-medium:
|
||||
-v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
|
||||
-e NODE_ENV=development \
|
||||
immich-server:latest \
|
||||
-c "npm ci && npm run test:medium -- --run"
|
||||
-c "pnpm test:medium -- --run"
|
||||
test-medium-dev:
|
||||
docker exec -it immich_server /bin/sh -c "npm run test:medium"
|
||||
docker exec -it immich_server /bin/sh -c "pnpm run test:medium"
|
||||
|
||||
build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ;
|
||||
install-all: $(foreach M,$(MODULES),install-$M) ;
|
||||
ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ;
|
||||
check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ;
|
||||
lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
|
||||
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
|
||||
audit-all: $(foreach M,$(MODULES),audit-$M) ;
|
||||
hygiene-all: lint-all format-all check-all sql audit-all;
|
||||
test-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),test-$M) ;
|
||||
install-all:
|
||||
pnpm -r --filter '!documentation' install
|
||||
|
||||
build-all: $(foreach M,$(filter-out e2e docs .github,$(MODULES)),build-$M) ;
|
||||
|
||||
check-all:
|
||||
pnpm -r --filter '!documentation' run "/^(check|check\:svelte|check\:typescript)$/"
|
||||
lint-all:
|
||||
pnpm -r --filter '!documentation' run lint:fix
|
||||
format-all:
|
||||
pnpm -r --filter '!documentation' run format:fix
|
||||
audit-all:
|
||||
pnpm -r --filter '!documentation' audit fix
|
||||
hygiene-all: audit-all
|
||||
pnpm -r --filter '!documentation' run "/(format:fix|check|check:svelte|check:typescript|sql)/"
|
||||
|
||||
test-all:
|
||||
pnpm -r --filter '!documentation' run "/^test/"
|
||||
|
||||
clean:
|
||||
find . -name "node_modules" -type d -prune -exec rm -rf {} +
|
||||
find . -name "dist" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name "build" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name "svelte-kit" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
|
||||
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
|
||||
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true
|
||||
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
|
||||
|
||||
WORKDIR /usr/src/open-api/typescript-sdk
|
||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||
RUN npm ci
|
||||
COPY open-api/typescript-sdk/ ./
|
||||
RUN npm run build
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY cli/package.json cli/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY cli .
|
||||
RUN npm run build
|
||||
COPY package* pnpm* .pnpmfile.cjs ./
|
||||
COPY ./cli ./cli/
|
||||
COPY ./open-api/typescript-sdk ./open-api/typescript-sdk/
|
||||
RUN corepack enable pnpm && \
|
||||
pnpm install --filter @immich/sdk --filter @immich/cli --frozen-lockfile && \
|
||||
pnpm --filter @immich/sdk build && \
|
||||
pnpm --filter @immich/cli build
|
||||
|
||||
WORKDIR /import
|
||||
|
||||
ENTRYPOINT ["node", "/usr/src/app/dist"]
|
||||
ENTRYPOINT ["node", "/usr/src/app/cli/dist"]
|
||||
|
||||
@@ -6,8 +6,10 @@ Please see the [Immich CLI documentation](https://immich.app/docs/features/comma
|
||||
|
||||
Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder:
|
||||
|
||||
$ npm install
|
||||
$ npm run build
|
||||
# if you don't have pnpm installed
|
||||
$ npm install -g pnpm
|
||||
$ pnpm install
|
||||
$ pnpm build
|
||||
|
||||
Then, to build the open-api client run the following in the open-api folder:
|
||||
|
||||
@@ -15,8 +17,10 @@ Then, to build the open-api client run the following in the open-api folder:
|
||||
|
||||
To run the Immich CLI from source, run the following in the cli folder:
|
||||
|
||||
$ npm install
|
||||
$ npm run build
|
||||
# if you don't have pnpm installed
|
||||
$ npm install -g pnpm
|
||||
$ pnpm install
|
||||
$ pnpm build
|
||||
$ ts-node .
|
||||
|
||||
You'll need ts-node, the easiest way to install it is to use npm:
|
||||
|
||||
4600
cli/package-lock.json
generated
4600
cli/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.77",
|
||||
"version": "2.2.79",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
@@ -21,17 +21,16 @@ services:
|
||||
# extends:
|
||||
# file: hwaccel.transcoding.yml
|
||||
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile
|
||||
target: dev
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ../server:/usr/src/app/server
|
||||
- ../open-api:/usr/src/app/open-api
|
||||
- ..:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}/photos:/data
|
||||
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
||||
- /usr/src/app/server/node_modules
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
@@ -58,8 +57,12 @@ services:
|
||||
- 9231:9231
|
||||
- 2283:2283
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
redis:
|
||||
condition: service_started
|
||||
database:
|
||||
condition: service_started
|
||||
init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
disable: false
|
||||
|
||||
@@ -68,6 +71,7 @@ services:
|
||||
image: immich-web-dev:latest
|
||||
# Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919
|
||||
# user: 0:0
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: web/Dockerfile
|
||||
@@ -78,18 +82,17 @@ services:
|
||||
- 3000:3000
|
||||
- 24678:24678
|
||||
volumes:
|
||||
- ../web:/usr/src/app/web
|
||||
- ../i18n:/usr/src/app/i18n
|
||||
- ../open-api/:/usr/src/app/open-api/
|
||||
# - ../../ui:/usr/ui
|
||||
- /usr/src/app/web/node_modules
|
||||
- ..:/usr/src/app
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 1048576
|
||||
hard: 1048576
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- immich-server
|
||||
immich-server:
|
||||
condition: service_started
|
||||
init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
@@ -117,7 +120,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
|
||||
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
@@ -157,6 +160,14 @@ services:
|
||||
# volumes:
|
||||
# - grafana-data:/var/lib/grafana
|
||||
|
||||
init:
|
||||
container_name: init
|
||||
image: busybox
|
||||
env_file:
|
||||
- .env
|
||||
user: 0:0
|
||||
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
||||
|
||||
volumes:
|
||||
model-cache:
|
||||
prometheus-data:
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
|
||||
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
|
||||
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
build/
|
||||
.docusaurus/
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
@@ -5,7 +5,7 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ npm install
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
@@ -204,7 +204,7 @@ When the Dev Container starts, it automatically:
|
||||
|
||||
1. **Runs post-create script** (`container-server-post-create.sh`):
|
||||
- Adjusts file permissions for the `node` user
|
||||
- Installs dependencies: `npm install` in all packages
|
||||
- Installs dependencies: `pnpm install` in all packages
|
||||
- Builds TypeScript SDK: `npm run build` in `open-api/typescript-sdk`
|
||||
|
||||
2. **Starts development servers** via VS Code tasks:
|
||||
|
||||
@@ -56,7 +56,7 @@ If you only want to do web development connected to an existing, remote backend,
|
||||
|
||||
1. Build the Immich SDK - `cd open-api/typescript-sdk && npm i && npm run build && cd -`
|
||||
2. Enter the web directory - `cd web/`
|
||||
3. Install web dependencies - `npm i`
|
||||
3. Install web dependencies - `pnpm i`
|
||||
4. Start the web development server
|
||||
|
||||
```bash
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
### Unit tests
|
||||
|
||||
Unit are run by calling `npm run test` from the `server/` directory.
|
||||
You need to run `npm install` (in `server/`) before _once_.
|
||||
You need to run `pnpm install` (in `server/`) before _once_.
|
||||
|
||||
### End to end tests
|
||||
|
||||
|
||||
@@ -27,3 +27,102 @@ docker image prune
|
||||
[watchtower]: https://containrrr.dev/watchtower/
|
||||
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
|
||||
[releases]: https://github.com/immich-app/immich/releases
|
||||
|
||||
## Migrating to VectorChord
|
||||
|
||||
:::info
|
||||
If you deploy Immich using Docker Compose, see `ghcr.io/immich-app/postgres` in the `docker-compose.yml` file and have not explicitly set the `DB_VECTOR_EXTENSION` environmental variable, your Immich database is already using VectorChord and this section does not apply to you.
|
||||
:::
|
||||
|
||||
:::important
|
||||
If you do not deploy Immich using Docker Compose and see a deprecation warning for pgvecto.rs on server startup, you should refer to the maintainers of the Immich distribution for guidance (if using a turnkey solution) or adapt the instructions for your specific setup.
|
||||
:::
|
||||
|
||||
Immich has migrated off of the deprecated pgvecto.rs database extension to its successor, [VectorChord](https://github.com/tensorchord/VectorChord), which comes with performance improvements in almost every aspect. This section will guide you on how to make this change in a Docker Compose setup.
|
||||
|
||||
Before making any changes, please [back up your database](/docs/administration/backup-and-restore). While every effort has been made to make this migration as smooth as possible, there’s always a chance that something can go wrong.
|
||||
|
||||
After making a backup, please modify your `docker-compose.yml` file with the following information.
|
||||
|
||||
```diff
|
||||
[...]
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
- image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
|
||||
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
POSTGRES_INITDB_ARGS: '--data-checksums'
|
||||
+ # Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
|
||||
+ # DB_STORAGE_TYPE: 'HDD'
|
||||
volumes:
|
||||
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
|
||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||
- healthcheck:
|
||||
- test: >-
|
||||
- pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
|
||||
- Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
|
||||
- --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
|
||||
- echo "checksum failure count is $$Chksum";
|
||||
- [ "$$Chksum" = '0' ] || exit 1
|
||||
- interval: 5m
|
||||
- start_interval: 30s
|
||||
- start_period: 5m
|
||||
- command: >-
|
||||
- postgres
|
||||
- -c shared_preload_libraries=vectors.so
|
||||
- -c 'search_path="$$user", public, vectors'
|
||||
- -c logging_collector=on
|
||||
- -c max_wal_size=2GB
|
||||
- -c shared_buffers=512MB
|
||||
- -c wal_compression=on
|
||||
+ shm_size: 128mb
|
||||
restart: always
|
||||
|
||||
[...]
|
||||
```
|
||||
|
||||
:::important
|
||||
If you deviated from the defaults of pg14 or pgvectors0.2.0, you must adjust the pg major version and pgvecto.rs version. If you are still using the default `docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0` image, you can just follow the changes above. For example, if the previous image is `docker.io/tensorchord/pgvecto-rs:pg16-v0.3.0`, the new image should be `ghcr.io/immich-app/postgres:16-vectorchord0.3.0-pgvectors0.3.0` instead of the image specified in the diff.
|
||||
:::
|
||||
|
||||
After making these changes, you can start Immich as normal. Immich will make some changes to the DB during startup, which can take seconds to minutes to finish, depending on hardware and library size. In particular, it’s normal for the server logs to be seemingly stuck at `Reindexing clip_index` and `Reindexing face_index`for some time if you have over 100k assets in Immich and/or Immich is on a relatively weak server. If you see these logs and there are no errors, just give it time.
|
||||
|
||||
:::danger
|
||||
After switching to VectorChord, you should not downgrade Immich below 1.133.0.
|
||||
:::
|
||||
|
||||
Please don’t hesitate to contact us on [GitHub](https://github.com/immich-app/immich/discussions) or [Discord](https://discord.immich.app/) if you encounter migration issues.
|
||||
|
||||
### VectorChord FAQ
|
||||
|
||||
#### I have a separate PostgreSQL instance shared with multiple services. How can I switch to VectorChord?
|
||||
|
||||
Please see the [standalone PostgreSQL documentation](/docs/administration/postgres-standalone#migrating-to-vectorchord) for migration instructions. The migration path will be different depending on whether you’re currently using pgvecto.rs or pgvector, as well as whether Immich has superuser DB permissions.
|
||||
|
||||
#### Why are so many lines removed from the `docker-compose.yml` file? Does this mean the health check is removed?
|
||||
|
||||
These lines are now incorporated into the image itself along with some additional tuning.
|
||||
|
||||
#### What does this change mean for my existing DB backups?
|
||||
|
||||
The new DB image includes pgvector and pgvecto.rs in addition to VectorChord, so you can use this image to restore from existing backups that used either of these extensions. However, backups made after switching to VectorChord require an image containing VectorChord to restore successfully.
|
||||
|
||||
#### Do I still need pgvecto.rs installed after migrating to VectorChord?
|
||||
|
||||
pgvecto.rs only needs to be available during the migration, or if you need to restore from a backup that used pgvecto.rs. For a leaner DB and a smaller image, you can optionally switch to an image variant that doesn’t have pgvecto.rs installed after you’ve performed the migration and started Immich: `ghcr.io/immich-app/postgres:14-vectorchord0.4.3`, changing the PostgreSQL version as appropriate.
|
||||
|
||||
#### Why does it matter whether my database is on an SSD or an HDD?
|
||||
|
||||
These storage mediums have different performance characteristics. As a result, the optimal settings for an SSD are not the same as those for an HDD. Either configuration is compatible with SSD and HDD, but using the right configuration will make Immich snappier. As a general tip, we recommend users store the database on an SSD whenever possible.
|
||||
|
||||
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
|
||||
|
||||
It’s a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
|
||||
|
||||
#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
|
||||
|
||||
VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.
|
||||
|
||||
20545
docs/package-lock.json
generated
20545
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
8
docs/static/archived-versions.json
vendored
8
docs/static/archived-versions.json
vendored
@@ -1,4 +1,12 @@
|
||||
[
|
||||
{
|
||||
"label": "v1.138.1",
|
||||
"url": "https://v1.138.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.138.0",
|
||||
"url": "https://v1.138.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.137.3",
|
||||
"url": "https://v1.137.3.archive.immich.app"
|
||||
|
||||
1
e2e/.gitignore
vendored
1
e2e/.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/dist
|
||||
|
||||
7419
e2e/package-lock.json
generated
7419
e2e/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.137.3",
|
||||
"version": "1.138.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -79,7 +79,7 @@ export const tempDir = tmpdir();
|
||||
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
|
||||
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||
export const immichCli = (args: string[]) =>
|
||||
executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]).promise;
|
||||
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
||||
export const immichAdmin = (args: string[]) =>
|
||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
||||
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
||||
|
||||
16
i18n/en.json
16
i18n/en.json
@@ -28,6 +28,9 @@
|
||||
"add_to_album": "Add to album",
|
||||
"add_to_album_bottom_sheet_added": "Added to {album}",
|
||||
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
|
||||
"add_to_album_toggle": "Toggle selection for {album}",
|
||||
"add_to_albums": "Add to albums",
|
||||
"add_to_albums_count": "Add to albums ({count})",
|
||||
"add_to_shared_album": "Add to shared album",
|
||||
"add_url": "Add URL",
|
||||
"added_to_archive": "Added to archive",
|
||||
@@ -497,7 +500,9 @@
|
||||
"assets": "Assets",
|
||||
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
|
||||
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
||||
"assets_added_to_albums_count": "Added {assetTotal} assets to {albumTotal} albums",
|
||||
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
||||
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
|
||||
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
||||
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
|
||||
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
|
||||
@@ -514,6 +519,7 @@
|
||||
"assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}",
|
||||
"assets_trashed_from_server": "{count} asset(s) trashed from the Immich server",
|
||||
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album",
|
||||
"assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} already part of the albums",
|
||||
"authorized_devices": "Authorized Devices",
|
||||
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
|
||||
"automatic_endpoint_switching_title": "Automatic URL switching",
|
||||
@@ -1056,6 +1062,7 @@
|
||||
"filter_people": "Filter people",
|
||||
"filter_places": "Filter places",
|
||||
"find_them_fast": "Find them fast by name with search",
|
||||
"first": "First",
|
||||
"fix_incorrect_match": "Fix incorrect match",
|
||||
"folder": "Folder",
|
||||
"folder_not_found": "Folder not found",
|
||||
@@ -1177,6 +1184,7 @@
|
||||
"language_search_hint": "Search languages...",
|
||||
"language_setting_description": "Select your preferred language",
|
||||
"large_files": "Large Files",
|
||||
"last": "Last",
|
||||
"last_seen": "Last seen",
|
||||
"latest_version": "Latest Version",
|
||||
"latitude": "Latitude",
|
||||
@@ -1195,6 +1203,7 @@
|
||||
"library_page_sort_title": "Album title",
|
||||
"licenses": "Licenses",
|
||||
"light": "Light",
|
||||
"like": "Like",
|
||||
"like_deleted": "Like deleted",
|
||||
"link_motion_video": "Link motion video",
|
||||
"link_to_oauth": "Link to OAuth",
|
||||
@@ -1457,9 +1466,9 @@
|
||||
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
|
||||
"permission_onboarding_request": "Immich requires permission to view your photos and videos.",
|
||||
"person": "Person",
|
||||
"person_age_months": "{months} months old",
|
||||
"person_age_year_months": "1 year, {months} months old",
|
||||
"person_age_years": "{years} years old",
|
||||
"person_age_months": "{months, plural, one {# month} other {# months}} old",
|
||||
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
|
||||
"person_age_years": "{years, plural, other {# years}} old",
|
||||
"person_birthdate": "Born on {date}",
|
||||
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
|
||||
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
|
||||
@@ -1856,6 +1865,7 @@
|
||||
"sort_created": "Date created",
|
||||
"sort_items": "Number of items",
|
||||
"sort_modified": "Date modified",
|
||||
"sort_newest": "Newest photo",
|
||||
"sort_oldest": "Oldest photo",
|
||||
"sort_people_by_similarity": "Sort people by similarity",
|
||||
"sort_recent": "Most recent photo",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.11-bookworm@sha256:ce3b954c9285a7a145cba620bae03db836ab890b6b9e0d05a3ca522ea00dfbc9 AS builder-cpu
|
||||
FROM python:3.11-bookworm@sha256:c642d5dfaf9115a12086785f23008558ae2e13bcd0c4794536340bcb777a4381 AS builder-cpu
|
||||
|
||||
FROM builder-cpu AS builder-openvino
|
||||
|
||||
@@ -59,7 +59,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends g++
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:9653efd4380d5a0e5511e337dcfc3b8ba5bc4e6ea7fa3be7716598261d5503fa /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:f64ad69940b634e75d2e4d799eb5238066c5eeda49f76e782d4873c3d014ea33 /uv /uvx /bin/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
@@ -68,11 +68,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
|
||||
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
||||
fi
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-cpu
|
||||
FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 AS prod-cpu
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-openvino
|
||||
FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
|
||||
@@ -36,7 +36,7 @@ def to_numpy(img: Image.Image) -> NDArray[np.float32]:
|
||||
def normalize(
|
||||
img: NDArray[np.float32], mean: float | NDArray[np.float32], std: float | NDArray[np.float32]
|
||||
) -> NDArray[np.float32]:
|
||||
return np.divide(img - mean, std, dtype=np.float32)
|
||||
return (img - mean) / std
|
||||
|
||||
|
||||
def get_pil_resampling(resample: str) -> Image.Resampling:
|
||||
@@ -58,11 +58,13 @@ def decode_pil(image_bytes: bytes | IO[bytes] | Image.Image) -> Image.Image:
|
||||
|
||||
|
||||
def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[np.uint8]:
|
||||
if isinstance(image_bytes, bytes):
|
||||
image_bytes = decode_pil(image_bytes) # pillow is much faster than cv2
|
||||
if isinstance(image_bytes, Image.Image):
|
||||
return pil_to_cv2(image_bytes)
|
||||
return image_bytes
|
||||
match image_bytes:
|
||||
case bytes() | memoryview() | bytearray():
|
||||
return pil_to_cv2(decode_pil(image_bytes)) # pillow is much faster than cv2
|
||||
case Image.Image():
|
||||
return pil_to_cv2(image_bytes)
|
||||
case _:
|
||||
return image_bytes
|
||||
|
||||
|
||||
def clean_text(text: str, canonicalize: bool = False) -> str:
|
||||
|
||||
@@ -112,8 +112,4 @@ def has_profiling(obj: Any) -> TypeGuard[HasProfiling]:
|
||||
return hasattr(obj, "profiling") and isinstance(obj.profiling, dict)
|
||||
|
||||
|
||||
def is_ndarray(obj: Any, dtype: "type[np._DTypeScalar_co]") -> "TypeGuard[npt.NDArray[np._DTypeScalar_co]]":
|
||||
return isinstance(obj, np.ndarray) and obj.dtype == dtype
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"gunicorn>=21.1.0",
|
||||
"huggingface-hub>=0.20.1,<1.0",
|
||||
"insightface>=0.7.3,<1.0",
|
||||
"numpy<2",
|
||||
"opencv-python-headless>=4.7.0.72,<5.0",
|
||||
"orjson>=3.9.5",
|
||||
"pillow>=9.5.0,<11.0",
|
||||
|
||||
773
machine-learning/uv.lock
generated
773
machine-learning/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
|
||||
cmake_minimum_required(VERSION 3.10.2)
|
||||
project("native_buffer")
|
||||
|
||||
add_library(native_buffer SHARED
|
||||
src/main/cpp/native_buffer.c)
|
||||
|
||||
find_library(log-lib log)
|
||||
|
||||
target_link_libraries(native_buffer ${log-lib})
|
||||
@@ -83,12 +83,6 @@ android {
|
||||
}
|
||||
}
|
||||
namespace 'app.alextran.immich'
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path "CMakeLists.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_allocateNative(
|
||||
JNIEnv *env, jclass clazz, jint size)
|
||||
{
|
||||
void *ptr = malloc(size);
|
||||
return (jlong)ptr;
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_allocateNative(
|
||||
JNIEnv *env, jclass clazz, jint size)
|
||||
{
|
||||
void *ptr = malloc(size);
|
||||
return (jlong)ptr;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_freeNative(
|
||||
JNIEnv *env, jclass clazz, jlong address)
|
||||
{
|
||||
if (address != 0)
|
||||
{
|
||||
free((void *)address);
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_freeNative(
|
||||
JNIEnv *env, jclass clazz, jlong address)
|
||||
{
|
||||
if (address != 0)
|
||||
{
|
||||
free((void *)address);
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapAsBuffer(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint capacity)
|
||||
{
|
||||
return (*env)->NewDirectByteBuffer(env, (void*)address, capacity);
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_app_alextran_immich_images_ThumbnailsImpl_wrapAsBuffer(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint capacity)
|
||||
{
|
||||
return (*env)->NewDirectByteBuffer(env, (void*)address, capacity);
|
||||
}
|
||||
@@ -1,20 +1,7 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import com.bumptech.glide.GlideBuilder
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter
|
||||
import com.bumptech.glide.load.engine.cache.DiskCacheAdapter
|
||||
import com.bumptech.glide.load.engine.cache.MemoryCacheAdapter
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
|
||||
@GlideModule
|
||||
class AppGlideModule : AppGlideModule() {
|
||||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||
super.applyOptions(context, builder)
|
||||
// disable caching as this is already done on the Flutter side
|
||||
builder.setMemoryCache(MemoryCacheAdapter())
|
||||
builder.setDiskCache(DiskCacheAdapter.Factory())
|
||||
builder.setBitmapPool(BitmapPoolAdapter())
|
||||
}
|
||||
}
|
||||
class AppGlideModule : AppGlideModule()
|
||||
@@ -2,8 +2,7 @@ package app.alextran.immich
|
||||
|
||||
import android.os.Build
|
||||
import android.os.ext.SdkExtensions
|
||||
import app.alextran.immich.images.ThumbnailApi
|
||||
import app.alextran.immich.images.ThumbnailsImpl
|
||||
import androidx.annotation.NonNull
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
@@ -11,7 +10,7 @@ import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
@@ -24,6 +23,5 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
NativeSyncApiImpl30(this)
|
||||
}
|
||||
NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl)
|
||||
ThumbnailApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ThumbnailsImpl(this))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.images
|
||||
|
||||
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 ThumbnailsPigeonUtils {
|
||||
|
||||
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
|
||||
) : Throwable()
|
||||
private open class ThumbnailsPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return super.readValueOfType(type, buffer)
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface ThumbnailApi {
|
||||
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, callback: (Result<Map<String, Long>>) -> Unit)
|
||||
fun cancelImageRequest(requestId: Long)
|
||||
|
||||
companion object {
|
||||
/** The codec used by ThumbnailApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
ThumbnailsPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val requestIdArg = args[1] as Long
|
||||
val widthArg = args[2] as Long
|
||||
val heightArg = args[3] as Long
|
||||
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg) { result: Result<Map<String, Long>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val requestIdArg = args[0] as Long
|
||||
val wrapped: List<Any?> = try {
|
||||
api.cancelImageRequest(requestIdArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
ThumbnailsPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package app.alextran.immich.images
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Images
|
||||
import android.provider.MediaStore.Video
|
||||
import android.util.Size
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.*
|
||||
import java.util.concurrent.Executors
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import java.util.HashMap
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.Future
|
||||
|
||||
data class Request(
|
||||
val requestId: Long,
|
||||
val taskFuture: Future<*>,
|
||||
val cancellationSignal: CancellationSignal,
|
||||
val callback: (Result<Map<String, Long>>) -> Unit
|
||||
)
|
||||
|
||||
class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
private val resolver: ContentResolver = ctx.contentResolver
|
||||
private val threadPool =
|
||||
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() / 2 + 1)
|
||||
private val requestMap = HashMap<Long, Request>()
|
||||
|
||||
companion object {
|
||||
val PROJECTION = arrayOf(
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE,
|
||||
)
|
||||
const val SELECTION = "${MediaStore.MediaColumns._ID} = ?"
|
||||
val URI: Uri = MediaStore.Files.getContentUri("external")
|
||||
|
||||
const val MEDIA_TYPE_IMAGE = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
|
||||
const val MEDIA_TYPE_VIDEO = MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO
|
||||
val CANCELLED = Result.success<Map<String, Long>>(mapOf())
|
||||
val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
|
||||
|
||||
init {
|
||||
System.loadLibrary("native_buffer")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
external fun allocateNative(size: Int): Long
|
||||
|
||||
@JvmStatic
|
||||
external fun freeNative(pointer: Long)
|
||||
|
||||
@JvmStatic
|
||||
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
|
||||
}
|
||||
|
||||
override fun requestImage(
|
||||
assetId: String,
|
||||
requestId: Long,
|
||||
width: Long,
|
||||
height: Long,
|
||||
callback: (Result<Map<String, Long>>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
val task = threadPool.submit {
|
||||
try {
|
||||
getThumbnailBufferInternal(assetId, width, height, callback, signal)
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is OperationCanceledException -> callback(CANCELLED)
|
||||
is CancellationException -> callback(CANCELLED)
|
||||
else -> callback(Result.failure(e))
|
||||
}
|
||||
} finally {
|
||||
requestMap.remove(requestId)
|
||||
}
|
||||
}
|
||||
requestMap[requestId] = Request(requestId, task, signal, callback)
|
||||
}
|
||||
|
||||
override fun cancelImageRequest(requestId: Long) {
|
||||
val request = requestMap.remove(requestId) ?: return
|
||||
request.taskFuture.cancel(false)
|
||||
request.cancellationSignal.cancel()
|
||||
if (request.taskFuture.isCancelled) {
|
||||
request.callback(CANCELLED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getThumbnailBufferInternal(
|
||||
assetId: String,
|
||||
width: Long,
|
||||
height: Long,
|
||||
callback: (Result<Map<String, Long>>) -> Unit,
|
||||
signal: CancellationSignal
|
||||
) {
|
||||
signal.throwIfCanceled()
|
||||
val targetWidth = width.toInt()
|
||||
val targetHeight = height.toInt()
|
||||
val id = assetId.toLong()
|
||||
|
||||
val cursor = resolver.query(URI, PROJECTION, SELECTION, arrayOf(assetId), null)
|
||||
?: return callback(Result.failure(RuntimeException("Asset not found")))
|
||||
|
||||
signal.throwIfCanceled()
|
||||
cursor.use { c ->
|
||||
if (!c.moveToNext()) {
|
||||
return callback(Result.failure(RuntimeException("Asset not found")))
|
||||
}
|
||||
|
||||
val mediaType = c.getInt(1)
|
||||
val bitmap = when (mediaType) {
|
||||
MEDIA_TYPE_IMAGE -> decodeImage(id, targetWidth, targetHeight, signal)
|
||||
MEDIA_TYPE_VIDEO -> decodeVideoThumbnail(id, targetWidth, targetHeight, signal)
|
||||
else -> return callback(Result.failure(RuntimeException("Unsupported media type")))
|
||||
}
|
||||
|
||||
processBitmap(bitmap, callback, signal)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processBitmap(
|
||||
bitmap: Bitmap,
|
||||
callback: (Result<Map<String, Long>>) -> Unit,
|
||||
signal: CancellationSignal
|
||||
) {
|
||||
signal.throwIfCanceled()
|
||||
val actualWidth = bitmap.width
|
||||
val actualHeight = bitmap.height
|
||||
|
||||
val size = actualWidth * actualHeight * 4
|
||||
val pointer = allocateNative(size)
|
||||
|
||||
try {
|
||||
signal.throwIfCanceled()
|
||||
val buffer = wrapAsBuffer(pointer, size)
|
||||
bitmap.copyPixelsToBuffer(buffer)
|
||||
signal.throwIfCanceled()
|
||||
val res = mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to actualWidth.toLong(),
|
||||
"height" to actualHeight.toLong()
|
||||
)
|
||||
callback(Result.success(res))
|
||||
} catch (e: Exception) {
|
||||
freeNative(pointer)
|
||||
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeImage(
|
||||
id: Long,
|
||||
targetWidth: Int,
|
||||
targetHeight: Int,
|
||||
signal: CancellationSignal
|
||||
): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
if (targetHeight > 768 || targetWidth > 768) {
|
||||
return decodeSource(uri, targetWidth, targetHeight, signal)
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
|
||||
} else {
|
||||
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeVideoThumbnail(
|
||||
id: Long,
|
||||
targetWidth: Int,
|
||||
targetHeight: Int,
|
||||
signal: CancellationSignal
|
||||
): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
|
||||
} else {
|
||||
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeSource(
|
||||
uri: Uri,
|
||||
targetWidth: Int,
|
||||
targetHeight: Int,
|
||||
signal: CancellationSignal
|
||||
): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val source = ImageDecoder.createSource(resolver, uri)
|
||||
signal.throwIfCanceled()
|
||||
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||
val sampleSize =
|
||||
getSampleSize(info.size.width, info.size.height, targetWidth, targetHeight)
|
||||
decoder.setTargetSampleSize(sampleSize)
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||
}
|
||||
} else {
|
||||
val ref = Glide.with(ctx)
|
||||
.asBitmap()
|
||||
.priority(Priority.IMMEDIATE)
|
||||
.load(uri)
|
||||
.disallowHardwareConfig()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||
.submit(targetWidth, targetHeight)
|
||||
signal.setOnCancelListener { Glide.with(ctx).clear(ref) }
|
||||
ref.get()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSampleSize(fullWidth: Int, fullHeight: Int, reqWidth: Int, reqHeight: Int): Int {
|
||||
return 1 shl max(
|
||||
0, floor(
|
||||
min(
|
||||
log2(fullWidth / (2.0 * reqWidth)),
|
||||
log2(fullHeight / (2.0 * reqHeight)),
|
||||
)
|
||||
).toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3002,
|
||||
"android.injected.version.name" => "1.137.3",
|
||||
"android.injected.version.code" => 3004,
|
||||
"android.injected.version.name" => "1.138.1",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
|
||||
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
|
||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
|
||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -104,8 +102,6 @@
|
||||
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
|
||||
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
|
||||
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -247,7 +243,6 @@
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
FED3B1952E253E9B0030FD97 /* Images */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@@ -263,15 +258,6 @@
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FED3B1952E253E9B0030FD97 /* Images */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
|
||||
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
|
||||
);
|
||||
path = Images;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -537,8 +523,6 @@
|
||||
files = (
|
||||
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
|
||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
|
||||
);
|
||||
|
||||
@@ -25,7 +25,6 @@ import UIKit
|
||||
|
||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||
NativeSyncApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: NativeSyncApiImpl())
|
||||
ThumbnailApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ThumbnailApiImpl())
|
||||
|
||||
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
||||
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import Flutter
|
||||
#elseif os(macOS)
|
||||
import FlutterMacOS
|
||||
#else
|
||||
#error("Unsupported platform.")
|
||||
#endif
|
||||
|
||||
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||
return [result]
|
||||
}
|
||||
|
||||
private func wrapError(_ error: Any) -> [Any?] {
|
||||
if let pigeonError = error as? PigeonError {
|
||||
return [
|
||||
pigeonError.code,
|
||||
pigeonError.message,
|
||||
pigeonError.details,
|
||||
]
|
||||
}
|
||||
if let flutterError = error as? FlutterError {
|
||||
return [
|
||||
flutterError.code,
|
||||
flutterError.message,
|
||||
flutterError.details,
|
||||
]
|
||||
}
|
||||
return [
|
||||
"\(error)",
|
||||
"\(type(of: error))",
|
||||
"Stacktrace: \(Thread.callStackSymbols)",
|
||||
]
|
||||
}
|
||||
|
||||
private func isNullish(_ value: Any?) -> Bool {
|
||||
return value is NSNull || value == nil
|
||||
}
|
||||
|
||||
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||
if value is NSNull { return nil }
|
||||
return value as! T?
|
||||
}
|
||||
|
||||
|
||||
private class ThumbnailsPigeonCodecReader: FlutterStandardReader {
|
||||
}
|
||||
|
||||
private class ThumbnailsPigeonCodecWriter: FlutterStandardWriter {
|
||||
}
|
||||
|
||||
private class ThumbnailsPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||
override func reader(with data: Data) -> FlutterStandardReader {
|
||||
return ThumbnailsPigeonCodecReader(data: data)
|
||||
}
|
||||
|
||||
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
|
||||
return ThumbnailsPigeonCodecWriter(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
static let shared = ThumbnailsPigeonCodec(readerWriter: ThumbnailsPigeonCodecReaderWriter())
|
||||
}
|
||||
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol ThumbnailApi {
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], Error>) -> Void)
|
||||
func cancelImageRequest(requestId: Int64) throws
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class ThumbnailApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { ThumbnailsPigeonCodec.shared }
|
||||
/// Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
requestImageChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let requestIdArg = args[1] as! Int64
|
||||
let widthArg = args[2] as! Int64
|
||||
let heightArg = args[3] as! Int64
|
||||
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
requestImageChannel.setMessageHandler(nil)
|
||||
}
|
||||
let cancelImageRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
cancelImageRequestChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let requestIdArg = args[0] as! Int64
|
||||
do {
|
||||
try api.cancelImageRequest(requestId: requestIdArg)
|
||||
reply(wrapResult(nil))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cancelImageRequestChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import CryptoKit
|
||||
import Flutter
|
||||
import MobileCoreServices
|
||||
import Photos
|
||||
|
||||
class Request {
|
||||
weak var workItem: DispatchWorkItem?
|
||||
var isCancelled = false
|
||||
let callback: (Result<[String: Int64], any Error>) -> Void
|
||||
|
||||
init(callback: @escaping (Result<[String: Int64], any Error>) -> Void) {
|
||||
self.callback = callback
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailApiImpl: ThumbnailApi {
|
||||
private static let imageManager = PHImageManager.default()
|
||||
private static let fetchOptions = {
|
||||
let fetchOptions = PHFetchOptions()
|
||||
fetchOptions.fetchLimit = 1
|
||||
fetchOptions.wantsIncrementalChangeDetails = false
|
||||
return fetchOptions
|
||||
}()
|
||||
private static let requestOptions = {
|
||||
let requestOptions = PHImageRequestOptions()
|
||||
requestOptions.isNetworkAccessAllowed = true
|
||||
requestOptions.deliveryMode = .highQualityFormat
|
||||
requestOptions.resizeMode = .fast
|
||||
requestOptions.isSynchronous = true
|
||||
requestOptions.version = .current
|
||||
return requestOptions
|
||||
}()
|
||||
|
||||
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
|
||||
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
|
||||
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
|
||||
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
|
||||
|
||||
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
|
||||
private static var requests = [Int64: Request]()
|
||||
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
|
||||
private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
|
||||
private static let assetCache = {
|
||||
let assetCache = NSCache<NSString, PHAsset>()
|
||||
assetCache.countLimit = 10000
|
||||
return assetCache
|
||||
}()
|
||||
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
|
||||
let request = Request(callback: completion)
|
||||
let item = DispatchWorkItem {
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
Self.concurrencySemaphore.wait()
|
||||
defer {
|
||||
Self.concurrencySemaphore.signal()
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let asset = Self.requestAsset(assetId: assetId)
|
||||
else {
|
||||
Self.removeRequest(requestId: requestId)
|
||||
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
|
||||
return
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
var image: UIImage?
|
||||
Self.imageManager.requestImage(
|
||||
for: asset,
|
||||
targetSize: CGSize(width: Double(width), height: Double(height)),
|
||||
contentMode: .aspectFit,
|
||||
options: Self.requestOptions,
|
||||
resultHandler: { (_image, info) -> Void in
|
||||
image = _image
|
||||
}
|
||||
)
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let image = image,
|
||||
let cgImage = image.cgImage else {
|
||||
Self.removeRequest(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
let pointer = UnsafeMutableRawPointer.allocate(
|
||||
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
|
||||
alignment: MemoryLayout<UInt8>.alignment
|
||||
)
|
||||
|
||||
if request.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
guard let context = CGContext(
|
||||
data: pointer,
|
||||
width: cgImage.width,
|
||||
height: cgImage.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: cgImage.width * 4,
|
||||
space: Self.rgbColorSpace,
|
||||
bitmapInfo: Self.bitmapInfo
|
||||
) else {
|
||||
pointer.deallocate()
|
||||
Self.removeRequest(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
context.interpolationQuality = .none
|
||||
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
|
||||
|
||||
if request.isCancelled {
|
||||
pointer.deallocate()
|
||||
return completion(Self.cancelledResult)
|
||||
}
|
||||
|
||||
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
|
||||
Self.removeRequest(requestId: requestId)
|
||||
}
|
||||
|
||||
request.workItem = item
|
||||
Self.addRequest(requestId: requestId, request: request)
|
||||
Self.processingQueue.async(execute: item)
|
||||
}
|
||||
|
||||
func cancelImageRequest(requestId: Int64) {
|
||||
Self.cancelRequest(requestId: requestId)
|
||||
}
|
||||
|
||||
private static func addRequest(requestId: Int64, request: Request) -> Void {
|
||||
requestQueue.sync { requests[requestId] = request }
|
||||
}
|
||||
|
||||
private static func removeRequest(requestId: Int64) -> Void {
|
||||
requestQueue.sync { requests[requestId] = nil }
|
||||
}
|
||||
|
||||
private static func cancelRequest(requestId: Int64) -> Void {
|
||||
requestQueue.async {
|
||||
guard let request = requests.removeValue(forKey: requestId) else { return }
|
||||
request.isCancelled = true
|
||||
guard let item = request.workItem else { return }
|
||||
if item.isCancelled {
|
||||
request.callback(Self.cancelledResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestAsset(assetId: String) -> PHAsset? {
|
||||
var asset: PHAsset?
|
||||
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
|
||||
if asset != nil { return asset }
|
||||
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
|
||||
else { return nil }
|
||||
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
|
||||
return asset
|
||||
}
|
||||
}
|
||||
@@ -184,4 +184,4 @@
|
||||
<string>We need local network permission to connect to the local server using IP address and
|
||||
allow the casting feature to work</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
@@ -1,35 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>IntentsSupported</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
|
||||
<dict>
|
||||
<key>AppGroupId</key>
|
||||
<string>$(CUSTOM_GROUP_ID)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>IntentsSupported</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
|
||||
$attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|
||||
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY
|
||||
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY
|
||||
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY
|
||||
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0
|
||||
).@count > 0 </string>
|
||||
<key>PHSupportedMediaTypes</key>
|
||||
<array>
|
||||
<string>Video</string>
|
||||
<string>Image</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
<key>PHSupportedMediaTypes</key>
|
||||
<array>
|
||||
<string>Video</string>
|
||||
<string>Image</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -22,7 +22,7 @@ platform :ios do
|
||||
path: "./Runner.xcodeproj",
|
||||
)
|
||||
increment_version_number(
|
||||
version_number: "1.137.3"
|
||||
version_number: "1.138.1"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -26,9 +26,8 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
|
||||
// Timeline constants
|
||||
const int kTimelineNoneSegmentSize = 120;
|
||||
const int kTimelineAssetLoadBatchSize = 1024;
|
||||
const int kTimelineAssetLoadBatchSize = 256;
|
||||
const int kTimelineAssetLoadOppositeSize = 64;
|
||||
const int kTimelineImageCacheMemory = 200 * 1024 * 1024;
|
||||
|
||||
// Widget keys
|
||||
const String appShareGroupId = "group.app.immich.share";
|
||||
|
||||
@@ -23,6 +23,7 @@ class RemoteAlbum {
|
||||
final AlbumAssetOrder order;
|
||||
final int assetCount;
|
||||
final String ownerName;
|
||||
final bool isShared;
|
||||
|
||||
const RemoteAlbum({
|
||||
required this.id,
|
||||
@@ -36,6 +37,7 @@ class RemoteAlbum {
|
||||
required this.order,
|
||||
required this.assetCount,
|
||||
required this.ownerName,
|
||||
required this.isShared,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -52,6 +54,7 @@ class RemoteAlbum {
|
||||
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
|
||||
assetCount: $assetCount
|
||||
ownerName: $ownerName
|
||||
isShared: $isShared
|
||||
}''';
|
||||
}
|
||||
|
||||
@@ -69,7 +72,8 @@ class RemoteAlbum {
|
||||
isActivityEnabled == other.isActivityEnabled &&
|
||||
order == other.order &&
|
||||
assetCount == other.assetCount &&
|
||||
ownerName == other.ownerName;
|
||||
ownerName == other.ownerName &&
|
||||
isShared == other.isShared;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -84,7 +88,8 @@ class RemoteAlbum {
|
||||
isActivityEnabled.hashCode ^
|
||||
order.hashCode ^
|
||||
assetCount.hashCode ^
|
||||
ownerName.hashCode;
|
||||
ownerName.hashCode ^
|
||||
isShared.hashCode;
|
||||
}
|
||||
|
||||
RemoteAlbum copyWith({
|
||||
@@ -99,6 +104,7 @@ class RemoteAlbum {
|
||||
AlbumAssetOrder? order,
|
||||
int? assetCount,
|
||||
String? ownerName,
|
||||
bool? isShared,
|
||||
}) {
|
||||
return RemoteAlbum(
|
||||
id: id ?? this.id,
|
||||
@@ -112,6 +118,7 @@ class RemoteAlbum {
|
||||
order: order ?? this.order,
|
||||
assetCount: assetCount ?? this.assetCount,
|
||||
ownerName: ownerName ?? this.ownerName,
|
||||
isShared: isShared ?? this.isShared,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.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/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/utils/remote_album.utils.dart';
|
||||
|
||||
class RemoteAlbumService {
|
||||
final DriftRemoteAlbumRepository _repository;
|
||||
@@ -26,8 +26,21 @@ class RemoteAlbumService {
|
||||
return _repository.get(albumId);
|
||||
}
|
||||
|
||||
List<RemoteAlbum> sortAlbums(List<RemoteAlbum> albums, RemoteAlbumSortMode sortMode, {bool isReverse = false}) {
|
||||
return sortMode.sortFn(albums, isReverse);
|
||||
Future<List<RemoteAlbum>> sortAlbums(
|
||||
List<RemoteAlbum> albums,
|
||||
RemoteAlbumSortMode sortMode, {
|
||||
bool isReverse = false,
|
||||
}) async {
|
||||
final List<RemoteAlbum> sorted = switch (sortMode) {
|
||||
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
|
||||
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
||||
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
||||
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||
};
|
||||
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
List<RemoteAlbum> searchAlbums(
|
||||
@@ -143,4 +156,60 @@ class RemoteAlbumService {
|
||||
Future<int> getCount() {
|
||||
return _repository.getCount();
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
|
||||
// map album IDs to their newest asset dates
|
||||
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
|
||||
for (final album in albums) {
|
||||
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
|
||||
}
|
||||
|
||||
// await all database queries
|
||||
final entries = await Future.wait(
|
||||
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
|
||||
);
|
||||
final assetTimestamps = Map.fromEntries(entries);
|
||||
|
||||
final sorted = albums.sorted((a, b) {
|
||||
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return aDate.compareTo(bDate);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
|
||||
// map album IDs to their oldest asset dates
|
||||
final Map<String, Future<DateTime?>> assetTimestampFutures = {
|
||||
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
|
||||
};
|
||||
|
||||
// await all database queries
|
||||
final entries = await Future.wait(
|
||||
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
|
||||
);
|
||||
final assetTimestamps = Map.fromEntries(entries);
|
||||
|
||||
final sorted = albums.sorted((a, b) {
|
||||
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return aDate.compareTo(bDate);
|
||||
});
|
||||
|
||||
return sorted.reversed.toList();
|
||||
}
|
||||
}
|
||||
|
||||
enum RemoteAlbumSortMode {
|
||||
title("library_page_sort_title"),
|
||||
assetCount("library_page_sort_asset_count"),
|
||||
lastModified("library_page_sort_last_modified"),
|
||||
created("library_page_sort_created"),
|
||||
mostRecent("sort_newest"),
|
||||
mostOldest("sort_oldest");
|
||||
|
||||
final String key;
|
||||
|
||||
const RemoteAlbumSortMode(this.key);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,36 @@ class TimelineService {
|
||||
return _buffer.elementAt(index - _bufferOffset);
|
||||
}
|
||||
|
||||
/// Gets an asset at the given index, automatically loading the buffer if needed.
|
||||
/// This is an async version that can handle out-of-range indices by loading the appropriate buffer.
|
||||
Future<BaseAsset?> getAssetAsync(int index) async {
|
||||
if (index < 0 || index >= _totalAssets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasRange(index, 1)) {
|
||||
return _buffer.elementAt(index - _bufferOffset);
|
||||
}
|
||||
|
||||
// Load the buffer containing the requested index
|
||||
try {
|
||||
final assets = await loadAssets(index, 1);
|
||||
return assets.isNotEmpty ? assets.first : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safely gets an asset at the given index without throwing a RangeError.
|
||||
/// Returns null if the index is out of bounds or not currently in the buffer.
|
||||
/// For automatic buffer loading, use getAssetAsync instead.
|
||||
BaseAsset? getAssetSafe(int index) {
|
||||
if (index < 0 || index >= _totalAssets || !hasRange(index, 1)) {
|
||||
return null;
|
||||
}
|
||||
return _buffer.elementAt(index - _bufferOffset);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _bucketSubscription?.cancel();
|
||||
_bucketSubscription = null;
|
||||
|
||||
@@ -85,13 +85,3 @@ extension DateRangeFormatting on DateTime {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension IsSameExtension on DateTime {
|
||||
bool isSameDay(DateTime other) {
|
||||
return day == other.day && month == other.month && year == other.year;
|
||||
}
|
||||
|
||||
bool isSameMonth(DateTime other) {
|
||||
return month == other.month && year == other.year;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,211 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
abstract class ImageRequest {
|
||||
static int _nextRequestId = 0;
|
||||
class AssetMediaRepository {
|
||||
const AssetMediaRepository();
|
||||
|
||||
final int requestId = _nextRequestId++;
|
||||
bool _isCancelled = false;
|
||||
|
||||
get isCancelled => _isCancelled;
|
||||
|
||||
ImageRequest();
|
||||
|
||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
|
||||
|
||||
void cancel() {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
_isCancelled = true;
|
||||
return _onCancelled();
|
||||
}
|
||||
|
||||
void _onCancelled();
|
||||
}
|
||||
|
||||
class LocalImageRequest extends ImageRequest {
|
||||
final String localId;
|
||||
final int width;
|
||||
final int height;
|
||||
|
||||
LocalImageRequest({required this.localId, required ui.Size size})
|
||||
: width = size.width.toInt(),
|
||||
height = size.height.toInt();
|
||||
|
||||
@override
|
||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, int> info = await thumbnailApi.requestImage(
|
||||
localId,
|
||||
requestId: requestId,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
|
||||
final address = info['pointer'];
|
||||
if (address == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
try {
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final actualWidth = info['width']!;
|
||||
final actualHeight = info['height']!;
|
||||
final actualSize = actualWidth * actualHeight * 4;
|
||||
|
||||
final buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final descriptor = ui.ImageDescriptor.raw(
|
||||
buffer,
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
pixelFormat: ui.PixelFormat.rgba8888,
|
||||
);
|
||||
final codec = await descriptor.instantiateCodec();
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final frame = await codec.getNextFrame();
|
||||
return ImageInfo(image: frame.image, scale: scale);
|
||||
} finally {
|
||||
malloc.free(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> _onCancelled() {
|
||||
return thumbnailApi.cancelImageRequest(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteImageRequest extends ImageRequest {
|
||||
static final log = Logger('RemoteImageRequest');
|
||||
static final cacheManager = RemoteImageCacheManager();
|
||||
static final client = HttpClient();
|
||||
String uri;
|
||||
Map<String, String> headers;
|
||||
HttpClientRequest? _request;
|
||||
|
||||
RemoteImageRequest({required this.uri, required this.headers});
|
||||
|
||||
@override
|
||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// The DB calls made by the cache manager are a *massive* bottleneck (6+ seconds) with high concurrency.
|
||||
// Since it isn't possible to cancel these operations, we only prefer the cache when they can be avoided.
|
||||
// The DB hit is left as a fallback for offline use.
|
||||
final cachedFileBuffer = await _loadCachedFile(uri, inMemoryOnly: true);
|
||||
if (cachedFileBuffer != null) {
|
||||
return _decodeBuffer(cachedFileBuffer, decode, scale);
|
||||
}
|
||||
|
||||
final buffer = await _downloadImage(uri);
|
||||
if (buffer == null || _isCancelled) {
|
||||
return null;
|
||||
}
|
||||
return await _decodeBuffer(buffer, decode, scale);
|
||||
} catch (e) {
|
||||
if (e is HttpException && (e.message.endsWith('aborted') || e.message.startsWith('Connection closed'))) {
|
||||
return null;
|
||||
}
|
||||
log.severe('Failed to load remote image', e);
|
||||
final buffer = await _loadCachedFile(uri, inMemoryOnly: false);
|
||||
if (buffer != null) {
|
||||
return _decodeBuffer(buffer, decode, scale);
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
_request = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImmutableBuffer?> _downloadImage(String url) async {
|
||||
final request = _request = await client.getUrl(Uri.parse(url));
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
for (final entry in headers.entries) {
|
||||
request.headers.set(entry.key, entry.value);
|
||||
}
|
||||
final response = await request.close();
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final bytes = await consolidateHttpClientResponseBytes(response);
|
||||
_cacheFile(url, bytes);
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
return await ImmutableBuffer.fromUint8List(bytes);
|
||||
}
|
||||
|
||||
Future<void> _cacheFile(String url, Uint8List bytes) async {
|
||||
try {
|
||||
await cacheManager.putFile(url, bytes);
|
||||
} catch (e) {
|
||||
log.severe('Failed to cache image', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImmutableBuffer?> _loadCachedFile(String url, {required bool inMemoryOnly}) async {
|
||||
if (_isCancelled) {
|
||||
return null;
|
||||
}
|
||||
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
|
||||
if (_isCancelled || file == null) {
|
||||
return null;
|
||||
}
|
||||
return await ImmutableBuffer.fromFilePath(file.file.path);
|
||||
}
|
||||
|
||||
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
return null;
|
||||
}
|
||||
final codec = await decode(buffer);
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
codec.dispose();
|
||||
return null;
|
||||
}
|
||||
final frame = await codec.getNextFrame();
|
||||
return ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
@override
|
||||
void _onCancelled() {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
}
|
||||
Future<Uint8List?> getThumbnail(String id, {int quality = 80, Size size = const Size.square(256)}) => AssetEntity(
|
||||
id: id,
|
||||
// The below fields are not used in thumbnailDataWithSize but are required
|
||||
// to create an AssetEntity instance. It is faster to create a dummy AssetEntity
|
||||
// instance than to fetch the asset from the device first.
|
||||
typeInt: AssetType.image.index,
|
||||
width: size.width.toInt(),
|
||||
height: size.height.toInt(),
|
||||
).thumbnailDataWithSize(ThumbnailSize(size.width.toInt(), size.height.toInt()), quality: quality);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/utils/database.utils.dart';
|
||||
import 'package:platform/platform.dart';
|
||||
|
||||
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount }
|
||||
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
|
||||
|
||||
class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
@@ -40,6 +40,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
SortLocalAlbumsBy.isIosSharedAlbum => OrderingTerm.asc(_db.localAlbumEntity.isIosSharedAlbum),
|
||||
SortLocalAlbumsBy.name => OrderingTerm.asc(_db.localAlbumEntity.name),
|
||||
SortLocalAlbumsBy.assetCount => OrderingTerm.desc(assetCount),
|
||||
SortLocalAlbumsBy.newestAsset => OrderingTerm.desc(_db.localAlbumEntity.updatedAt),
|
||||
});
|
||||
}
|
||||
query.orderBy(orderings);
|
||||
@@ -319,7 +320,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
])
|
||||
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
|
||||
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)])
|
||||
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
|
||||
..limit(1);
|
||||
|
||||
final results = await query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
|
||||
|
||||
@@ -31,11 +31,17 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), useColumns: false),
|
||||
leftOuterJoin(
|
||||
_db.remoteAlbumUserEntity,
|
||||
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
]);
|
||||
query
|
||||
..where(_db.remoteAssetEntity.deletedAt.isNull())
|
||||
..addColumns([assetCount])
|
||||
..addColumns([_db.userEntity.name])
|
||||
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
||||
..groupBy([_db.remoteAlbumEntity.id]);
|
||||
|
||||
if (sortBy.isNotEmpty) {
|
||||
@@ -53,7 +59,11 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
.map(
|
||||
(row) => row
|
||||
.readTable(_db.remoteAlbumEntity)
|
||||
.toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!),
|
||||
.toDto(
|
||||
assetCount: row.read(assetCount) ?? 0,
|
||||
ownerName: row.read(_db.userEntity.name)!,
|
||||
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
||||
),
|
||||
)
|
||||
.get();
|
||||
}
|
||||
@@ -78,17 +88,27 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.remoteAlbumUserEntity,
|
||||
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
|
||||
..addColumns([assetCount])
|
||||
..addColumns([_db.userEntity.name])
|
||||
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
||||
..groupBy([_db.remoteAlbumEntity.id]);
|
||||
|
||||
return query
|
||||
.map(
|
||||
(row) => row
|
||||
.readTable(_db.remoteAlbumEntity)
|
||||
.toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!),
|
||||
.toDto(
|
||||
assetCount: row.read(assetCount) ?? 0,
|
||||
ownerName: row.read(_db.userEntity.name)!,
|
||||
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
||||
),
|
||||
)
|
||||
.getSingleOrNull();
|
||||
}
|
||||
@@ -254,24 +274,57 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.remoteAlbumUserEntity,
|
||||
_db.remoteAlbumUserEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(_db.remoteAlbumEntity.id.equals(albumId))
|
||||
..addColumns([_db.userEntity.name])
|
||||
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
||||
..groupBy([_db.remoteAlbumEntity.id]);
|
||||
|
||||
return query.map((row) {
|
||||
final album = row.readTable(_db.remoteAlbumEntity).toDto(ownerName: row.read(_db.userEntity.name)!);
|
||||
final album = row
|
||||
.readTable(_db.remoteAlbumEntity)
|
||||
.toDto(
|
||||
ownerName: row.read(_db.userEntity.name)!,
|
||||
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
||||
);
|
||||
return album;
|
||||
}).watchSingleOrNull();
|
||||
}
|
||||
|
||||
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
|
||||
final query = _db.remoteAlbumAssetEntity.selectOnly()
|
||||
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
|
||||
..join([
|
||||
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||
]);
|
||||
|
||||
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
|
||||
final query = _db.remoteAlbumAssetEntity.selectOnly()
|
||||
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
|
||||
..join([
|
||||
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||
]);
|
||||
|
||||
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _db.managers.remoteAlbumEntity.count();
|
||||
}
|
||||
}
|
||||
|
||||
extension on RemoteAlbumEntityData {
|
||||
RemoteAlbum toDto({int assetCount = 0, required String ownerName}) {
|
||||
RemoteAlbum toDto({int assetCount = 0, required String ownerName, required bool isShared}) {
|
||||
return RemoteAlbum(
|
||||
id: id,
|
||||
name: name,
|
||||
@@ -284,6 +337,7 @@ extension on RemoteAlbumEntityData {
|
||||
order: order,
|
||||
assetCount: assetCount,
|
||||
ownerName: ownerName,
|
||||
isShared: isShared,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +258,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
);
|
||||
|
||||
TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
|
||||
filter: (row) => row.deletedAt.isNull() & row.isFavorite.equals(true) & row.ownerId.equals(userId),
|
||||
filter: (row) =>
|
||||
row.deletedAt.isNull() &
|
||||
row.isFavorite.equals(true) &
|
||||
row.ownerId.equals(userId) &
|
||||
row.visibility.equalsValue(AssetVisibility.timeline),
|
||||
groupBy: groupBy,
|
||||
);
|
||||
|
||||
|
||||
@@ -71,8 +71,6 @@ Future<void> initApp() async {
|
||||
}
|
||||
}
|
||||
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = kTimelineImageCacheMemory;
|
||||
|
||||
await DynamicTheme.fetchSystemPalette();
|
||||
|
||||
final log = Logger("ImmichErrorLogger");
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||
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/user.provider.dart';
|
||||
@@ -39,6 +40,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(backgroundSyncProvider).syncRemote();
|
||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
child: asset != null
|
||||
? Thumbnail.fromBaseAsset(asset: asset, size: const Size(512, 512), fit: BoxFit.cover)
|
||||
? Thumbnail(asset: asset, size: const Size(512, 512), fit: BoxFit.cover)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
107
mobile/lib/platform/thumbnail_api.g.dart
generated
107
mobile/lib/platform/thumbnail_api.g.dart
generated
@@ -1,107 +0,0 @@
|
||||
// Autogenerated from Pigeon (v26.0.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
PlatformException _createConnectionError(String channelName) {
|
||||
return PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailApi {
|
||||
/// Constructor for [ThumbnailApi]. 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.
|
||||
ThumbnailApi({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<Map<String, int>> requestImage(
|
||||
String assetId, {
|
||||
required int requestId,
|
||||
required int width,
|
||||
required int height,
|
||||
}) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, requestId, width, height]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)!.cast<String, int>();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelImageRequest(int requestId) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
mobile/lib/presentation/pages/drift_activities.page.dart
Normal file
104
mobile/lib/presentation/pages/drift_activities.page.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
|
||||
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftActivitiesPage extends HookConsumerWidget {
|
||||
const DriftActivitiesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentRemoteAlbumProvider)!;
|
||||
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
|
||||
final listViewScrollController = useScrollController();
|
||||
|
||||
void scrollToBottom() {
|
||||
listViewScrollController.animateTo(
|
||||
listViewScrollController.position.maxScrollExtent + 80,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onAddComment(String comment) async {
|
||||
await activityNotifier.addComment(comment);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: asset == null ? Text(album.name) : null,
|
||||
actions: [const LikeActivityActionButton(menuItem: true)],
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
body: activities.widgetWhen(
|
||||
onData: (data) {
|
||||
final liked = data.firstWhereOrNull(
|
||||
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
|
||||
);
|
||||
|
||||
return SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: listViewScrollController,
|
||||
itemCount: data.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == data.length) {
|
||||
return const SizedBox(height: 80);
|
||||
}
|
||||
final activity = data[index];
|
||||
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: DismissibleActivity(
|
||||
activity.id,
|
||||
ActivityTile(activity),
|
||||
onDismiss: canDelete
|
||||
? (activityId) async => await activityNotifier.removeActivity(activity.id)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.scaffoldBackgroundColor,
|
||||
border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)),
|
||||
),
|
||||
child: DriftActivityTextField(
|
||||
isEnabled: album.isActivityEnabled,
|
||||
likeId: liked?.id,
|
||||
onSubmit: onAddComment,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
resizeToAvoidBottomInset: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
||||
final asset = selectedAssets.elementAt(index);
|
||||
return GestureDetector(
|
||||
onTap: onBackgroundTapped,
|
||||
child: Thumbnail.fromBaseAsset(asset: asset),
|
||||
child: Thumbnail(asset: asset),
|
||||
);
|
||||
}, childCount: selectedAssets.length),
|
||||
),
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -164,11 +163,7 @@ class _PlaceTile extends StatelessWidget {
|
||||
title: Text(place.$1, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
child: Thumbnail(
|
||||
imageProvider: RemoteThumbProvider(assetId: place.$2),
|
||||
size: const Size(80, 80),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
child: Thumbnail(size: const Size(80, 80), fit: BoxFit.cover, remoteId: place.$2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,6 +165,10 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showActivity(BuildContext context) async {
|
||||
context.pushRoute(const DriftActivitiesRoute());
|
||||
}
|
||||
|
||||
void showOptionSheet(BuildContext context) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isOwner = user != null ? user.id == _album.ownerId : false;
|
||||
@@ -241,6 +245,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
||||
onShowOptions: () => showOptionSheet(context),
|
||||
onToggleAlbumOrder: () => toggleAlbumOrder(),
|
||||
onEditTitle: () => showEditTitleAndDescription(context),
|
||||
onActivity: () => showActivity(context),
|
||||
),
|
||||
bottomSheet: RemoteAlbumBottomSheet(album: _album),
|
||||
),
|
||||
|
||||
174
mobile/lib/presentation/pages/editing/drift_crop.page.dart
Normal file
174
mobile/lib/presentation/pages/editing/drift_crop.page.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:crop_image/crop_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
|
||||
|
||||
/// A widget for cropping an image.
|
||||
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
|
||||
/// users to crop an image and then navigate to the [EditImagePage] with the
|
||||
/// cropped image.
|
||||
|
||||
@RoutePage()
|
||||
class DriftCropImagePage extends HookWidget {
|
||||
final Image image;
|
||||
final BaseAsset asset;
|
||||
const DriftCropImagePage({super.key, required this.image, required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cropController = useCropController();
|
||||
final aspectRatio = useState<double?>(null);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
title: Text("crop".tr()),
|
||||
leading: CloseButton(color: context.primaryColor),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
|
||||
onPressed: () async {
|
||||
final croppedImage = await cropController.croppedImage();
|
||||
context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
width: constraints.maxWidth * 0.9,
|
||||
height: constraints.maxHeight * 0.6,
|
||||
child: CropImage(controller: cropController, image: image, gridColor: Colors.white),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: context.scaffoldBackgroundColor,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
topRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color),
|
||||
onPressed: () {
|
||||
cropController.rotateLeft();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color),
|
||||
onPressed: () {
|
||||
cropController.rotateRight();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
aspectRatio: aspectRatio,
|
||||
ratio: null,
|
||||
label: 'Free',
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
aspectRatio: aspectRatio,
|
||||
ratio: 1.0,
|
||||
label: '1:1',
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
aspectRatio: aspectRatio,
|
||||
ratio: 16.0 / 9.0,
|
||||
label: '16:9',
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
aspectRatio: aspectRatio,
|
||||
ratio: 3.0 / 2.0,
|
||||
label: '3:2',
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
aspectRatio: aspectRatio,
|
||||
ratio: 7.0 / 5.0,
|
||||
label: '7:5',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AspectRatioButton extends StatelessWidget {
|
||||
final CropController cropController;
|
||||
final ValueNotifier<double?> aspectRatio;
|
||||
final double? ratio;
|
||||
final String label;
|
||||
|
||||
const _AspectRatioButton({
|
||||
required this.cropController,
|
||||
required this.aspectRatio,
|
||||
required this.ratio,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(switch (label) {
|
||||
'Free' => Icons.crop_free_rounded,
|
||||
'1:1' => Icons.crop_square_rounded,
|
||||
'16:9' => Icons.crop_16_9_rounded,
|
||||
'3:2' => Icons.crop_3_2_rounded,
|
||||
'7:5' => Icons.crop_7_5_rounded,
|
||||
_ => Icons.crop_free_rounded,
|
||||
}, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color),
|
||||
onPressed: () {
|
||||
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
|
||||
aspectRatio.value = ratio;
|
||||
cropController.aspectRatio = ratio;
|
||||
},
|
||||
),
|
||||
Text(label, style: context.textTheme.displayMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
165
mobile/lib/presentation/pages/editing/drift_edit.page.dart
Normal file
165
mobile/lib/presentation/pages/editing/drift_edit.page.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// A stateless widget that provides functionality for editing an image.
|
||||
///
|
||||
/// This widget allows users to edit an image provided either as an [Asset] or
|
||||
/// directly as an [Image]. It ensures that exactly one of these is provided.
|
||||
///
|
||||
/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone
|
||||
/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server.
|
||||
@immutable
|
||||
@RoutePage()
|
||||
class DriftEditImagePage extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final Image image;
|
||||
final bool isEdited;
|
||||
|
||||
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
|
||||
Future<Uint8List> _imageToUint8List(Image image) async {
|
||||
final Completer<Uint8List> completer = Completer();
|
||||
image.image
|
||||
.resolve(const ImageConfiguration())
|
||||
.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) {
|
||||
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
|
||||
if (byteData != null) {
|
||||
completer.complete(byteData.buffer.asUint8List());
|
||||
} else {
|
||||
completer.completeError('Failed to convert image to bytes');
|
||||
}
|
||||
});
|
||||
}, onError: (exception, stackTrace) => completer.completeError(exception)),
|
||||
);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
|
||||
try {
|
||||
final Uint8List imageData = await _imageToUint8List(image);
|
||||
LocalAsset? localAsset;
|
||||
|
||||
try {
|
||||
localAsset = await ref
|
||||
.read(fileMediaRepositoryProvider)
|
||||
.saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg");
|
||||
} on PlatformException catch (e) {
|
||||
// OS might not return the saved image back, so we handle that gracefully
|
||||
// This can happen if app does not have full library access
|
||||
Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e);
|
||||
}
|
||||
|
||||
ref.read(backgroundSyncProvider).syncLocal(full: true);
|
||||
context.navigator.popUntil((route) => route.isFirst);
|
||||
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!');
|
||||
|
||||
if (localAsset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(uploadServiceProvider).manualBackup([localAsset]);
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 6,
|
||||
context: context,
|
||||
msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("edit".tr()),
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
|
||||
onPressed: () => context.navigator.popUntil((route) => route.isFirst),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null,
|
||||
child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||
child: Image(image: image.image, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: Container(
|
||||
height: 70,
|
||||
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: context.scaffoldBackgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(30)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25),
|
||||
onPressed: () {
|
||||
context.pushRoute(DriftCropImageRoute(asset: asset, image: image));
|
||||
},
|
||||
),
|
||||
Text("crop".tr(), style: context.textTheme.displayMedium),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25),
|
||||
onPressed: () {
|
||||
context.pushRoute(DriftFilterImageRoute(asset: asset, image: image));
|
||||
},
|
||||
),
|
||||
Text("filter".tr(), style: context.textTheme.displayMedium),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
mobile/lib/presentation/pages/editing/drift_filter.page.dart
Normal file
159
mobile/lib/presentation/pages/editing/drift_filter.page.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/constants/filters.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
/// A widget for filtering an image.
|
||||
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
|
||||
/// users to add filters to an image and then navigate to the [EditImagePage] with the
|
||||
/// final composition.'
|
||||
@RoutePage()
|
||||
class DriftFilterImagePage extends HookWidget {
|
||||
final Image image;
|
||||
final BaseAsset asset;
|
||||
|
||||
const DriftFilterImagePage({super.key, required this.image, required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorFilter = useState<ColorFilter>(filters[0]);
|
||||
final selectedFilterIndex = useState<int>(0);
|
||||
|
||||
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
|
||||
final completer = Completer<ui.Image>();
|
||||
final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble());
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
|
||||
final paint = Paint()..colorFilter = filter;
|
||||
canvas.drawImage(inputImage, Offset.zero, paint);
|
||||
|
||||
recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) {
|
||||
completer.complete(image);
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void applyFilter(ColorFilter filter, int index) {
|
||||
colorFilter.value = filter;
|
||||
selectedFilterIndex.value = index;
|
||||
}
|
||||
|
||||
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
|
||||
final completer = Completer<ui.Image>();
|
||||
image.image
|
||||
.resolve(ImageConfiguration.empty)
|
||||
.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) {
|
||||
completer.complete(info.image);
|
||||
}),
|
||||
);
|
||||
final uiImage = await completer.future;
|
||||
|
||||
final filteredUiImage = await createFilteredImage(uiImage, filter);
|
||||
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
|
||||
final pngBytes = byteData!.buffer.asUint8List();
|
||||
|
||||
return Image.memory(pngBytes, fit: BoxFit.contain);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
title: Text("filter".tr()),
|
||||
leading: CloseButton(color: context.primaryColor),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
|
||||
onPressed: () async {
|
||||
final filteredImage = await applyFilterAndConvert(colorFilter.value);
|
||||
context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: context.scaffoldBackgroundColor,
|
||||
body: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: context.height * 0.7,
|
||||
child: Center(
|
||||
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: filters.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: _FilterButton(
|
||||
image: image,
|
||||
label: filterNames[index],
|
||||
filter: filters[index],
|
||||
isSelected: selectedFilterIndex.value == index,
|
||||
onTap: () => applyFilter(filters[index], index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterButton extends StatelessWidget {
|
||||
final Image image;
|
||||
final String label;
|
||||
final ColorFilter filter;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FilterButton({
|
||||
required this.image,
|
||||
required this.label,
|
||||
required this.filter,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: ColorFiltered(
|
||||
colorFilter: filter,
|
||||
child: FittedBox(fit: BoxFit.cover, child: image),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(label, style: context.themeData.textTheme.bodyMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
|
||||
class EditImageActionButton extends ConsumerWidget {
|
||||
const EditImageActionButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentAsset = ref.watch(currentAssetNotifier);
|
||||
|
||||
onPress() {
|
||||
if (currentAsset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final image = Image(image: getFullImageProvider(currentAsset));
|
||||
|
||||
context.navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DriftEditImagePage(asset: currentAsset, image: image, isEdited: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return BaseActionButton(
|
||||
iconData: Icons.tune,
|
||||
label: "edit".t(context: context),
|
||||
onPressed: onPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:collection/collection.dart';
|
||||
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/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
class LikeActivityActionButton extends ConsumerWidget {
|
||||
const LikeActivityActionButton({super.key, this.menuItem = false});
|
||||
|
||||
final bool menuItem;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentRemoteAlbumProvider);
|
||||
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||
|
||||
onTap(Activity? liked) async {
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (liked != null) {
|
||||
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).removeActivity(liked.id);
|
||||
} else {
|
||||
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).addLike();
|
||||
}
|
||||
|
||||
ref.invalidate(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||
}
|
||||
|
||||
return activities.when(
|
||||
data: (data) {
|
||||
final liked = data.firstWhereOrNull(
|
||||
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id,
|
||||
);
|
||||
|
||||
return BaseActionButton(
|
||||
maxWidth: 60,
|
||||
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
|
||||
label: "like".t(context: context),
|
||||
onPressed: () => onTap(liked),
|
||||
menuItem: menuItem,
|
||||
);
|
||||
},
|
||||
|
||||
// default to empty heart during loading
|
||||
loading: () => BaseActionButton(
|
||||
iconData: Icons.favorite_border,
|
||||
label: "like".t(context: context),
|
||||
menuItem: menuItem,
|
||||
),
|
||||
error: (error, stack) => Text("Error: $error"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/remote_album.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/remote_album.utils.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
@@ -138,21 +138,28 @@ class _SortButton extends ConsumerStatefulWidget {
|
||||
class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
|
||||
bool albumSortIsReverse = true;
|
||||
bool isSorting = false;
|
||||
|
||||
void onMenuTapped(RemoteAlbumSortMode sortMode) {
|
||||
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
|
||||
final selected = albumSortOption == sortMode;
|
||||
// Switch direction
|
||||
if (selected) {
|
||||
setState(() {
|
||||
albumSortIsReverse = !albumSortIsReverse;
|
||||
isSorting = true;
|
||||
});
|
||||
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
|
||||
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
|
||||
} else {
|
||||
setState(() {
|
||||
albumSortOption = sortMode;
|
||||
isSorting = true;
|
||||
});
|
||||
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
|
||||
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
isSorting = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -230,6 +237,16 @@ class _SortButtonState extends ConsumerState<_SortButton> {
|
||||
color: context.colorScheme.onSurface.withAlpha(225),
|
||||
),
|
||||
),
|
||||
isSorting
|
||||
? SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colorScheme.onSurface.withAlpha(225),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -424,46 +441,72 @@ class _AlbumList extends ConsumerWidget {
|
||||
sliver: SliverList.builder(
|
||||
itemBuilder: (_, index) {
|
||||
final album = albums[index];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: LargeLeadingTile(
|
||||
title: Text(
|
||||
album.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onTap: () => onAlbumSelected(album),
|
||||
leadingPadding: const EdgeInsets.only(right: 16),
|
||||
leading: album.thumbnailAssetId != null
|
||||
? ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: Thumbnail(imageProvider: RemoteThumbProvider(assetId: album.thumbnailAssetId!)),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
|
||||
),
|
||||
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
final albumTile = LargeLeadingTile(
|
||||
title: Text(
|
||||
album.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onTap: () => onAlbumSelected(album),
|
||||
leadingPadding: const EdgeInsets.only(right: 16),
|
||||
leading: album.thumbnailAssetId != null
|
||||
? ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: SizedBox(width: 80, height: 80, child: Thumbnail(remoteId: album.thumbnailAssetId)),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
|
||||
),
|
||||
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
final isOwner = album.ownerId == userId;
|
||||
|
||||
if (isOwner) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Dismissible(
|
||||
key: ValueKey(album.id),
|
||||
background: Container(
|
||||
color: context.colorScheme.error,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.delete, color: context.colorScheme.onError),
|
||||
),
|
||||
direction: DismissDirection.endToStart,
|
||||
confirmDismiss: (direction) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
onOk: () => true,
|
||||
title: "delete_album".t(context: context),
|
||||
content: "album_delete_confirmation".t(context: context, args: {'album': album.name}),
|
||||
ok: "delete".t(context: context),
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (direction) async {
|
||||
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(album.id);
|
||||
},
|
||||
child: albumTile,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Padding(padding: const EdgeInsets.only(bottom: 8.0), child: albumTile);
|
||||
}
|
||||
},
|
||||
itemCount: albums.length,
|
||||
),
|
||||
@@ -534,7 +577,7 @@ class _GridAlbumCard extends ConsumerWidget {
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: album.thumbnailAssetId != null
|
||||
? Thumbnail(imageProvider: RemoteThumbProvider(assetId: album.thumbnailAssetId!))
|
||||
? Thumbnail(remoteId: album.thumbnailAssetId)
|
||||
: Container(
|
||||
color: context.colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||
|
||||
class DriftActivityTextField extends ConsumerStatefulWidget {
|
||||
final bool isEnabled;
|
||||
final String? likeId;
|
||||
final Function(String) onSubmit;
|
||||
final Function()? onKeyboardFocus;
|
||||
|
||||
const DriftActivityTextField({
|
||||
required this.onSubmit,
|
||||
this.isEnabled = true,
|
||||
this.likeId,
|
||||
this.onKeyboardFocus,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DriftActivityTextField> createState() => _DriftActivityTextFieldState();
|
||||
}
|
||||
|
||||
class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField> {
|
||||
late FocusNode inputFocusNode;
|
||||
late TextEditingController inputController;
|
||||
bool sendEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
inputController = TextEditingController();
|
||||
inputFocusNode = FocusNode();
|
||||
|
||||
inputFocusNode.requestFocus();
|
||||
|
||||
inputFocusNode.addListener(() {
|
||||
if (inputFocusNode.hasFocus) {
|
||||
widget.onKeyboardFocus?.call();
|
||||
}
|
||||
});
|
||||
|
||||
inputController.addListener(() {
|
||||
setState(() {
|
||||
sendEnabled = inputController.text.trim().isNotEmpty;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
inputController.dispose();
|
||||
inputFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
// Pass text to callback and reset controller
|
||||
void onEditingComplete() {
|
||||
if (inputController.text.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
widget.onSubmit(inputController.text);
|
||||
inputController.clear();
|
||||
inputFocusNode.unfocus();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: TextField(
|
||||
controller: inputController,
|
||||
enabled: widget.isEnabled,
|
||||
focusNode: inputFocusNode,
|
||||
textInputAction: TextInputAction.send,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12), // Adjust as needed
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
prefixIcon: user != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: UserCircleAvatar(user: user, size: 30, radius: 15),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: IconButton(
|
||||
onPressed: sendEnabled ? onEditingComplete : null,
|
||||
icon: const Icon(Icons.send),
|
||||
iconSize: 24,
|
||||
color: context.primaryColor,
|
||||
disabledColor: context.colorScheme.secondaryContainer,
|
||||
),
|
||||
hintText: !widget.isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(),
|
||||
hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
onEditingComplete: onEditingComplete,
|
||||
onTapOutside: (_) => inputFocusNode.unfocus(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,10 +113,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get showingBottomSheet => ref.read(assetViewerProvider).showingBottomSheet;
|
||||
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
|
||||
Color get backgroundColor {
|
||||
final opacity = ref.read(assetViewerProvider).backgroundOpacity;
|
||||
final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||
return Colors.black.withAlpha(opacity);
|
||||
}
|
||||
|
||||
@@ -127,20 +127,21 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
_delayedOperations.clear();
|
||||
}
|
||||
|
||||
// This is used to calculate the scale of the asset when the bottom sheet is showing.
|
||||
// It is a small increment to ensure that the asset is slightly zoomed in when the
|
||||
// bottom sheet is showing, which emulates the zoom effect.
|
||||
double get _getScaleForBottomSheet => (viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) + 0.01;
|
||||
|
||||
double _getVerticalOffsetForBottomSheet(double extent) =>
|
||||
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
|
||||
|
||||
Future<void> _precacheImage(int index) async {
|
||||
if (!mounted || index < 0 || index >= totalAssets) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final asset = await timelineService.getAssetAsync(index);
|
||||
|
||||
if (asset == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
final screenSize = Size(context.width, context.height);
|
||||
|
||||
// Precache both thumbnail and full image for smooth transitions
|
||||
@@ -152,8 +153,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onAssetChanged(int index) {
|
||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
void _onAssetChanged(int index) async {
|
||||
// Validate index bounds and try to get asset, loading buffer if needed
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final asset = await timelineService.getAssetAsync(index);
|
||||
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always holds the current asset from the timeline
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
// The currentAssetNotifier actually holds the current asset that is displayed
|
||||
@@ -172,8 +180,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
// Check if widget is still mounted before proceeding
|
||||
if (!mounted) return;
|
||||
|
||||
unawaited(_precacheImage(index - 1));
|
||||
unawaited(_precacheImage(index + 1));
|
||||
for (final offset in [-1, 1]) {
|
||||
unawaited(_precacheImage(index + offset));
|
||||
}
|
||||
});
|
||||
_delayedOperations.add(timer);
|
||||
|
||||
@@ -216,19 +225,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final verticalOffset =
|
||||
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
|
||||
controller.position = Offset(0, -verticalOffset);
|
||||
// Apply the zoom effect when the bottom sheet is showing
|
||||
initialScale = controller.scale;
|
||||
controller.scale = (controller.scale ?? 1.0) + 0.01;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
|
||||
_onAssetChanged(index);
|
||||
viewController = controller;
|
||||
|
||||
// If the bottom sheet is showing, we need to adjust scale the asset to
|
||||
// emulate the zoom effect
|
||||
if (showingBottomSheet) {
|
||||
initialScale = controller?.scale;
|
||||
controller?.scale = _getScaleForBottomSheet;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragStart(
|
||||
@@ -411,16 +416,22 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onAssetReloadEvent() {
|
||||
setState(() {
|
||||
final index = pageController.page?.round() ?? 0;
|
||||
final newAsset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
// Do not reload / close the bottom sheet if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) {
|
||||
return;
|
||||
}
|
||||
void _onAssetReloadEvent() async {
|
||||
final index = pageController.page?.round() ?? 0;
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final newAsset = await timelineService.getAssetAsync(index);
|
||||
|
||||
if (newAsset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
// Do not reload / close the bottom sheet if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_onAssetChanged(pageController.page!.round());
|
||||
sheetCloseController?.close();
|
||||
});
|
||||
@@ -429,7 +440,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent}) {
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
|
||||
initialScale = viewController?.scale;
|
||||
viewController?.updateMultiple(scale: _getScaleForBottomSheet);
|
||||
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
|
||||
previousExtent = _kBottomSheetMinimumExtent;
|
||||
sheetCloseController = showBottomSheet(
|
||||
context: ctx,
|
||||
@@ -467,12 +478,30 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
|
||||
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final asset = timelineService.getAssetSafe(index);
|
||||
|
||||
// If asset is not available in buffer, show a loading container
|
||||
if (asset == null) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: backgroundColor,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
BaseAsset displayAsset = asset;
|
||||
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
|
||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
||||
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
|
||||
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
|
||||
}
|
||||
return Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: backgroundColor,
|
||||
child: Thumbnail(asset: displayAsset, fit: BoxFit.contain),
|
||||
);
|
||||
}
|
||||
|
||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||
@@ -487,18 +516,34 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
||||
scaffoldContext ??= ctx;
|
||||
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final asset = timelineService.getAssetSafe(index);
|
||||
|
||||
// If asset is not available in buffer, return a placeholder
|
||||
if (asset == null) {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'),
|
||||
child: Container(
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
color: backgroundColor,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BaseAsset displayAsset = asset;
|
||||
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
|
||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
||||
asset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
|
||||
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
|
||||
}
|
||||
|
||||
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
|
||||
if (asset.isImage && !isPlayingMotionVideo) {
|
||||
return _imageBuilder(ctx, asset);
|
||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||
return _imageBuilder(ctx, displayAsset);
|
||||
}
|
||||
|
||||
return _videoBuilder(ctx, asset);
|
||||
return _videoBuilder(ctx, displayAsset);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
|
||||
@@ -509,8 +554,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
disableScaleGestures: showingBottomSheet,
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
@@ -521,7 +564,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
color: backgroundColor,
|
||||
child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain),
|
||||
child: Thumbnail(asset: asset, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -539,9 +582,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
onTapDown: _onTapDown,
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
maxScale: 1.0,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
basePosition: Alignment.center,
|
||||
child: SizedBox(
|
||||
width: ctx.width,
|
||||
@@ -570,11 +611,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
Widget build(BuildContext context) {
|
||||
// Rebuild the widget when the asset viewer state changes
|
||||
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
|
||||
ref.watch(
|
||||
assetViewerProvider.select(
|
||||
(s) => s.showingBottomSheet.hashCode ^ s.backgroundOpacity.hashCode ^ s.stackIndex.hashCode,
|
||||
),
|
||||
);
|
||||
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
ref.watch(isPlayingMotionVideoProvider);
|
||||
|
||||
// Listen for casting changes and send initial asset to the cast provider
|
||||
|
||||
@@ -75,34 +75,22 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
|
||||
}
|
||||
|
||||
void setAsset(BaseAsset? asset) {
|
||||
if (asset != state.currentAsset) {
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||
}
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||
}
|
||||
|
||||
void setOpacity(int opacity) {
|
||||
if (opacity != state.backgroundOpacity) {
|
||||
state = state.copyWith(
|
||||
backgroundOpacity: opacity,
|
||||
showingControls: opacity == 255 ? true : state.showingControls,
|
||||
);
|
||||
}
|
||||
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls);
|
||||
}
|
||||
|
||||
void setBottomSheet(bool showing) {
|
||||
if (showing == state.showingBottomSheet) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(showingBottomSheet: showing, showingControls: showing || state.showingControls);
|
||||
state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls);
|
||||
if (showing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
}
|
||||
}
|
||||
|
||||
void setControls(bool isShowing) {
|
||||
if (isShowing != state.showingControls) {
|
||||
state = state.copyWith(showingControls: isShowing);
|
||||
}
|
||||
state = state.copyWith(showingControls: isShowing);
|
||||
}
|
||||
|
||||
void toggleControls() {
|
||||
@@ -110,9 +98,7 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
|
||||
}
|
||||
|
||||
void setStackIndex(int index) {
|
||||
if (index != state.stackIndex) {
|
||||
state = state.copyWith(stackIndex: index);
|
||||
}
|
||||
state = state.copyWith(stackIndex: index);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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/edit_image_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/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
@@ -30,6 +32,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0;
|
||||
@@ -38,10 +41,16 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final actions = <Widget>[
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer),
|
||||
asset.isLocalOnly
|
||||
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
if (asset.type == AssetType.image) const EditImageActionButton(),
|
||||
if (isOwner) ...[
|
||||
if (asset.hasRemote && isOwner && isArchived)
|
||||
const UnArchiveActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ArchiveActionButton(source: ActionSource.viewer),
|
||||
asset.isLocalOnly
|
||||
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
],
|
||||
];
|
||||
|
||||
return IgnorePointer(
|
||||
|
||||
@@ -6,17 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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_permanent_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/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_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/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/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
@@ -25,6 +14,8 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.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';
|
||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@@ -44,32 +35,25 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id;
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||
|
||||
final actions = <Widget>[
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
if (asset.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.viewer),
|
||||
const ArchiveActionButton(source: ActionSource.viewer),
|
||||
if (!asset.hasLocal) const DownloadActionButton(source: ActionSource.viewer),
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.viewer)
|
||||
: const DeletePermanentActionButton(source: ActionSource.viewer),
|
||||
const DeleteActionButton(source: ActionSource.viewer),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.viewer),
|
||||
],
|
||||
if (asset.storage == AssetState.local) ...[
|
||||
const DeleteLocalActionButton(source: ActionSource.viewer),
|
||||
const UploadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
if (currentAlbum != null) RemoveFromAlbumActionButton(albumId: currentAlbum.id, source: ActionSource.viewer),
|
||||
];
|
||||
final buttonContext = ActionButtonContext(
|
||||
asset: asset,
|
||||
isOwner: isOwner,
|
||||
isArchived: isArchived,
|
||||
isTrashEnabled: isTrashEnable,
|
||||
isInLockedView: isInLockedView,
|
||||
currentAlbum: currentAlbum,
|
||||
source: ActionSource.viewer,
|
||||
);
|
||||
|
||||
final lockedViewActions = <Widget>[];
|
||||
final actions = ActionButtonBuilder.build(buttonContext);
|
||||
|
||||
return BaseBottomSheet(
|
||||
actions: isInLockedView ? lockedViewActions : actions,
|
||||
actions: actions,
|
||||
slivers: const [_AssetDetailBottomSheet()],
|
||||
controller: controller,
|
||||
initialChildSize: initialChildSize,
|
||||
|
||||
@@ -61,7 +61,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 150,
|
||||
height: 160,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
scrollDirection: Axis.horizontal,
|
||||
|
||||
@@ -13,9 +13,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
@@ -28,12 +28,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final album = ref.watch(currentRemoteAlbumProvider);
|
||||
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
|
||||
final previousRouteName = ref.watch(previousRouteNameProvider);
|
||||
final showViewInTimelineButton = previousRouteName != TabShellRoute.name && previousRouteName != null;
|
||||
final showViewInTimelineButton =
|
||||
previousRouteName != TabShellRoute.name &&
|
||||
previousRouteName != AssetViewerRoute.name &&
|
||||
previousRouteName != null;
|
||||
|
||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
@@ -44,10 +49,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
}
|
||||
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
final websocketConnected = ref.watch(websocketProvider.select((c) => c.isConnected));
|
||||
|
||||
final actions = <Widget>[
|
||||
if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true),
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
onPressed: () {
|
||||
context.navigateTo(const DriftActivitiesRoute());
|
||||
},
|
||||
),
|
||||
if (showViewInTimelineButton)
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
@@ -67,7 +78,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
];
|
||||
|
||||
final lockedViewActions = <Widget>[
|
||||
if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true),
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||
const _KebabMenu(),
|
||||
];
|
||||
|
||||
|
||||
@@ -69,10 +69,8 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
|
||||
shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent,
|
||||
builder: (BuildContext context, ScrollController scrollController) {
|
||||
return Card(
|
||||
color: widget.backgroundColor ?? context.colorScheme.surface,
|
||||
borderOnForeground: false,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 6.0,
|
||||
color: widget.backgroundColor ?? context.colorScheme.surfaceContainer,
|
||||
elevation: 3.0,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0),
|
||||
child: CustomScrollView(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
class FullImage extends StatelessWidget {
|
||||
@@ -9,7 +9,7 @@ class FullImage extends StatelessWidget {
|
||||
this.asset, {
|
||||
required this.size,
|
||||
this.fit = BoxFit.cover,
|
||||
this.placeholder = const Thumbnail(),
|
||||
this.placeholder = const ThumbnailPlaceholder(),
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,32 +5,13 @@ import 'package:immich_mobile/domain/services/setting.service.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';
|
||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||
|
||||
abstract class CancellableImageProvider {
|
||||
void cancel();
|
||||
}
|
||||
|
||||
mixin class CancellableImageProviderMixin implements CancellableImageProvider {
|
||||
ImageRequest? request;
|
||||
|
||||
@override
|
||||
void cancel() {
|
||||
final request = this.request;
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
this.request = null;
|
||||
return request.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
|
||||
// Create new provider and cache it
|
||||
final ImageProvider provider;
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
provider = LocalFullImageProvider(id: id, size: size);
|
||||
provider = LocalFullImageProvider(id: id, size: size, type: asset.type, updatedAt: asset.updatedAt);
|
||||
} else {
|
||||
final String assetId;
|
||||
if (asset is LocalAsset && asset.hasRemote) {
|
||||
@@ -55,7 +36,7 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
|
||||
|
||||
if (_shouldUseLocalAsset(asset!)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
return LocalThumbProvider(id: id, size: size);
|
||||
return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, size: size);
|
||||
}
|
||||
|
||||
final String assetId;
|
||||
|
||||
@@ -26,7 +26,7 @@ class LocalAlbumThumbnail extends ConsumerWidget {
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
child: Thumbnail.fromBaseAsset(asset: data),
|
||||
child: Thumbnail(asset: data),
|
||||
);
|
||||
},
|
||||
error: (error, stack) {
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.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/extensions/codec_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||
final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository();
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with CancellableImageProviderMixin {
|
||||
final String id;
|
||||
final DateTime updatedAt;
|
||||
final Size size;
|
||||
|
||||
LocalThumbProvider({required this.id, this.size = kThumbnailResolution});
|
||||
const LocalThumbProvider({
|
||||
required this.id,
|
||||
required this.updatedAt,
|
||||
this.size = kThumbnailResolution,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -21,45 +39,63 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with Cancella
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) {
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
|
||||
DiagnosticsProperty<Size>('Size', key.size),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) async* {
|
||||
final request = this.request = LocalImageRequest(localId: key.id, size: size);
|
||||
try {
|
||||
final image = await request.load(decode);
|
||||
if (image != null) {
|
||||
yield image;
|
||||
}
|
||||
} finally {
|
||||
this.request = null;
|
||||
Future<Codec> _codec(LocalThumbProvider key, CacheManager cache, ImageDecoderCallback decode) async {
|
||||
final cacheKey = '${key.id}-${key.updatedAt}-${key.size.width}x${key.size.height}';
|
||||
|
||||
final fileFromCache = await cache.getFileFromCache(cacheKey);
|
||||
if (fileFromCache != null) {
|
||||
try {
|
||||
final buffer = await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
|
||||
return decode(buffer);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final thumbnailBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
|
||||
if (thumbnailBytes == null) {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
throw StateError("Loading thumb for local photo ${key.id} failed");
|
||||
}
|
||||
|
||||
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
|
||||
unawaited(cache.putFile(cacheKey, thumbnailBytes));
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is LocalThumbProvider) {
|
||||
return id == other.id && size == other.size;
|
||||
return id == other.id && updatedAt == other.updatedAt;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ size.hashCode;
|
||||
int get hashCode => id.hashCode ^ updatedAt.hashCode;
|
||||
}
|
||||
|
||||
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with CancellableImageProviderMixin {
|
||||
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
||||
final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository();
|
||||
final StorageRepository _storageRepository = const StorageRepository();
|
||||
|
||||
final String id;
|
||||
final Size size;
|
||||
final AssetType type;
|
||||
final DateTime updatedAt; // temporary, only exists to fetch cached thumbnail until local disk cache is removed
|
||||
|
||||
LocalFullImageProvider({required this.id, required this.size});
|
||||
const LocalFullImageProvider({required this.id, required this.size, required this.type, required this.updatedAt});
|
||||
|
||||
@override
|
||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -70,41 +106,114 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with
|
||||
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getCachedImage(LocalThumbProvider(id: key.id)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
|
||||
DiagnosticsProperty<Size>('Size', key.size),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
final request = this.request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
);
|
||||
|
||||
try {
|
||||
final image = await request.load(decode);
|
||||
if (image != null) {
|
||||
yield image;
|
||||
// First, yield the thumbnail image from LocalThumbProvider
|
||||
final thumbProvider = LocalThumbProvider(id: key.id, updatedAt: key.updatedAt);
|
||||
try {
|
||||
final thumbCodec = await thumbProvider._codec(
|
||||
thumbProvider,
|
||||
thumbProvider.cacheManager ?? ThumbnailImageCacheManager(),
|
||||
decode,
|
||||
);
|
||||
final thumbImageInfo = await thumbCodec.getImageInfo();
|
||||
yield thumbImageInfo;
|
||||
} catch (_) {}
|
||||
|
||||
// Then proceed with the main image loading stream
|
||||
final mainStream = switch (key.type) {
|
||||
AssetType.image => _decodeProgressive(key, decode),
|
||||
AssetType.video => _getThumbnailCodec(key, decode),
|
||||
_ => throw StateError('Unsupported asset type ${key.type}'),
|
||||
};
|
||||
|
||||
await for (final imageInfo in mainStream) {
|
||||
yield imageInfo;
|
||||
}
|
||||
} finally {
|
||||
this.request = null;
|
||||
} catch (error, stack) {
|
||||
Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack);
|
||||
throw const ImageLoadingException('Could not load image from local storage');
|
||||
}
|
||||
}
|
||||
|
||||
Stream<ImageInfo> _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
|
||||
if (thumbBytes == null) {
|
||||
throw StateError("Failed to load preview for ${key.id}");
|
||||
}
|
||||
final buffer = await ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield await codec.getImageInfo();
|
||||
}
|
||||
|
||||
Stream<ImageInfo> _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
final file = await _storageRepository.getFileForAsset(key.id);
|
||||
if (file == null) {
|
||||
throw StateError("Opening file for asset ${key.id} failed");
|
||||
}
|
||||
|
||||
final fileSize = await file.length();
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
final isLargeFile = fileSize > 20 * 1024 * 1024; // 20MB
|
||||
final isHEIC = file.path.toLowerCase().contains(RegExp(r'\.(heic|heif)$'));
|
||||
final isProgressive = isLargeFile || (isHEIC && !Platform.isIOS);
|
||||
|
||||
if (isProgressive) {
|
||||
try {
|
||||
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 1.3 : 1.2;
|
||||
final size = Size(
|
||||
(key.size.width * progressiveMultiplier).clamp(256, 1024),
|
||||
(key.size.height * progressiveMultiplier).clamp(256, 1024),
|
||||
);
|
||||
final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
|
||||
if (mediumThumb != null) {
|
||||
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
|
||||
final codec = await decode(mediumBuffer);
|
||||
yield await codec.getImageInfo();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Load original only when the file is smaller or if the user wants to load original images
|
||||
// Or load a slightly larger image for progressive loading
|
||||
if (isProgressive && !(AppSetting.get(Setting.loadOriginal))) {
|
||||
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 2.0 : 1.6;
|
||||
final size = Size(
|
||||
(key.size.width * progressiveMultiplier).clamp(512, 2048),
|
||||
(key.size.height * progressiveMultiplier).clamp(512, 2048),
|
||||
);
|
||||
final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
|
||||
if (highThumb != null) {
|
||||
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
|
||||
final codec = await decode(highBuffer);
|
||||
yield await codec.getImageInfo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final buffer = await ImmutableBuffer.fromFilePath(file.path);
|
||||
final codec = await decode(buffer);
|
||||
yield await codec.getImageInfo();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is LocalFullImageProvider) {
|
||||
return id == other.id && size == other.size;
|
||||
return id == other.id && size == other.size && type == other.type;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ size.hashCode;
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/extensions/codec_extensions.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';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> with CancellableImageProviderMixin {
|
||||
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
|
||||
final String assetId;
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
RemoteThumbProvider({required this.assetId, this.cacheManager});
|
||||
const RemoteThumbProvider({required this.assetId, this.cacheManager});
|
||||
|
||||
@override
|
||||
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -24,8 +26,12 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> with Cancel
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
final cache = cacheManager ?? RemoteImageCacheManager();
|
||||
final chunkController = StreamController<ImageChunkEvent>();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkController),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkController.stream,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
@@ -33,17 +39,20 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> with Cancel
|
||||
);
|
||||
}
|
||||
|
||||
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) async* {
|
||||
Future<Codec> _codec(
|
||||
RemoteThumbProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkController,
|
||||
) async {
|
||||
final preview = getThumbnailUrlForRemoteId(key.assetId);
|
||||
final request = this.request = RemoteImageRequest(uri: preview, headers: ApiService.getRequestHeaders());
|
||||
try {
|
||||
final image = await request.load(decode);
|
||||
if (image != null) {
|
||||
yield image;
|
||||
}
|
||||
} finally {
|
||||
this.request = null;
|
||||
}
|
||||
|
||||
return ImageLoader.loadImageFromCache(
|
||||
preview,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkController,
|
||||
).whenComplete(chunkController.close);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -60,11 +69,11 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> with Cancel
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
||||
|
||||
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> with CancellableImageProviderMixin {
|
||||
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
|
||||
final String assetId;
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
RemoteFullImageProvider({required this.assetId, this.cacheManager});
|
||||
const RemoteFullImageProvider({required this.assetId, this.cacheManager});
|
||||
|
||||
@override
|
||||
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -73,44 +82,28 @@ class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> wit
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
||||
final cache = cacheManager ?? RemoteImageCacheManager();
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getCachedImage(RemoteThumbProvider(assetId: assetId)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
],
|
||||
_codec(key, cache, decode),
|
||||
initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)),
|
||||
);
|
||||
}
|
||||
|
||||
Stream<ImageInfo> _codec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
try {
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getPreviewUrlForRemoteId(key.assetId),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
);
|
||||
final image = await request.load(decode);
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
yield image;
|
||||
} finally {
|
||||
request = null;
|
||||
}
|
||||
Stream<ImageInfo> _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* {
|
||||
final codec = await ImageLoader.loadImageFromCache(
|
||||
getPreviewUrlForRemoteId(key.assetId),
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
);
|
||||
yield await codec.getImageInfo();
|
||||
|
||||
if (AppSetting.get(Setting.loadOriginal)) {
|
||||
try {
|
||||
final request = this.request = RemoteImageRequest(
|
||||
uri: getOriginalUrlForRemoteId(key.assetId),
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
);
|
||||
final image = await request.load(decode);
|
||||
if (image != null) {
|
||||
yield image;
|
||||
}
|
||||
} finally {
|
||||
request = null;
|
||||
}
|
||||
final codec = await ImageLoader.loadImageFromCache(
|
||||
getOriginalUrlForRemoteId(key.assetId),
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
);
|
||||
yield await codec.getImageInfo();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'dart:convert' hide Codec;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:thumbhash/thumbhash.dart';
|
||||
|
||||
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
|
||||
final String thumbHash;
|
||||
|
||||
const ThumbHashProvider({required this.thumbHash});
|
||||
|
||||
@override
|
||||
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
|
||||
return MultiFrameImageStreamCompleter(codec: _loadCodec(key, decode), scale: 1.0);
|
||||
}
|
||||
|
||||
Future<Codec> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) async {
|
||||
final image = thumbHashToRGBA(base64Decode(key.thumbHash));
|
||||
return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image)));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ThumbHashProvider) {
|
||||
return thumbHash == other.thumbHash;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => thumbHash.hashCode;
|
||||
}
|
||||
@@ -1,372 +1,61 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.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';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:thumbhash/thumbhash.dart' as thumbhash;
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
final log = Logger('ThumbnailWidget');
|
||||
class Thumbnail extends StatelessWidget {
|
||||
const Thumbnail({this.asset, this.remoteId, this.size = const Size.square(256), this.fit = BoxFit.cover, super.key})
|
||||
: assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
|
||||
|
||||
enum ThumbhashMode { enabled, disabled, only }
|
||||
|
||||
class Thumbnail extends StatefulWidget {
|
||||
final ImageProvider? imageProvider;
|
||||
final BaseAsset? asset;
|
||||
final String? remoteId;
|
||||
final Size size;
|
||||
final BoxFit fit;
|
||||
final ui.Size size;
|
||||
final String? blurhash;
|
||||
final ThumbhashMode thumbhashMode;
|
||||
|
||||
const Thumbnail({
|
||||
this.imageProvider,
|
||||
this.fit = BoxFit.cover,
|
||||
this.size = kThumbnailResolution,
|
||||
this.blurhash,
|
||||
this.thumbhashMode = ThumbhashMode.enabled,
|
||||
super.key,
|
||||
});
|
||||
|
||||
Thumbnail.fromAsset({
|
||||
required Asset asset,
|
||||
this.fit = BoxFit.cover,
|
||||
this.size = kThumbnailResolution,
|
||||
this.thumbhashMode = ThumbhashMode.enabled,
|
||||
super.key,
|
||||
}) : blurhash = asset.thumbhash,
|
||||
imageProvider = _getImageProviderFromAsset(asset, size);
|
||||
|
||||
Thumbnail.fromBaseAsset({
|
||||
required BaseAsset? asset,
|
||||
this.fit = BoxFit.cover,
|
||||
this.size = kThumbnailResolution,
|
||||
this.thumbhashMode = ThumbhashMode.enabled,
|
||||
super.key,
|
||||
}) : blurhash = switch (asset) {
|
||||
RemoteAsset() => asset.thumbHash,
|
||||
_ => null,
|
||||
},
|
||||
imageProvider = _getImageProviderFromBaseAsset(asset, size);
|
||||
|
||||
static ImageProvider? _getImageProviderFromAsset(Asset asset, ui.Size size) {
|
||||
if (asset.localId != null) {
|
||||
return LocalThumbProvider(id: asset.localId!, size: size);
|
||||
} else if (asset.remoteId != null) {
|
||||
return RemoteThumbProvider(assetId: asset.remoteId!);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static ImageProvider? _getImageProviderFromBaseAsset(BaseAsset? asset, ui.Size size) {
|
||||
switch (asset) {
|
||||
case RemoteAsset():
|
||||
if (asset.localId != null) {
|
||||
return LocalThumbProvider(id: asset.localId!, size: size);
|
||||
} else {
|
||||
return RemoteThumbProvider(assetId: asset.id);
|
||||
}
|
||||
case LocalAsset():
|
||||
return LocalThumbProvider(id: asset.id, size: size);
|
||||
case null:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
State<Thumbnail> createState() => _ThumbnailState();
|
||||
}
|
||||
|
||||
class _ThumbnailState extends State<Thumbnail> {
|
||||
ui.Image? _providerImage;
|
||||
ImageStream? _imageStream;
|
||||
ImageStreamListener? _imageStreamListener;
|
||||
|
||||
static final _gradientCache = <ColorScheme, Gradient>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Thumbnail oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.imageProvider != oldWidget.imageProvider) {
|
||||
return _loadImage();
|
||||
}
|
||||
|
||||
if (_providerImage != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((oldWidget.thumbhashMode == ThumbhashMode.disabled && widget.thumbhashMode != ThumbhashMode.disabled) ||
|
||||
(oldWidget.thumbhashMode == ThumbhashMode.only && widget.thumbhashMode != ThumbhashMode.only) ||
|
||||
(widget.thumbhashMode != ThumbhashMode.disabled && oldWidget.blurhash != widget.blurhash)) {
|
||||
_loadImage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
super.reassemble();
|
||||
_loadImage();
|
||||
}
|
||||
|
||||
void _loadImage() {
|
||||
_stopListeningToStream();
|
||||
if (widget.thumbhashMode != ThumbhashMode.only && widget.imageProvider != null) {
|
||||
_loadFromProvider();
|
||||
}
|
||||
|
||||
if (widget.thumbhashMode != ThumbhashMode.disabled && widget.blurhash != null) {
|
||||
_decodeThumbhash();
|
||||
}
|
||||
}
|
||||
|
||||
void _loadFromProvider() {
|
||||
final imageProvider = widget.imageProvider;
|
||||
if (imageProvider == null) return;
|
||||
|
||||
_imageStream = imageProvider.resolve(ImageConfiguration.empty);
|
||||
_imageStreamListener = ImageStreamListener(
|
||||
(ImageInfo imageInfo, bool synchronousCall) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (_providerImage != imageInfo.image) {
|
||||
setState(() {
|
||||
_providerImage = imageInfo.image;
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (exception, stackTrace) {
|
||||
log.severe('Error loading image: $exception', exception, stackTrace);
|
||||
},
|
||||
);
|
||||
_imageStream?.addListener(_imageStreamListener!);
|
||||
}
|
||||
|
||||
void _stopListeningToStream() {
|
||||
if (_imageStreamListener != null && _imageStream != null) {
|
||||
_imageStream!.removeListener(_imageStreamListener!);
|
||||
}
|
||||
_imageStream = null;
|
||||
_imageStreamListener = null;
|
||||
}
|
||||
|
||||
Future<void> _decodeThumbhash() async {
|
||||
final blurhash = widget.blurhash;
|
||||
if (blurhash == null || !mounted || _providerImage != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final image = thumbhash.thumbHashToRGBA(base64.decode(blurhash));
|
||||
final buffer = await ImmutableBuffer.fromUint8List(image.rgba);
|
||||
if (!mounted || _providerImage != null) {
|
||||
buffer.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final descriptor = ImageDescriptor.raw(
|
||||
buffer,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
pixelFormat: PixelFormat.rgba8888,
|
||||
);
|
||||
|
||||
final codec = await descriptor.instantiateCodec();
|
||||
|
||||
if (!mounted || _providerImage != null) {
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final frame = (await codec.getNextFrame()).image;
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
|
||||
if (!mounted || _providerImage != null) {
|
||||
frame.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_providerImage = frame;
|
||||
});
|
||||
} catch (e) {
|
||||
log.severe('Error decoding thumbhash: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
final gradient = _gradientCache[colorScheme] ??= LinearGradient(
|
||||
colors: [colorScheme.surfaceContainer, colorScheme.surfaceContainer.darken(amount: .1)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
|
||||
final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId);
|
||||
|
||||
return OctoImage.fromSet(
|
||||
image: provider,
|
||||
octoSet: OctoSet(
|
||||
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit),
|
||||
errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, fit: fit, asset: asset),
|
||||
),
|
||||
fadeOutDuration: const Duration(milliseconds: 100),
|
||||
fadeInDuration: Duration.zero,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fit: fit,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
);
|
||||
|
||||
return _ThumbnailLeaf(image: _providerImage, fit: widget.fit, placeholderGradient: gradient);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopListeningToStream();
|
||||
_providerImage?.dispose();
|
||||
final imageProvider = widget.imageProvider;
|
||||
if (imageProvider is CancellableImageProvider) {
|
||||
(imageProvider as CancellableImageProvider).cancel();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _ThumbnailLeaf extends LeafRenderObjectWidget {
|
||||
final ui.Image? image;
|
||||
final BoxFit fit;
|
||||
final Gradient placeholderGradient;
|
||||
|
||||
const _ThumbnailLeaf({required this.image, required this.fit, required this.placeholderGradient});
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _ThumbnailRenderBox(image: image, fit: fit, placeholderGradient: placeholderGradient);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, _ThumbnailRenderBox renderObject) {
|
||||
renderObject.fit = fit;
|
||||
renderObject.image = image;
|
||||
renderObject.placeholderGradient = placeholderGradient;
|
||||
}
|
||||
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash, {BoxFit? fit}) {
|
||||
return (context) => thumbHash == null
|
||||
? const ThumbnailPlaceholder()
|
||||
: FadeInPlaceholderImage(
|
||||
placeholder: const ThumbnailPlaceholder(),
|
||||
image: ThumbHashProvider(thumbHash: thumbHash),
|
||||
fit: fit ?? BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
class _ThumbnailRenderBox extends RenderBox {
|
||||
ui.Image? _image;
|
||||
ui.Image? _previousImage;
|
||||
BoxFit _fit;
|
||||
Gradient _placeholderGradient;
|
||||
DateTime _lastImageRequest;
|
||||
|
||||
double _crossFadeProgress = 1.0;
|
||||
static const _fadeDuration = Duration(milliseconds: 100);
|
||||
DateTime? _fadeStartTime;
|
||||
|
||||
@override
|
||||
bool isRepaintBoundary = true;
|
||||
|
||||
_ThumbnailRenderBox({required ui.Image? image, required BoxFit fit, required Gradient placeholderGradient})
|
||||
: _image = image,
|
||||
_fit = fit,
|
||||
_placeholderGradient = placeholderGradient,
|
||||
_lastImageRequest = DateTime.now();
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
final rect = offset & size;
|
||||
final canvas = context.canvas;
|
||||
|
||||
if (_fadeStartTime != null) {
|
||||
final elapsed = DateTime.now().difference(_fadeStartTime!);
|
||||
_crossFadeProgress = (elapsed.inMilliseconds / _fadeDuration.inMilliseconds).clamp(0.0, 1.0);
|
||||
|
||||
if (_crossFadeProgress < 1.0) {
|
||||
SchedulerBinding.instance.scheduleFrameCallback((_) {
|
||||
markNeedsPaint();
|
||||
});
|
||||
} else {
|
||||
_previousImage?.dispose();
|
||||
_previousImage = null;
|
||||
_fadeStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (_previousImage != null && _crossFadeProgress < 1.0) {
|
||||
paintImage(
|
||||
canvas: canvas,
|
||||
rect: rect,
|
||||
image: _previousImage!,
|
||||
fit: _fit,
|
||||
filterQuality: FilterQuality.low,
|
||||
opacity: 1.0 - _crossFadeProgress,
|
||||
OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider, BoxFit? fit}) =>
|
||||
(context, e, s) {
|
||||
Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s);
|
||||
provider?.evict();
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_blurHashPlaceholderBuilder(blurhash, fit: fit)(context),
|
||||
const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)),
|
||||
],
|
||||
);
|
||||
} else if (_image == null) {
|
||||
final paint = Paint();
|
||||
paint.shader = _placeholderGradient.createShader(rect);
|
||||
canvas.drawRect(rect, paint);
|
||||
}
|
||||
|
||||
if (_image != null) {
|
||||
paintImage(
|
||||
canvas: canvas,
|
||||
rect: rect,
|
||||
image: _image!,
|
||||
fit: _fit,
|
||||
filterQuality: FilterQuality.low,
|
||||
opacity: _crossFadeProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
size = constraints.biggest;
|
||||
}
|
||||
|
||||
set image(ui.Image? value) {
|
||||
if (_image == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
final time = DateTime.now();
|
||||
if (time.difference(_lastImageRequest).inMilliseconds >= 16) {
|
||||
_fadeStartTime = time;
|
||||
_previousImage = _image;
|
||||
}
|
||||
_image = value;
|
||||
_lastImageRequest = time;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
set fit(BoxFit value) {
|
||||
if (_fit == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
_fit = value;
|
||||
if (_image != null) {
|
||||
markNeedsPaint();
|
||||
}
|
||||
}
|
||||
|
||||
set placeholderGradient(Gradient value) {
|
||||
if (_placeholderGradient == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
_placeholderGradient = value;
|
||||
if (_image == null) {
|
||||
markNeedsPaint();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
_previousImage?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,15 +7,13 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class ThumbnailTile extends ConsumerWidget {
|
||||
const ThumbnailTile(
|
||||
this.asset, {
|
||||
this.size = kTimelineFixedTileExtent,
|
||||
this.size = const Size.square(256),
|
||||
this.fit = BoxFit.cover,
|
||||
this.showStorageIndicator,
|
||||
this.lockSelection = false,
|
||||
@@ -23,7 +21,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BaseAsset? asset;
|
||||
final BaseAsset asset;
|
||||
final Size size;
|
||||
final BoxFit fit;
|
||||
final bool? showStorageIndicator;
|
||||
@@ -32,7 +30,6 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = this.asset;
|
||||
final heroIndex = heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
|
||||
final assetContainerColor = context.isDarkTheme
|
||||
@@ -42,7 +39,6 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
final isSelected = ref.watch(
|
||||
multiSelectProvider.select((multiselect) => multiselect.selectedAssets.contains(asset)),
|
||||
);
|
||||
final isScrubbing = ref.watch(timelineStateProvider.select((state) => state.isScrubbing));
|
||||
|
||||
final borderStyle = lockSelection
|
||||
? BoxDecoration(
|
||||
@@ -56,6 +52,8 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
)
|
||||
: const BoxDecoration();
|
||||
|
||||
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
|
||||
|
||||
final bool storageIndicator =
|
||||
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
|
||||
|
||||
@@ -73,34 +71,19 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Hero(
|
||||
tag: '${asset?.heroTag ?? ''}_$heroIndex',
|
||||
child: Thumbnail.fromBaseAsset(
|
||||
asset: asset,
|
||||
thumbhashMode: isScrubbing
|
||||
? ThumbhashMode.only
|
||||
: asset != null && asset.hasLocal
|
||||
? ThumbhashMode.disabled
|
||||
: ThumbhashMode.enabled,
|
||||
),
|
||||
tag: '${asset.heroTag}_$heroIndex',
|
||||
child: Thumbnail(asset: asset, fit: fit, size: size),
|
||||
),
|
||||
),
|
||||
if (asset is RemoteAsset && asset.stackId != null)
|
||||
asset.isVideo
|
||||
? const Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 10.0, top: 24.0),
|
||||
child: _TileOverlayIcon(Icons.burst_mode_rounded),
|
||||
),
|
||||
)
|
||||
: const Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 10.0, top: 6.0),
|
||||
child: _TileOverlayIcon(Icons.burst_mode_rounded),
|
||||
),
|
||||
),
|
||||
if (asset != null && asset.isVideo)
|
||||
if (hasStack)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 10.0, top: asset.isVideo ? 24.0 : 6.0),
|
||||
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
|
||||
),
|
||||
),
|
||||
if (asset.isVideo)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
@@ -108,7 +91,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
child: _VideoIndicator(asset.duration),
|
||||
),
|
||||
),
|
||||
if (storageIndicator && asset != null)
|
||||
if (storageIndicator)
|
||||
switch (asset.storage) {
|
||||
AssetState.local => const Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
@@ -132,7 +115,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
},
|
||||
if (asset != null && asset.isFavorite)
|
||||
if (asset.isFavorite)
|
||||
const Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/full_image.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/utils/hooks/blurhash_hook.dart';
|
||||
|
||||
class DriftMemoryCard extends StatelessWidget {
|
||||
final RemoteAsset asset;
|
||||
@@ -88,26 +88,31 @@ class _BlurredBackdrop extends HookWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blurhash = asset.thumbHash;
|
||||
final blurhash = useDriftBlurHashRef(asset).value;
|
||||
if (blurhash != null) {
|
||||
// Use a nice cheap blur hash image decoration
|
||||
return Thumbnail(blurhash: blurhash);
|
||||
}
|
||||
|
||||
// Fall back to using a more expensive image filtered
|
||||
// Since the ImmichImage is already precached, we can
|
||||
// safely use that as the image provider
|
||||
return ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: DecoratedBox(
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
image: DecorationImage(image: MemoryImage(blurhash), fit: BoxFit.cover),
|
||||
),
|
||||
child: const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.2)),
|
||||
),
|
||||
);
|
||||
child: Container(color: Colors.black.withValues(alpha: 0.2)),
|
||||
);
|
||||
} else {
|
||||
// Fall back to using a more expensive image filtered
|
||||
// Since the ImmichImage is already precached, we can
|
||||
// safely use that as the image provider
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,11 @@ class DriftMemoryCard extends ConsumerWidget {
|
||||
children: [
|
||||
ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken),
|
||||
child: SizedBox(width: 205, height: 200, child: Thumbnail.fromBaseAsset(asset: memory.assets[0])),
|
||||
child: SizedBox(
|
||||
width: 205,
|
||||
height: 200,
|
||||
child: Thumbnail(remoteId: memory.assets[0].id, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:ui';
|
||||
|
||||
const double kTimelineHeaderExtent = 80.0;
|
||||
const Size kTimelineFixedTileExtent = Size.square(256);
|
||||
const Size kThumbnailResolution = Size.square(384);
|
||||
const Size kThumbnailResolution = kTimelineFixedTileExtent;
|
||||
const double kTimelineSpacing = 2.0;
|
||||
const int kTimelineColumnCount = 3;
|
||||
|
||||
|
||||
@@ -4,19 +4,16 @@ import 'package:auto_route/auto_route.dart';
|
||||
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/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.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';
|
||||
@@ -79,21 +76,6 @@ class FixedSegment extends Segment {
|
||||
spacing: spacing,
|
||||
);
|
||||
}
|
||||
|
||||
const FixedSegment.empty()
|
||||
: this(
|
||||
firstIndex: 0,
|
||||
lastIndex: 0,
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
firstAssetIndex: 0,
|
||||
bucket: const Bucket(assetCount: 0),
|
||||
tileHeight: 1,
|
||||
columnCount: 0,
|
||||
headerExtent: 0,
|
||||
spacing: 0,
|
||||
header: HeaderType.none,
|
||||
);
|
||||
}
|
||||
|
||||
class _FixedSegmentRow extends ConsumerWidget {
|
||||
@@ -111,45 +93,58 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
try {
|
||||
final assets = timelineService.getAssets(assetIndex, assetCount);
|
||||
return _buildAssetRow(context, assets, timelineService);
|
||||
} catch (e) {
|
||||
return FutureBuilder<List<BaseAsset>>(
|
||||
future: timelineService.loadAssets(assetIndex, assetCount),
|
||||
builder: (context, snapshot) {
|
||||
return _buildAssetRow(context, snapshot.data, timelineService);
|
||||
},
|
||||
);
|
||||
|
||||
if (isScrubbing) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
|
||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
|
||||
}
|
||||
|
||||
return FutureBuilder<List<BaseAsset>>(
|
||||
future: timelineService.loadAssets(assetIndex, assetCount),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset>? assets, TimelineService timelineService) {
|
||||
final assetIndex = this.assetIndex;
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
|
||||
return FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: List.generate(assetCount, (i) {
|
||||
final curAssetIndex = assetIndex + i;
|
||||
return TimelineAssetIndexWrapper(
|
||||
// this key is intentionally generic to preserve the state of the widget and its subtree
|
||||
key: ValueKey(i.hashCode ^ timelineService.hashCode),
|
||||
assetIndex: curAssetIndex,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(asset: assets?[i], assetIndex: curAssetIndex),
|
||||
);
|
||||
}, growable: false),
|
||||
children: [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
assetIndex: assetIndex + i,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetTileWidget extends ConsumerWidget {
|
||||
final BaseAsset? asset;
|
||||
final BaseAsset asset;
|
||||
final int assetIndex;
|
||||
|
||||
const _AssetTileWidget({required this.asset, required this.assetIndex});
|
||||
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
|
||||
|
||||
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
|
||||
final multiSelectState = ref.read(multiSelectProvider);
|
||||
@@ -159,12 +154,6 @@ class _AssetTileWidget extends ConsumerWidget {
|
||||
} else {
|
||||
await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1);
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
||||
if (asset.isVideo || asset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
}
|
||||
ctx.pushRoute(
|
||||
AssetViewerRoute(
|
||||
initialIndex: assetIndex,
|
||||
@@ -201,16 +190,17 @@ class _AssetTileWidget extends ConsumerWidget {
|
||||
|
||||
final lockSelection = _getLockSelectionStatus(ref);
|
||||
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
|
||||
final asset = this.asset;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => lockSelection || asset == null ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
|
||||
onLongPress: () => lockSelection || asset == null ? null : _handleOnLongPress(ref, asset),
|
||||
child: ThumbnailTile(
|
||||
asset,
|
||||
lockSelection: lockSelection,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
heroOffset: heroOffset,
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
|
||||
onLongPress: () => lockSelection ? null : _handleOnLongPress(ref, asset),
|
||||
child: ThumbnailTile(
|
||||
asset,
|
||||
lockSelection: lockSelection,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
heroOffset: heroOffset,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/extensions/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
|
||||
@@ -7,7 +6,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart
|
||||
class FixedSegmentBuilder extends SegmentBuilder {
|
||||
final double tileHeight;
|
||||
final int columnCount;
|
||||
static final DateTime _dummyDate = DateTime.fromMicrosecondsSinceEpoch(0);
|
||||
|
||||
const FixedSegmentBuilder({
|
||||
required super.buckets,
|
||||
@@ -18,11 +16,12 @@ class FixedSegmentBuilder extends SegmentBuilder {
|
||||
});
|
||||
|
||||
List<Segment> generate() {
|
||||
final segments = List.filled(buckets.length, const FixedSegment.empty());
|
||||
final segments = <Segment>[];
|
||||
int firstIndex = 0;
|
||||
double startOffset = 0;
|
||||
int assetIndex = 0;
|
||||
DateTime previousDate = _dummyDate;
|
||||
DateTime? previousDate;
|
||||
|
||||
for (int i = 0; i < buckets.length; i++) {
|
||||
final bucket = buckets[i];
|
||||
|
||||
@@ -33,10 +32,11 @@ class FixedSegmentBuilder extends SegmentBuilder {
|
||||
final segmentFirstIndex = firstIndex;
|
||||
firstIndex += segmentCount;
|
||||
final segmentLastIndex = firstIndex - 1;
|
||||
|
||||
final timelineHeader = switch (groupBy) {
|
||||
GroupAssetsBy.month => HeaderType.month,
|
||||
GroupAssetsBy.day || GroupAssetsBy.auto =>
|
||||
bucket is TimeBucket && !previousDate.isSameMonth(bucket.date) ? HeaderType.monthAndDay : HeaderType.day,
|
||||
bucket is TimeBucket && bucket.date.month != previousDate?.month ? HeaderType.monthAndDay : HeaderType.day,
|
||||
GroupAssetsBy.none => HeaderType.none,
|
||||
};
|
||||
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
|
||||
@@ -45,18 +45,20 @@ class FixedSegmentBuilder extends SegmentBuilder {
|
||||
startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
|
||||
final segmentEndOffset = startOffset;
|
||||
|
||||
segments[i] = FixedSegment(
|
||||
firstIndex: segmentFirstIndex,
|
||||
lastIndex: segmentLastIndex,
|
||||
startOffset: segmentStartOffset,
|
||||
endOffset: segmentEndOffset,
|
||||
firstAssetIndex: assetIndex,
|
||||
bucket: bucket,
|
||||
tileHeight: tileHeight,
|
||||
columnCount: columnCount,
|
||||
headerExtent: headerExtent,
|
||||
spacing: spacing,
|
||||
header: timelineHeader,
|
||||
segments.add(
|
||||
FixedSegment(
|
||||
firstIndex: segmentFirstIndex,
|
||||
lastIndex: segmentLastIndex,
|
||||
startOffset: segmentStartOffset,
|
||||
endOffset: segmentEndOffset,
|
||||
firstAssetIndex: assetIndex,
|
||||
bucket: bucket,
|
||||
tileHeight: tileHeight,
|
||||
columnCount: columnCount,
|
||||
headerExtent: headerExtent,
|
||||
spacing: spacing,
|
||||
header: timelineHeader,
|
||||
),
|
||||
);
|
||||
|
||||
assetIndex += assetCount;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user