Compare commits

..

16 Commits

Author SHA1 Message Date
mertalev
e639041b84 ios fixes and improvements 2025-08-12 22:42:43 -04:00
mertalev
0f772ea875 fix ref cycle 2025-08-11 11:48:50 -04:00
mertalev
2c5fc4e953 remove unused parameter 2025-08-11 02:36:30 -04:00
mertalev
2d0c39ed04 update constants 2025-08-11 02:32:42 -04:00
mertalev
86ee4ff822 request cancellation 2025-08-11 01:57:45 -04:00
mertalev
cb51300356 add ffi dependency
formatting
2025-08-11 01:08:57 -04:00
mertalev
98c1f3c476 buttery hero animations
buttery hero animation for remote assets
2025-08-11 01:08:57 -04:00
mertalev
be0fe36210 preserve subtree
not growable

minor cleanup
2025-08-11 01:08:57 -04:00
mertalev
4f19dacecd formatting 2025-08-11 01:08:57 -04:00
mertalev
b81081e777 disable thumbhash for local assets
better update condition

load from provider first

show thumbhash for merged assets when scrubbing

fix disposal
2025-08-11 01:08:57 -04:00
mertalev
97a6c6d7a0 cleanup
restore xcode settings

formatting

restore xcode settings

fix rebase
2025-08-11 01:08:57 -04:00
mertalev
fc6415cdc3 transition 2025-08-11 01:08:57 -04:00
mertalev
af2a4ae3fc light at the end of the tunnel 2025-08-11 01:08:57 -04:00
mertalev
a67374df75 draw to buffer
inline

scale video frame when possible

account for different dimensions
2025-08-11 01:08:57 -04:00
mertalev
f9687888b0 kotlin impl, avoid message passing overhead 2025-08-11 01:08:57 -04:00
mertalev
ddd65dea58 thumbhash improvements
thumbhash render box

refactor

wip

rebase
2025-08-11 01:08:57 -04:00
195 changed files with 64853 additions and 31345 deletions

View File

@@ -49,11 +49,10 @@ 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" \

View File

@@ -8,13 +8,21 @@ 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
@@ -25,10 +33,17 @@ 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:

View File

@@ -3,20 +3,15 @@
# 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"jj
log "Immich workspace not found"
exit 1
)
while true; do
run_cmd pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch
run_cmd node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch
log "Nest API Server crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -3,13 +3,6 @@
# 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" || (
@@ -23,7 +16,7 @@ until curl --output /dev/null --silent --head --fail "http://127.0.0.1:${IMMICH_
done
while true; do
run_cmd pnpm --filter immich-web exec vite dev --host 0.0.0.0 --port "${DEV_PORT}"
run_cmd node ./node_modules/.bin/vite dev --host 0.0.0.0 --port "${DEV_PORT}"
log "Web crashed with exit code $?. Respawning in 3s ..."
sleep 3
done

View File

@@ -6,6 +6,9 @@ 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 Normal file
View File

@@ -0,0 +1,28 @@
{
"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"
}
}
}
}

View File

@@ -33,24 +33,21 @@ 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
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- 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
- 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
if: ${{ github.event_name == 'release' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -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. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
-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 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. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \
-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 query='
mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) {
addDiscussionComment(input: {

View File

@@ -55,24 +55,21 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run install
run: pnpm install
- name: Run npm install
run: npm ci
- name: Check formatting
run: pnpm format
run: npm run format
- name: Run build
run: pnpm build
run: npm run build
- name: Upload build output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2

View File

@@ -32,8 +32,8 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Fix formatting
run: make install-all && make format-all

View File

@@ -20,21 +20,18 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install deps
run: pnpm install --frozen-lockfile
run: npm ci
- name: Build
run: pnpm build
run: npm run build
- name: Publish
run: pnpm publish
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -4,10 +4,13 @@ on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
pre-job:
runs-on: ubuntu-latest
@@ -29,6 +32,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- id: found_paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
@@ -54,9 +58,11 @@ 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
@@ -67,33 +73,39 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run package manager install
run: pnpm install
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
- name: Run linter
run: pnpm lint
run: npm run lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
run: npm run format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
run: npm run check
if: ${{ !cancelled() }}
- name: Run small tests & coverage
run: pnpm test
run: npm test
if: ${{ !cancelled() }}
cli-unit-tests:
name: Unit Test CLI
needs: pre-job
@@ -104,36 +116,43 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk
run: pnpm install && pnpm run build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: pnpm install
run: npm ci
- name: Run linter
run: pnpm lint
run: npm run lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
run: npm run format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
run: npm run check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
run: npm run test
if: ${{ !cancelled() }}
cli-unit-tests-win:
name: Unit Test CLI (Windows)
needs: pre-job
@@ -144,31 +163,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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: pnpm install --frozen-lockfile
run: npm ci
# Skip linter & formatter in Windows test.
- name: Run tsc
run: pnpm check
run: npm run check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
run: npm run test
if: ${{ !cancelled() }}
web-lint:
name: Lint Web
needs: pre-job
@@ -179,33 +203,39 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Run pnpm install
run: pnpm rebuild && pnpm install --frozen-lockfile
- name: Run npm install
run: npm ci
- name: Run linter
run: pnpm lint:p
run: npm run lint:p
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
run: npm run format
if: ${{ !cancelled() }}
- name: Run svelte checks
run: pnpm check:svelte
run: npm run check:svelte
if: ${{ !cancelled() }}
web-unit-tests:
name: Test Web
needs: pre-job
@@ -216,30 +246,35 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Run npm install
run: pnpm install --frozen-lockfile
run: npm ci
- name: Run tsc
run: pnpm check:typescript
run: npm run check:typescript
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
run: npm run test
if: ${{ !cancelled() }}
i18n-tests:
name: Test i18n
needs: pre-job
@@ -252,24 +287,27 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install dependencies
run: pnpm --filter=immich-web install --frozen-lockfile
run: npm --prefix=web ci
- name: Format
run: pnpm --filter=immich-web format:i18n
run: npm --prefix=web run 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:
@@ -278,6 +316,7 @@ 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
@@ -288,35 +327,41 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: npm ci
if: ${{ !cancelled() }}
- name: Run linter
run: pnpm lint
run: npm run lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
run: npm run format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
run: npm run check
if: ${{ !cancelled() }}
server-medium-tests:
name: Medium Tests (Server)
needs: pre-job
@@ -327,24 +372,27 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
- name: Run medium tests
run: pnpm test:medium
run: npm run test:medium
if: ${{ !cancelled() }}
e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI)
needs: pre-job
@@ -358,41 +406,43 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run 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: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./cli
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: npm ci
if: ${{ !cancelled() }}
- name: Docker build
run: docker compose build
if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli)
run: pnpm test
run: npm run test
if: ${{ !cancelled() }}
e2e-tests-web:
name: End-to-End Tests (Web)
needs: pre-job
@@ -406,36 +456,42 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: npm ci
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]
@@ -446,6 +502,7 @@ 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
@@ -457,19 +514,21 @@ 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
@@ -484,6 +543,7 @@ 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
@@ -506,6 +566,7 @@ 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
@@ -516,24 +577,27 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
run: pnpm install --frozen-lockfile
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
- name: Run formatter
run: pnpm format
run: npm run format
if: ${{ !cancelled() }}
shellcheck:
name: ShellCheck
runs-on: ubuntu-latest
@@ -543,11 +607,15 @@ 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
@@ -558,20 +626,23 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
run: npm --prefix=server ci
- name: Build the app
run: pnpm --filter immich build
run: npm --prefix=server run 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
@@ -580,6 +651,7 @@ 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:
@@ -588,6 +660,7 @@ 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
@@ -601,36 +674,45 @@ 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: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
run: npm ci
- name: Build the app
run: pnpm build
run: npm run build
- name: Run existing migrations
run: pnpm migrations:run
run: npm run migrations:run
- name: Test npm run schema:reset command works
run: pnpm schema:reset
run: npm run schema:reset
- name: Generate new migrations
continue-on-error: true
run: pnpm migrations:generate src/TestMigration
run: npm run migrations:generate src/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
@@ -646,16 +728,19 @@ jobs:
echo "Changed files: ${CHANGED_FILES}"
cat ./src/*-TestMigration.ts
exit 1
- name: Run SQL generation
run: pnpm sync:sql
run: npm run 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:
@@ -666,77 +751,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
View File

@@ -5,7 +5,6 @@
!.vscode/launch.json
!.vscode/extensions.json
.idea
**/.pnpm-store/**
docker/upload
docker/library

View File

@@ -1,39 +0,0 @@
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;
},
},
};

View File

@@ -56,8 +56,7 @@
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.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"
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
},
"svelte.enable-ts-plugin": true,
"typescript.preferences.importModuleSpecifier": "non-relative"

View File

@@ -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:
pnpm --filter immich run sync:sql
npm --prefix server run sync:sql
attach-server:
docker exec -it docker_immich-server_1 sh
@@ -53,40 +53,31 @@ 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-%:
pnpm --filter $(call map-package,$*) audit fix
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
install-%:
pnpm --filter $(call map-package,$*) install $(if $(FROZEN),--frozen-lockfile) $(if $(OFFLINE),--offline)
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i
ci-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) ci
build-cli: build-sdk
build-web: build-sdk
build-%: install-%
pnpm --filter $(call map-package,$*) run build
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
format-%:
pnpm --filter $(call map-package,$*) run format:fix
npm --prefix $* run format:fix
lint-%:
pnpm --filter $(call map-package,$*) run lint:fix
lint-web:
pnpm --filter $(call map-package,$*) run lint:p
npm --prefix $* run lint:fix
check-%:
pnpm --filter $(call map-package,$*) run check
npm --prefix $* run check
check-web:
pnpm --filter immich-web run check:typescript
pnpm --filter immich-web run check:svelte
npm --prefix web run check:typescript
npm --prefix web run check:svelte
test-%:
pnpm --filter $(call map-package,$*) run test
npm --prefix $* run test
test-e2e:
docker compose -f ./e2e/docker-compose.yml build
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
npm --prefix e2e run test
npm --prefix e2e run test:web
test-medium:
docker run \
--rm \
@@ -96,36 +87,25 @@ test-medium:
-v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
-e NODE_ENV=development \
immich-server:latest \
-c "pnpm test:medium -- --run"
-c "npm ci && npm run test:medium -- --run"
test-medium-dev:
docker exec -it immich_server /bin/sh -c "pnpm run test:medium"
docker exec -it immich_server /bin/sh -c "npm run test:medium"
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/"
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) ;
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 "coverage" -type d -prune -exec rm -rf '{}' +
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
find . -name "svelte-kit" -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

View File

@@ -1,14 +1,19 @@
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 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
COPY cli/package.json cli/package-lock.json ./
RUN npm ci
COPY cli .
RUN npm run build
WORKDIR /import
ENTRYPOINT ["node", "/usr/src/app/cli/dist"]
ENTRYPOINT ["node", "/usr/src/app/dist"]

View File

@@ -6,10 +6,8 @@ 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:
# if you don't have pnpm installed
$ npm install -g pnpm
$ pnpm install
$ pnpm build
$ npm install
$ npm run build
Then, to build the open-api client run the following in the open-api folder:
@@ -17,10 +15,8 @@ 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:
# if you don't have pnpm installed
$ npm install -g pnpm
$ pnpm install
$ pnpm build
$ npm install
$ npm run build
$ ts-node .
You'll need ts-node, the easiest way to install it is to use npm:

4600
cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.79",
"version": "2.2.77",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -21,16 +21,17 @@ 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:
- ..:/usr/src/app
- ../server:/usr/src/app/server
- ../open-api:/usr/src/app/open-api
- ${UPLOAD_LOCATION}/photos:/data
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
- /usr/src/app/server/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
@@ -57,12 +58,8 @@ services:
- 9231:9231
- 2283:2283
depends_on:
redis:
condition: service_started
database:
condition: service_started
init:
condition: service_completed_successfully
- redis
- database
healthcheck:
disable: false
@@ -71,7 +68,6 @@ 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
@@ -82,17 +78,18 @@ services:
- 3000:3000
- 24678:24678
volumes:
- ..:/usr/src/app
- ../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
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped
depends_on:
immich-server:
condition: service_started
init:
condition: service_completed_successfully
- immich-server
immich-machine-learning:
container_name: immich_machine_learning
@@ -120,7 +117,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
healthcheck:
test: redis-cli ping || exit 1
@@ -160,14 +157,6 @@ 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:

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:5b8f8c333bef895c925f56629d6ba90aea95a4f7391f62411e625267c600b19c
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -1,7 +1,2 @@
build/
.docusaurus/
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@@ -5,7 +5,7 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati
### Installation
```
$ pnpm install
$ npm install
```
### Local Development

View File

@@ -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: `pnpm install` in all packages
- Installs dependencies: `npm install` in all packages
- Builds TypeScript SDK: `npm run build` in `open-api/typescript-sdk`
2. **Starts development servers** via VS Code tasks:

View File

@@ -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 - `pnpm i`
3. Install web dependencies - `npm i`
4. Start the web development server
```bash

View File

@@ -5,7 +5,7 @@
### Unit tests
Unit are run by calling `npm run test` from the `server/` directory.
You need to run `pnpm install` (in `server/`) before _once_.
You need to run `npm install` (in `server/`) before _once_.
### End to end tests

View File

@@ -27,102 +27,3 @@ 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, theres 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, its 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 dont 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 youre 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 doesnt have pgvecto.rs installed after youve 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?
Its 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 Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,4 @@
[
{
"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
View File

@@ -3,4 +3,3 @@ node_modules/
/playwright-report/
/blob-report/
/playwright/.cache/
/dist

7419
e2e/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.138.1",
"version": "1.137.3",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -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('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]).promise;
export const immichAdmin = (args: string[]) =>
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];

View File

@@ -28,9 +28,6 @@
"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",
@@ -500,9 +497,7 @@
"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",
@@ -519,7 +514,6 @@
"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",
@@ -1062,7 +1056,6 @@
"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",
@@ -1184,7 +1177,6 @@
"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",
@@ -1203,7 +1195,6 @@
"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",
@@ -1466,9 +1457,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, 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_age_months": "{months} months old",
"person_age_year_months": "1 year, {months} months old",
"person_age_years": "{years} 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.",
@@ -1865,7 +1856,6 @@
"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",

View File

@@ -1,6 +1,6 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:c642d5dfaf9115a12086785f23008558ae2e13bcd0c4794536340bcb777a4381 AS builder-cpu
FROM python:3.11-bookworm@sha256:ce3b954c9285a7a145cba620bae03db836ab890b6b9e0d05a3ca522ea00dfbc9 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:f64ad69940b634e75d2e4d799eb5238066c5eeda49f76e782d4873c3d014ea33 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:9653efd4380d5a0e5511e337dcfc3b8ba5bc4e6ea7fa3be7716598261d5503fa /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:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
FROM python:3.11-slim-bookworm@sha256:838ff46ae6c481e85e369706fa3dea5166953824124735639f3c9f52af85f319 AS prod-openvino
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \

View File

@@ -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 (img - mean) / std
return np.divide(img - mean, std, dtype=np.float32)
def get_pil_resampling(resample: str) -> Image.Resampling:
@@ -58,13 +58,11 @@ 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]:
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
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
def clean_text(text: str, canonicalize: bool = False) -> str:

View File

@@ -112,4 +112,8 @@ 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")

View File

@@ -12,7 +12,6 @@ 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
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})

View File

@@ -83,6 +83,12 @@ android {
}
}
namespace 'app.alextran.immich'
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
flutter {

View File

@@ -0,0 +1,52 @@
#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);
}

View File

@@ -1,7 +1,20 @@
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()
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())
}
}

View File

@@ -2,7 +2,8 @@ package app.alextran.immich
import android.os.Build
import android.os.ext.SdkExtensions
import androidx.annotation.NonNull
import app.alextran.immich.images.ThumbnailApi
import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -10,7 +11,7 @@ import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
@@ -23,5 +24,6 @@ class MainActivity : FlutterFragmentActivity() {
NativeSyncApiImpl30(this)
}
NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl)
ThumbnailApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ThumbnailsImpl(this))
}
}

View File

@@ -0,0 +1,117 @@
// 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)
}
}
}
}
}

View File

@@ -0,0 +1,236 @@
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()
)
}
}

View File

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

View File

@@ -24,6 +24,8 @@
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 */
@@ -102,6 +104,8 @@
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 */
@@ -243,6 +247,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FED3B1952E253E9B0030FD97 /* Images */,
);
path = Runner;
sourceTree = "<group>";
@@ -258,6 +263,15 @@
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 */
@@ -523,6 +537,8 @@
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 */,
);

View File

@@ -25,6 +25,7 @@ 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") {

View File

@@ -0,0 +1,119 @@
// 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)
}
}
}

View File

@@ -0,0 +1,177 @@
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
}
}

View File

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

View File

@@ -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 &gt; 0
).@count &gt; 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>

View File

@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj",
)
increment_version_number(
version_number: "1.138.1"
version_number: "1.137.3"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -26,8 +26,9 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
// Timeline constants
const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 256;
const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;
const int kTimelineImageCacheMemory = 200 * 1024 * 1024;
// Widget keys
const String appShareGroupId = "group.app.immich.share";

View File

@@ -23,7 +23,6 @@ class RemoteAlbum {
final AlbumAssetOrder order;
final int assetCount;
final String ownerName;
final bool isShared;
const RemoteAlbum({
required this.id,
@@ -37,7 +36,6 @@ class RemoteAlbum {
required this.order,
required this.assetCount,
required this.ownerName,
required this.isShared,
});
@override
@@ -54,7 +52,6 @@ class RemoteAlbum {
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
assetCount: $assetCount
ownerName: $ownerName
isShared: $isShared
}''';
}
@@ -72,8 +69,7 @@ class RemoteAlbum {
isActivityEnabled == other.isActivityEnabled &&
order == other.order &&
assetCount == other.assetCount &&
ownerName == other.ownerName &&
isShared == other.isShared;
ownerName == other.ownerName;
}
@override
@@ -88,8 +84,7 @@ class RemoteAlbum {
isActivityEnabled.hashCode ^
order.hashCode ^
assetCount.hashCode ^
ownerName.hashCode ^
isShared.hashCode;
ownerName.hashCode;
}
RemoteAlbum copyWith({
@@ -104,7 +99,6 @@ class RemoteAlbum {
AlbumAssetOrder? order,
int? assetCount,
String? ownerName,
bool? isShared,
}) {
return RemoteAlbum(
id: id ?? this.id,
@@ -118,7 +112,6 @@ class RemoteAlbum {
order: order ?? this.order,
assetCount: assetCount ?? this.assetCount,
ownerName: ownerName ?? this.ownerName,
isShared: isShared ?? this.isShared,
);
}
}

View File

@@ -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,21 +26,8 @@ class RemoteAlbumService {
return _repository.get(albumId);
}
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> sortAlbums(List<RemoteAlbum> albums, RemoteAlbumSortMode sortMode, {bool isReverse = false}) {
return sortMode.sortFn(albums, isReverse);
}
List<RemoteAlbum> searchAlbums(
@@ -156,60 +143,4 @@ 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);
}

View File

@@ -169,36 +169,6 @@ 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;

View File

@@ -85,3 +85,13 @@ 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;
}
}

View File

@@ -1,18 +1,211 @@
import 'dart:typed_data';
import 'dart:ui';
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:photo_manager/photo_manager.dart';
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';
class AssetMediaRepository {
const AssetMediaRepository();
abstract class ImageRequest {
static int _nextRequestId = 0;
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);
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;
}
}

View File

@@ -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, newestAsset }
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount }
class DriftLocalAlbumRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -40,7 +40,6 @@ 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);
@@ -320,7 +319,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)])
..limit(1);
final results = await query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();

View File

@@ -31,17 +31,11 @@ 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) {
@@ -59,11 +53,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.map(
(row) => row
.readTable(_db.remoteAlbumEntity)
.toDto(
assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
),
.toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!),
)
.get();
}
@@ -88,27 +78,17 @@ 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)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
),
.toDto(assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!),
)
.getSingleOrNull();
}
@@ -274,57 +254,24 @@ 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)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
);
final album = row.readTable(_db.remoteAlbumEntity).toDto(ownerName: row.read(_db.userEntity.name)!);
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, required bool isShared}) {
RemoteAlbum toDto({int assetCount = 0, required String ownerName}) {
return RemoteAlbum(
id: id,
name: name,
@@ -337,7 +284,6 @@ extension on RemoteAlbumEntityData {
order: order,
assetCount: assetCount,
ownerName: ownerName,
isShared: isShared,
);
}
}

View File

@@ -258,11 +258,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
);
TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) =>
row.deletedAt.isNull() &
row.isFavorite.equals(true) &
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
filter: (row) => row.deletedAt.isNull() & row.isFavorite.equals(true) & row.ownerId.equals(userId),
groupBy: groupBy,
);

View File

@@ -71,6 +71,8 @@ Future<void> initApp() async {
}
}
PaintingBinding.instance.imageCache.maximumSizeBytes = kTimelineImageCacheMemory;
await DynamicTheme.fetchSystemPalette();
final log = Logger("ImmichErrorLogger");

View File

@@ -7,7 +7,6 @@ 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';
@@ -40,7 +39,6 @@ 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);
}

View File

@@ -224,7 +224,7 @@ class FileDetailDialog extends ConsumerWidget {
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: asset != null
? Thumbnail(asset: asset, size: const Size(512, 512), fit: BoxFit.cover)
? Thumbnail.fromBaseAsset(asset: asset, size: const Size(512, 512), fit: BoxFit.cover)
: null,
),
),

107
mobile/lib/platform/thumbnail_api.g.dart generated Normal file
View File

@@ -0,0 +1,107 @@
// 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;
}
}
}

View File

@@ -1,104 +0,0 @@
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,
);
}
}

View File

@@ -119,7 +119,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
final asset = selectedAssets.elementAt(index);
return GestureDetector(
onTap: onBackgroundTapped,
child: Thumbnail(asset: asset),
child: Thumbnail.fromBaseAsset(asset: asset),
);
}, childCount: selectedAssets.length),
),

View File

@@ -4,6 +4,7 @@ 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';
@@ -163,7 +164,11 @@ 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(size: const Size(80, 80), fit: BoxFit.cover, remoteId: place.$2),
child: Thumbnail(
imageProvider: RemoteThumbProvider(assetId: place.$2),
size: const Size(80, 80),
fit: BoxFit.cover,
),
),
);
}

View File

@@ -165,10 +165,6 @@ 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;
@@ -245,7 +241,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditTitle: () => showEditTitleAndDescription(context),
onActivity: () => showActivity(context),
),
bottomSheet: RemoteAlbumBottomSheet(album: _album),
),

View File

@@ -1,174 +0,0 @@
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),
],
);
}
}

View File

@@ -1,165 +0,0 @@
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),
],
),
],
),
),
);
}
}

View File

@@ -1,159 +0,0 @@
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),
],
);
}
}

View File

@@ -1,37 +0,0 @@
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,
);
}
}

View File

@@ -1,64 +0,0 @@
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"),
);
}
}

View File

@@ -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/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/utils/remote_album.utils.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,28 +138,21 @@ class _SortButton extends ConsumerStatefulWidget {
class _SortButtonState extends ConsumerState<_SortButton> {
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
bool albumSortIsReverse = true;
bool isSorting = false;
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
void onMenuTapped(RemoteAlbumSortMode sortMode) {
final selected = albumSortOption == sortMode;
// Switch direction
if (selected) {
setState(() {
albumSortIsReverse = !albumSortIsReverse;
isSorting = true;
});
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
} else {
setState(() {
albumSortOption = sortMode;
isSorting = true;
});
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
}
setState(() {
isSorting = false;
});
}
@override
@@ -237,16 +230,6 @@ 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(),
],
),
);
@@ -441,72 +424,46 @@ class _AlbumList extends ConsumerWidget {
sliver: SliverList.builder(
itemBuilder: (_, index) {
final album = albums[index];
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,
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),
),
);
} else {
return Padding(padding: const EdgeInsets.only(bottom: 8.0), child: albumTile);
}
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),
),
),
),
);
},
itemCount: albums.length,
),
@@ -577,7 +534,7 @@ class _GridAlbumCard extends ConsumerWidget {
child: SizedBox(
width: double.infinity,
child: album.thumbnailAssetId != null
? Thumbnail(remoteId: album.thumbnailAssetId)
? Thumbnail(imageProvider: RemoteThumbProvider(assetId: album.thumbnailAssetId!))
: Container(
color: context.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),

View File

@@ -1,109 +0,0 @@
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(),
),
);
}
}

View File

@@ -113,10 +113,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.dispose();
}
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
bool get showingBottomSheet => ref.read(assetViewerProvider).showingBottomSheet;
Color get backgroundColor {
final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
final opacity = ref.read(assetViewerProvider).backgroundOpacity;
return Colors.black.withAlpha(opacity);
}
@@ -127,21 +127,20 @@ 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) {
return;
}
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
if (asset == null || !mounted) {
if (!mounted || index < 0 || index >= totalAssets) {
return;
}
final asset = ref.read(timelineServiceProvider).getAsset(index);
final screenSize = Size(context.width, context.height);
// Precache both thumbnail and full image for smooth transitions
@@ -153,15 +152,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
);
}
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;
}
void _onAssetChanged(int index) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
// Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed
@@ -180,9 +172,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Check if widget is still mounted before proceeding
if (!mounted) return;
for (final offset in [-1, 1]) {
unawaited(_precacheImage(index + offset));
}
unawaited(_precacheImage(index - 1));
unawaited(_precacheImage(index + 1));
});
_delayedOperations.add(timer);
@@ -225,15 +216,19 @@ 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(
@@ -416,22 +411,16 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
}
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;
}
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;
}
_onAssetChanged(pageController.page!.round());
sheetCloseController?.close();
});
@@ -440,7 +429,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: (viewController?.scale ?? 1.0) + 0.01);
viewController?.updateMultiple(scale: _getScaleForBottomSheet);
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet(
context: ctx,
@@ -478,30 +467,12 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int 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;
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
}
return Container(
width: double.infinity,
height: double.infinity,
color: backgroundColor,
child: Thumbnail(asset: displayAsset, fit: BoxFit.contain),
);
return Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain);
}
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
@@ -516,34 +487,18 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx;
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;
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
asset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
}
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
if (displayAsset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, displayAsset);
if (asset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, asset);
}
return _videoBuilder(ctx, displayAsset);
return _videoBuilder(ctx, asset);
}
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
@@ -554,6 +509,8 @@ 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,
@@ -564,7 +521,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
width: size.width,
height: size.height,
color: backgroundColor,
child: Thumbnail(asset: asset, fit: BoxFit.contain),
child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain),
),
);
}
@@ -582,7 +539,9 @@ 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,
@@ -611,9 +570,11 @@ 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));
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
ref.watch(
assetViewerProvider.select(
(s) => s.showingBottomSheet.hashCode ^ s.backgroundOpacity.hashCode ^ s.stackIndex.hashCode,
),
);
ref.watch(isPlayingMotionVideoProvider);
// Listen for casting changes and send initial asset to the cast provider

View File

@@ -75,22 +75,34 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
}
void setAsset(BaseAsset? asset) {
state = state.copyWith(currentAsset: asset, stackIndex: 0);
if (asset != state.currentAsset) {
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
}
void setOpacity(int opacity) {
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls);
if (opacity != state.backgroundOpacity) {
state = state.copyWith(
backgroundOpacity: opacity,
showingControls: opacity == 255 ? true : state.showingControls,
);
}
}
void setBottomSheet(bool showing) {
state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls);
if (showing == state.showingBottomSheet) {
return;
}
state = state.copyWith(showingBottomSheet: showing, showingControls: showing || state.showingControls);
if (showing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
}
}
void setControls(bool isShowing) {
state = state.copyWith(showingControls: isShowing);
if (isShowing != state.showingControls) {
state = state.copyWith(showingControls: isShowing);
}
}
void toggleControls() {
@@ -98,7 +110,9 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
}
void setStackIndex(int index) {
state = state.copyWith(stackIndex: index);
if (index != state.stackIndex) {
state = state.copyWith(stackIndex: index);
}
}
}

View File

@@ -6,9 +6,7 @@ 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';
@@ -32,7 +30,6 @@ 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;
@@ -41,16 +38,10 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
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),
],
if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer),
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
];
return IgnorePointer(

View File

@@ -6,6 +6,17 @@ 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';
@@ -14,8 +25,6 @@ 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';
@@ -35,25 +44,32 @@ 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 buttonContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView,
currentAlbum: currentAlbum,
source: ActionSource.viewer,
);
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 actions = ActionButtonBuilder.build(buttonContext);
final lockedViewActions = <Widget>[];
return BaseBottomSheet(
actions: actions,
actions: isInLockedView ? lockedViewActions : actions,
slivers: const [_AssetDetailBottomSheet()],
controller: controller,
initialChildSize: initialChildSize,

View File

@@ -61,7 +61,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
),
),
SizedBox(
height: 160,
height: 150,
child: ListView(
padding: const EdgeInsets.only(left: 16.0),
scrollDirection: Axis.horizontal,

View File

@@ -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,17 +28,12 @@ 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 != AssetViewerRoute.name &&
previousRouteName != null;
final showViewInTimelineButton = previousRouteName != TabShellRoute.name && previousRouteName != null;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
@@ -49,16 +44,10 @@ 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)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
icon: const Icon(Icons.chat_outlined),
onPressed: () {
context.navigateTo(const DriftActivitiesRoute());
},
),
if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true),
if (showViewInTimelineButton)
IconButton(
onPressed: () async {
@@ -78,7 +67,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
];
final lockedViewActions = <Widget>[
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (isCasting || (asset.hasRemote && websocketConnected)) const CastActionButton(menuItem: true),
const _KebabMenu(),
];

View File

@@ -69,8 +69,10 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent,
builder: (BuildContext context, ScrollController scrollController) {
return Card(
color: widget.backgroundColor ?? context.colorScheme.surfaceContainer,
elevation: 3.0,
color: widget.backgroundColor ?? context.colorScheme.surface,
borderOnForeground: false,
clipBehavior: Clip.antiAlias,
elevation: 6.0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
margin: const EdgeInsets.symmetric(horizontal: 0),
child: CustomScrollView(

View File

@@ -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/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.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 ThumbnailPlaceholder(),
this.placeholder = const Thumbnail(),
super.key,
});

View File

@@ -5,13 +5,32 @@ 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, type: asset.type, updatedAt: asset.updatedAt);
provider = LocalFullImageProvider(id: id, size: size);
} else {
final String assetId;
if (asset is LocalAsset && asset.hasRemote) {
@@ -36,7 +55,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, updatedAt: asset.updatedAt, size: size);
return LocalThumbProvider(id: id, size: size);
}
final String assetId;

View File

@@ -26,7 +26,7 @@ class LocalAlbumThumbnail extends ConsumerWidget {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Thumbnail(asset: data),
child: Thumbnail.fromBaseAsset(asset: data),
);
},
error: (error, stack) {

View File

@@ -1,36 +1,18 @@
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/infrastructure/repositories/storage.repository.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/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;
const LocalThumbProvider({
required this.id,
required this.updatedAt,
this.size = kThumbnailResolution,
this.cacheManager,
});
LocalThumbProvider({required this.id, this.size = kThumbnailResolution});
@override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -39,63 +21,45 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
@override
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode),
scale: 1.0,
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
DiagnosticsProperty<Size>('Size', key.size),
],
);
}
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 (_) {}
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;
}
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 && updatedAt == other.updatedAt;
return id == other.id && size == other.size;
}
return false;
}
@override
int get hashCode => id.hashCode ^ updatedAt.hashCode;
int get hashCode => id.hashCode ^ size.hashCode;
}
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository();
final StorageRepository _storageRepository = const StorageRepository();
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with CancellableImageProviderMixin {
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
const LocalFullImageProvider({required this.id, required this.size, required this.type, required this.updatedAt});
LocalFullImageProvider({required this.id, required this.size});
@override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -106,114 +70,41 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
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* {
try {
// 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;
}
} 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);
final request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
);
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();
try {
final image = await request.load(decode);
if (image != null) {
yield image;
}
return;
} finally {
this.request = null;
}
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 && type == other.type;
return id == other.id && size == other.size;
}
return false;
}
@override
int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode;
int get hashCode => id.hashCode ^ size.hashCode;
}

View File

@@ -1,23 +1,21 @@
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/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/presentation/widgets/images/one_frame_multi_image_stream_completer.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/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> with CancellableImageProviderMixin {
final String assetId;
final CacheManager? cacheManager;
const RemoteThumbProvider({required this.assetId, this.cacheManager});
RemoteThumbProvider({required this.assetId, this.cacheManager});
@override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -26,12 +24,8 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
@override
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? RemoteImageCacheManager();
final chunkController = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode, chunkController),
scale: 1.0,
chunkEvents: chunkController.stream,
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
@@ -39,20 +33,17 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
);
}
Future<Codec> _codec(
RemoteThumbProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkController,
) async {
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) async* {
final preview = getThumbnailUrlForRemoteId(key.assetId);
return ImageLoader.loadImageFromCache(
preview,
cache: cache,
decode: decode,
chunkEvents: chunkController,
).whenComplete(chunkController.close);
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;
}
}
@override
@@ -69,11 +60,11 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
int get hashCode => assetId.hashCode;
}
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> with CancellableImageProviderMixin {
final String assetId;
final CacheManager? cacheManager;
const RemoteFullImageProvider({required this.assetId, this.cacheManager});
RemoteFullImageProvider({required this.assetId, this.cacheManager});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -82,28 +73,44 @@ class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
@override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? RemoteImageCacheManager();
return OneFramePlaceholderImageStreamCompleter(
_codec(key, cache, decode),
initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)),
_codec(key, decode),
initialImage: getCachedImage(RemoteThumbProvider(assetId: assetId)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
);
}
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();
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;
}
if (AppSetting.get(Setting.loadOriginal)) {
final codec = await ImageLoader.loadImageFromCache(
getOriginalUrlForRemoteId(key.assetId),
cache: cache,
decode: decode,
);
yield await codec.getImageInfo();
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;
}
}
}

View File

@@ -1,39 +0,0 @@
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;
}

View File

@@ -1,61 +1,372 @@
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/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: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:logging/logging.dart';
import 'package:octo_image/octo_image.dart';
import 'package:thumbhash/thumbhash.dart' as thumbhash;
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');
final log = Logger('ThumbnailWidget');
final BaseAsset? asset;
final String? remoteId;
final Size size;
enum ThumbhashMode { enabled, disabled, only }
class Thumbnail extends StatefulWidget {
final ImageProvider? imageProvider;
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 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,
final colorScheme = context.colorScheme;
final gradient = _gradientCache[colorScheme] ??= LinearGradient(
colors: [colorScheme.surfaceContainer, colorScheme.surfaceContainer.darken(amount: .1)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
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();
}
}
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 _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;
}
}
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)),
],
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,
);
};
} 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();
}
}

View File

@@ -7,13 +7,15 @@ 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 = const Size.square(256),
this.size = kTimelineFixedTileExtent,
this.fit = BoxFit.cover,
this.showStorageIndicator,
this.lockSelection = false,
@@ -21,7 +23,7 @@ class ThumbnailTile extends ConsumerWidget {
super.key,
});
final BaseAsset asset;
final BaseAsset? asset;
final Size size;
final BoxFit fit;
final bool? showStorageIndicator;
@@ -30,6 +32,7 @@ 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
@@ -39,6 +42,7 @@ 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(
@@ -52,8 +56,6 @@ 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)));
@@ -71,19 +73,34 @@ class ThumbnailTile extends ConsumerWidget {
children: [
Positioned.fill(
child: Hero(
tag: '${asset.heroTag}_$heroIndex',
child: Thumbnail(asset: asset, fit: fit, size: size),
),
),
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),
tag: '${asset?.heroTag ?? ''}_$heroIndex',
child: Thumbnail.fromBaseAsset(
asset: asset,
thumbhashMode: isScrubbing
? ThumbhashMode.only
: asset != null && asset.hasLocal
? ThumbhashMode.disabled
: ThumbhashMode.enabled,
),
),
if (asset.isVideo)
),
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)
Align(
alignment: Alignment.topRight,
child: Padding(
@@ -91,7 +108,7 @@ class ThumbnailTile extends ConsumerWidget {
child: _VideoIndicator(asset.duration),
),
),
if (storageIndicator)
if (storageIndicator && asset != null)
switch (asset.storage) {
AssetState.local => const Align(
alignment: Alignment.bottomRight,
@@ -115,7 +132,7 @@ class ThumbnailTile extends ConsumerWidget {
),
),
},
if (asset.isFavorite)
if (asset != null && asset.isFavorite)
const Align(
alignment: Alignment.bottomLeft,
child: Padding(

View File

@@ -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/utils/hooks/blurhash_hook.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
class DriftMemoryCard extends StatelessWidget {
final RemoteAsset asset;
@@ -88,31 +88,26 @@ class _BlurredBackdrop extends HookWidget {
@override
Widget build(BuildContext context) {
final blurhash = useDriftBlurHashRef(asset).value;
final blurhash = asset.thumbHash;
if (blurhash != null) {
// Use a nice cheap blur hash image decoration
return Container(
decoration: BoxDecoration(
image: DecorationImage(image: MemoryImage(blurhash), fit: BoxFit.cover),
),
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)),
),
);
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(
decoration: BoxDecoration(
image: DecorationImage(
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
fit: BoxFit.cover,
),
),
child: const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.2)),
),
);
}
}

View File

@@ -58,11 +58,7 @@ 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(remoteId: memory.assets[0].id, fit: BoxFit.cover),
),
child: SizedBox(width: 205, height: 200, child: Thumbnail.fromBaseAsset(asset: memory.assets[0])),
),
Positioned(
bottom: 16,

View File

@@ -2,7 +2,7 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256);
const Size kThumbnailResolution = kTimelineFixedTileExtent;
const Size kThumbnailResolution = Size.square(384);
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;

View File

@@ -4,16 +4,19 @@ 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';
@@ -76,6 +79,21 @@ 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 {
@@ -93,58 +111,45 @@ 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);
if (isScrubbing) {
return _buildPlaceholder(context);
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 (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 _buildPlaceholder(BuildContext context) {
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
}
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
Widget _buildAssetRow(BuildContext context, List<BaseAsset>? assets, TimelineService timelineService) {
final assetIndex = this.assetIndex;
return FixedTimelineRow(
dimension: tileHeight,
spacing: spacing,
textDirection: Directionality.of(context),
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,
),
),
],
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),
);
}
}
class _AssetTileWidget extends ConsumerWidget {
final BaseAsset asset;
final BaseAsset? asset;
final int assetIndex;
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
const _AssetTileWidget({required this.asset, required this.assetIndex});
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
final multiSelectState = ref.read(multiSelectProvider);
@@ -154,6 +159,12 @@ 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,
@@ -190,17 +201,16 @@ class _AssetTileWidget extends ConsumerWidget {
final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final asset = this.asset;
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,
),
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,
),
);
}

View File

@@ -1,4 +1,5 @@
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';
@@ -6,6 +7,7 @@ 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,
@@ -16,12 +18,11 @@ class FixedSegmentBuilder extends SegmentBuilder {
});
List<Segment> generate() {
final segments = <Segment>[];
final segments = List.filled(buckets.length, const FixedSegment.empty());
int firstIndex = 0;
double startOffset = 0;
int assetIndex = 0;
DateTime? previousDate;
DateTime previousDate = _dummyDate;
for (int i = 0; i < buckets.length; i++) {
final bucket = buckets[i];
@@ -32,11 +33,10 @@ 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 && bucket.date.month != previousDate?.month ? HeaderType.monthAndDay : HeaderType.day,
bucket is TimeBucket && !previousDate.isSameMonth(bucket.date) ? HeaderType.monthAndDay : HeaderType.day,
GroupAssetsBy.none => HeaderType.none,
};
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
@@ -45,20 +45,18 @@ class FixedSegmentBuilder extends SegmentBuilder {
startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
final segmentEndOffset = startOffset;
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,
),
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,
);
assetIndex += assetCount;

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