Compare commits

..

1 Commits

Author SHA1 Message Date
midzelis 6fe63b70a6 feat(web): animate zoom toggle with cubicOut easing 2026-03-07 14:11:31 +00:00
268 changed files with 4799 additions and 6672 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Check for breaking API changes - name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31 uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
with: with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json
-80
View File
@@ -1,80 +0,0 @@
name: Check PR Template
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited]
permissions: {}
jobs:
parse:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
act:
runs-on: ubuntu-latest
needs: parse
permissions:
pull-requests: write
steps:
- name: Close PR
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
- name: Reopen PR (sections now present, PR closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f query='
mutation ReopenPR($prId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
+2 -2
View File
@@ -42,10 +42,10 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
# ️ Command-line programs to run using the OS shell. # ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'
+1 -1
View File
@@ -131,7 +131,7 @@ jobs:
- device: rocm - device: rocm
suffixes: '-rocm' suffixes: '-rocm'
platforms: linux/amd64 platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}' runner-mapping: '{"linux/amd64": "pokedex-giant"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1 uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
permissions: permissions:
contents: read contents: read
+2 -2
View File
@@ -67,10 +67,10 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './docs/.nvmrc' node-version-file: './docs/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
+2 -2
View File
@@ -29,10 +29,10 @@ jobs:
persist-credentials: true persist-credentials: true
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
+3 -3
View File
@@ -63,13 +63,13 @@ jobs:
ref: main ref: main
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
+170
View File
@@ -0,0 +1,170 @@
name: Manage release PR
on:
workflow_dispatch:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: {}
jobs:
bump:
runs-on: ubuntu-latest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Determine release type
id: bump-type
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
with:
token: ${{ steps.generate-token.outputs.token }}
- name: Bump versions
env:
TYPE: ${{ steps.bump-type.outputs.bump }}
run: |
if [ "$TYPE" == "none" ]; then
exit 1 # TODO: Is there a cleaner way to abort the workflow?
fi
misc/release/pump-version.sh -s $TYPE -m true
- name: Manage Outline release document
id: outline
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const fs = require('fs');
const outlineKey = process.env.OUTLINE_API_KEY;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
let documentId;
let documentUrl;
let documentText;
if (!document) {
// Create new document
console.log('No existing document found. Creating new one...');
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'next',
text: notesTmpl,
collectionId: collectionId,
parentDocumentId: parentDocumentId,
publish: true
})
});
if (!createResponse.ok) {
throw new Error(`Failed to create document: ${createResponse.statusText}`);
}
const createData = await createResponse.json();
documentId = createData.data.id;
const urlId = createData.data.urlId;
documentUrl = `${baseUrl}/doc/next-${urlId}`;
documentText = createData.data.text || '';
console.log(`Created new document: ${documentUrl}`);
} else {
documentId = document.id;
const docPath = document.url;
documentUrl = `${baseUrl}${docPath}`;
documentText = document.text || '';
console.log(`Found existing document: ${documentUrl}`);
}
// Generate GitHub release notes
console.log('Generating GitHub release notes...');
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `${process.env.NEXT_VERSION}`,
});
// Combine the content
const changelog = `
# ${process.env.NEXT_VERSION}
${documentText}
${releaseNotesResponse.data.body}
---
`
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
core.setOutput('document_url', documentUrl);
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
labels: 'changelog:skip'
branch: 'release/next'
draft: true
+149
View File
@@ -0,0 +1,149 @@
name: release.yml
on:
pull_request:
types: [closed]
paths:
- CHANGELOG.md
jobs:
# Maybe double check PR source branch?
merge_translations:
uses: ./.github/workflows/merge-translations.yml
permissions:
pull-requests: write
secrets:
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: merge_translations
permissions:
contents: read
secrets:
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
# iOS secrets
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with:
ref: main
environment: production
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
permissions:
actions: read # To download the app artifact
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: main
- name: Extract changelog
id: changelog
run: |
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}
body_path: ${{ steps.changelog.outputs.path }}
draft: true
files: |
docker/docker-compose.yml
docker/docker-compose.rootless.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk
- name: Rename Outline document
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
continue-on-error: true
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
VERSION: ${{ steps.changelog.outputs.version }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const outlineKey = process.env.OUTLINE_API_KEY;
const version = process.env.VERSION;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
if (document) {
console.log(`Found document 'next', renaming to '${version}'...`);
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: document.id,
title: version
})
});
if (!updateResponse.ok) {
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
}
} else {
console.log('No document titled "next" found to rename');
}
+2 -2
View File
@@ -30,10 +30,10 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './open-api/typescript-sdk/.nvmrc' node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
+27 -27
View File
@@ -75,9 +75,9 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -119,9 +119,9 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -166,9 +166,9 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -208,9 +208,9 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -252,9 +252,9 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -290,9 +290,9 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -338,9 +338,9 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -385,9 +385,9 @@ jobs:
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -424,9 +424,9 @@ jobs:
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -496,9 +496,9 @@ jobs:
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
with: with:
python-version: 3.11 python-version: 3.11
- name: Install dependencies - name: Install dependencies
@@ -661,9 +661,9 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './.github/.nvmrc' node-version-file: './.github/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -712,9 +712,9 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -774,9 +774,9 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
+1 -1
View File
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^24.11.0", "@types/node": "^24.10.14",
"@vitest/coverage-v8": "^4.0.0", "@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
+1 -1
View File
@@ -155,7 +155,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
+1 -1
View File
@@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
+1 -1
View File
@@ -61,7 +61,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
user: '1000:1000' user: '1000:1000'
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
+1 -1
View File
@@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
+1 -1
View File
@@ -230,7 +230,7 @@ The default value is `ultrafast`.
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec} ### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`. Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
The default value is `aac`. The default value is `aac`.
+1 -1
View File
@@ -27,7 +27,7 @@ The default configuration looks like this:
"ffmpeg": { "ffmpeg": {
"accel": "disabled", "accel": "disabled",
"accelDecode": false, "accelDecode": false,
"acceptedAudioCodecs": ["aac", "mp3", "opus"], "acceptedAudioCodecs": ["aac", "mp3", "libopus"],
"acceptedContainers": ["mov", "ogg", "webm"], "acceptedContainers": ["mov", "ogg", "webm"],
"acceptedVideoCodecs": ["h264"], "acceptedVideoCodecs": ["h264"],
"bframes": -1, "bframes": -1,
+4 -25
View File
@@ -10,7 +10,6 @@ export enum OAuthClient {
export enum OAuthUser { export enum OAuthUser {
NO_EMAIL = 'no-email', NO_EMAIL = 'no-email',
NO_NAME = 'no-name', NO_NAME = 'no-name',
ID_TOKEN_CLAIMS = 'id-token-claims',
WITH_QUOTA = 'with-quota', WITH_QUOTA = 'with-quota',
WITH_USERNAME = 'with-username', WITH_USERNAME = 'with-username',
WITH_ROLE = 'with-role', WITH_ROLE = 'with-role',
@@ -53,25 +52,12 @@ const withDefaultClaims = (sub: string) => ({
email_verified: true, email_verified: true,
}); });
const getClaims = (sub: string, use?: string) => { const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
if (sub === OAuthUser.ID_TOKEN_CLAIMS) {
return {
sub,
email: `oauth-${sub}@immich.app`,
email_verified: true,
name: use === 'id_token' ? 'ID Token User' : 'Userinfo User',
};
}
return claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
};
const setup = async () => { const setup = async () => {
const { privateKey, publicKey } = await generateKeyPair('RS256'); const { privateKey, publicKey } = await generateKeyPair('RS256');
const redirectUris = [ const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
'http://127.0.0.1:2285/auth/login',
'https://photos.immich.app/oauth/mobile-redirect',
];
const port = 2286; const port = 2286;
const host = '0.0.0.0'; const host = '0.0.0.0';
const oidc = new Provider(`http://${host}:${port}`, { const oidc = new Provider(`http://${host}:${port}`, {
@@ -80,10 +66,7 @@ const setup = async () => {
console.error(error); console.error(error);
ctx.body = 'Internal Server Error'; ctx.body = 'Internal Server Error';
}, },
findAccount: (ctx, sub) => ({ findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
accountId: sub,
claims: (use) => getClaims(sub, use),
}),
scopes: ['openid', 'email', 'profile'], scopes: ['openid', 'email', 'profile'],
claims: { claims: {
openid: ['sub'], openid: ['sub'],
@@ -111,7 +94,6 @@ const setup = async () => {
state: 'oidc.state', state: 'oidc.state',
}, },
}, },
conformIdTokenClaims: false,
pkce: { pkce: {
required: () => false, required: () => false,
}, },
@@ -143,10 +125,7 @@ const setup = async () => {
], ],
}); });
const onStart = () => const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
console.log(
`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`,
);
const app = oidc.listen(port, host, onStart); const app = oidc.listen(port, host, onStart);
return () => app.close(); return () => app.close();
}; };
+1 -1
View File
@@ -44,7 +44,7 @@ services:
redis: redis:
container_name: immich-e2e-redis container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
+1 -1
View File
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^24.11.0", "@types/node": "^24.10.14",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
@@ -380,23 +380,4 @@ describe(`/oauth`, () => {
}); });
}); });
}); });
describe('idTokenClaims', () => {
it('should use claims from the ID token if IDP includes them', async () => {
await setupOAuth(admin.accessToken, {
enabled: true,
clientId: OAuthClient.DEFAULT,
clientSecret: OAuthClient.DEFAULT,
});
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
name: 'ID Token User',
userEmail: 'oauth-id-token-claims@immich.app',
userId: expect.any(String),
});
});
});
}); });
@@ -438,16 +438,6 @@ describe('/shared-links', () => {
expect(body).toEqual(errorDto.badRequest('Invalid shared link type')); expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
}); });
it('should reject guests removing assets from an individual shared link', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)
.query({ key: linkWithAssets.key })
.send({ assetIds: [asset1.id] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should remove assets from a shared link (individual)', async () => { it('should remove assets from a shared link (individual)', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`) .delete(`/shared-links/${linkWithAssets.id}/assets`)
+19 -43
View File
@@ -1,13 +1,14 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test'; import { Page, expect, test } from '@playwright/test';
import type { Socket } from 'socket.io-client';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => { test.describe('Photo Viewer', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetMediaResponseDto; let asset: AssetMediaResponseDto;
let rawAsset: AssetMediaResponseDto; let rawAsset: AssetMediaResponseDto;
let websocket: Socket;
test.beforeAll(async () => { test.beforeAll(async () => {
utils.initSdk(); utils.initSdk();
@@ -15,11 +16,6 @@ test.describe('Photo Viewer', () => {
admin = await utils.adminSetup(); admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken); asset = await utils.createAsset(admin.accessToken);
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } }); rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
websocket = await utils.connectWebsocket(admin.accessToken);
});
test.afterAll(() => {
utils.disconnectWebsocket(websocket);
}); });
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }) => {
@@ -30,51 +26,31 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => { test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const preview = page.getByTestId('preview').filter({ visible: true }); const box = await imageLocator(page).boundingBox();
await expect(preview).toHaveAttribute('src', /.+/); expect(box).toBeTruthy();
const { x, y, width, height } = box!;
const originalResponse = page.waitForResponse((response) => response.url().includes('/original')); await page.mouse.move(x + width / 2, y + height / 2);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1); await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
await originalResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /original/);
}); });
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => { test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`); await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const preview = page.getByTestId('preview').filter({ visible: true }); const box = await imageLocator(page).boundingBox();
await expect(preview).toHaveAttribute('src', /.+/); expect(box).toBeTruthy();
const { x, y, width, height } = box!;
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize')); await page.mouse.move(x + width / 2, y + height / 2);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1); await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
await fullsizeResponse;
const original = page.getByTestId('original').filter({ visible: true });
await expect(original).toHaveAttribute('src', /fullsize/);
}); });
test('reloads photo when checksum changes', async ({ page }) => { test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`); await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const preview = page.getByTestId('preview').filter({ visible: true }); const initialSrc = await imageLocator(page).getAttribute('src');
await expect(preview).toHaveAttribute('src', /.+/);
const initialSrc = await preview.getAttribute('src');
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await utils.replaceAsset(admin.accessToken, asset.id); await utils.replaceAsset(admin.accessToken, asset.id);
await websocketEvent; await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
await expect(preview).not.toHaveAttribute('src', initialSrc!);
}); });
}); });
-24
View File
@@ -12,18 +12,15 @@ import { asBearerAuth, utils } from 'src/utils';
test.describe('Shared Links', () => { test.describe('Shared Links', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let asset: AssetMediaResponseDto; let asset: AssetMediaResponseDto;
let asset2: AssetMediaResponseDto;
let album: AlbumResponseDto; let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto; let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto; let sharedLinkPassword: SharedLinkResponseDto;
let individualSharedLink: SharedLinkResponseDto;
test.beforeAll(async () => { test.beforeAll(async () => {
utils.initSdk(); utils.initSdk();
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken); asset = await utils.createAsset(admin.accessToken);
asset2 = await utils.createAsset(admin.accessToken);
album = await createAlbum( album = await createAlbum(
{ {
createAlbumDto: { createAlbumDto: {
@@ -42,10 +39,6 @@ test.describe('Shared Links', () => {
albumId: album.id, albumId: album.id,
password: 'test-password', password: 'test-password',
}); });
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id, asset2.id],
});
}); });
test('download from a shared link', async ({ page }) => { test('download from a shared link', async ({ page }) => {
@@ -116,21 +109,4 @@ test.describe('Shared Links', () => {
await page.waitForURL('/photos'); await page.waitForURL('/photos');
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor(); await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
}); });
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/share/${individualSharedLink.key}`);
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
await page.locator(`[data-asset="${asset.id}"]`).hover();
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
await page.getByRole('button', { name: 'Remove from shared link' }).click();
await page.getByRole('button', { name: 'Remove', exact: true }).click();
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
});
}); });
@@ -64,9 +64,7 @@ test.describe('broken-asset responsiveness', () => {
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => { test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
await context.route( await context.route(
(url) => (url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
async (route) => { async (route) => {
return route.fulfill({ status: 404 }); return route.fulfill({ status: 404 });
}, },
@@ -75,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
await page.goto(`/photos/${fixture.primaryAsset.id}`); await page.goto(`/photos/${fixture.primaryAsset.id}`);
await page.waitForSelector('#immich-asset-viewer'); await page.waitForSelector('#immich-asset-viewer');
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first(); const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
await expect(viewerBrokenAsset).toBeVisible(); await expect(viewerBrokenAsset).toBeVisible();
await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
+2 -3
View File
@@ -215,9 +215,8 @@ export const pageUtils = {
await page.getByText('Confirm').click(); await page.getByText('Confirm').click();
}, },
async selectDay(page: Page, day: string) { async selectDay(page: Page, day: string) {
const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]'); await page.getByTitle(day).hover();
await section.hover(); await page.locator('[data-group] .w-8').click();
await section.locator('.w-8').click();
}, },
async pauseTestDebug() { async pauseTestDebug() {
console.log('NOTE: pausing test indefinately for debug'); console.log('NOTE: pausing test indefinately for debug');
+29 -40
View File
@@ -177,51 +177,40 @@ export const utils = {
}, },
resetDatabase: async (tables?: string[]) => { resetDatabase: async (tables?: string[]) => {
client = await utils.connectDatabase(); try {
client = await utils.connectDatabase();
tables = tables || [ tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex // TODO e2e test for deleting a stack, since it is quite complex
'stack', 'stack',
'library', 'library',
'shared_link', 'shared_link',
'person', 'person',
'album', 'album',
'asset', 'asset',
'asset_face', 'asset_face',
'activity', 'activity',
'api_key', 'api_key',
'session', 'session',
'user', 'user',
'system_metadata', 'system_metadata',
'tag', 'tag',
]; ];
const truncateTables = tables.filter((table) => table !== 'system_metadata'); const sql: string[] = [];
const sql: string[] = [];
if (truncateTables.length > 0) { for (const table of tables) {
sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`); if (table === 'system_metadata') {
} sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
} else {
if (tables.includes('system_metadata')) { sql.push(`DELETE FROM "${table}" CASCADE;`);
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
}
const query = sql.join('\n');
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await client.query(query);
return;
} catch (error: any) {
if (error?.code === '40P01' && attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
continue;
} }
console.error('Failed to reset database', error);
throw error;
} }
await client.query(sql.join('\n'));
} catch (error) {
console.error('Failed to reset database', error);
throw error;
} }
}, },
+1 -4
View File
@@ -1007,8 +1007,6 @@
"editor_edits_applied_success": "Edits applied successfully", "editor_edits_applied_success": "Edits applied successfully",
"editor_flip_horizontal": "Flip horizontal", "editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical", "editor_flip_vertical": "Flip vertical",
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle",
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle",
"editor_orientation": "Orientation", "editor_orientation": "Orientation",
"editor_reset_all_changes": "Reset changes", "editor_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise", "editor_rotate_left": "Rotate 90° counterclockwise",
@@ -1074,7 +1072,7 @@
"failed_to_update_notification_status": "Failed to update notification status", "failed_to_update_notification_status": "Failed to update notification status",
"incorrect_email_or_password": "Incorrect email or password", "incorrect_email_or_password": "Incorrect email or password",
"library_folder_already_exists": "This import path already exists.", "library_folder_already_exists": "This import path already exists.",
"page_not_found": "Page not found", "page_not_found": "Page not found :/",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.", "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size", "quota_higher_than_disk_size": "You set a quota higher than the disk size",
@@ -1651,7 +1649,6 @@
"only_favorites": "Only favorites", "only_favorites": "Only favorites",
"open": "Open", "open": "Open",
"open_calendar": "Open calendar", "open_calendar": "Open calendar",
"open_in_browser": "Open in browser",
"open_in_map_view": "Open in map view", "open_in_map_view": "Open in map view",
"open_in_openstreetmap": "Open in OpenStreetMap", "open_in_openstreetmap": "Open in OpenStreetMap",
"open_the_search_filters": "Open the search filters", "open_the_search_filters": "Open the search filters",
+12 -13
View File
@@ -64,6 +64,14 @@ class OrtSession:
def _providers_default(self) -> list[str]: def _providers_default(self) -> list[str]:
available_providers = set(ort.get_available_providers()) available_providers = set(ort.get_available_providers())
log.debug(f"Available ORT providers: {available_providers}") log.debug(f"Available ORT providers: {available_providers}")
if (openvino := "OpenVINOExecutionProvider") in available_providers:
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
log.debug(f"Available OpenVINO devices: {device_ids}")
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
if not gpu_devices:
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
available_providers.remove(openvino)
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers] return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
@property @property
@@ -94,19 +102,12 @@ class OrtSession:
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0", "migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
} }
case "OpenVINOExecutionProvider": case "OpenVINOExecutionProvider":
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids() openvino_dir = self.model_path.parent / "openvino"
# Check for available devices, preferring GPU over CPU device = f"GPU.{settings.device_id}"
gpu_devices = [d for d in device_ids if d.startswith("GPU")]
if gpu_devices:
device_type = f"GPU.{settings.device_id}"
log.debug(f"OpenVINO: Using GPU device {device_type}")
else:
device_type = "CPU"
log.debug("OpenVINO: No GPU found, using CPU")
options = { options = {
"device_type": device_type, "device_type": device,
"precision": settings.openvino_precision.value, "precision": settings.openvino_precision.value,
"cache_dir": (self.model_path.parent / "openvino").as_posix(), "cache_dir": openvino_dir.as_posix(),
} }
case "CoreMLExecutionProvider": case "CoreMLExecutionProvider":
options = { options = {
@@ -138,14 +139,12 @@ class OrtSession:
sess_options.enable_cpu_mem_arena = settings.model_arena sess_options.enable_cpu_mem_arena = settings.model_arena
# avoid thread contention between models # avoid thread contention between models
# Set inter_op threads
if settings.model_inter_op_threads > 0: if settings.model_inter_op_threads > 0:
sess_options.inter_op_num_threads = settings.model_inter_op_threads sess_options.inter_op_num_threads = settings.model_inter_op_threads
# these defaults work well for CPU, but bottleneck GPU # these defaults work well for CPU, but bottleneck GPU
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]: elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
sess_options.inter_op_num_threads = 1 sess_options.inter_op_num_threads = 1
# Set intra_op threads
if settings.model_intra_op_threads > 0: if settings.model_intra_op_threads > 0:
sess_options.intra_op_num_threads = settings.model_intra_op_threads sess_options.intra_op_num_threads = settings.model_intra_op_threads
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]: elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
+9 -34
View File
@@ -204,6 +204,13 @@ class TestOrtSession:
assert session.providers == self.OV_EP assert session.providers == self.OV_EP
@pytest.mark.ov_device_ids(["CPU"])
@pytest.mark.providers(OV_EP)
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], ov_device_ids: list[str]) -> None:
session = OrtSession("ViT-B-32__openai")
assert session.providers == self.CPU_EP
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER) @pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None: def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
session = OrtSession("ViT-B-32__openai") session = OrtSession("ViT-B-32__openai")
@@ -249,8 +256,7 @@ class TestOrtSession:
{"arena_extend_strategy": "kSameAsRequested"}, {"arena_extend_strategy": "kSameAsRequested"},
] ]
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"]) def test_sets_provider_options_for_openvino(self) -> None:
def test_sets_provider_options_for_openvino(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/textual/model.onnx" model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
@@ -264,8 +270,7 @@ class TestOrtSession:
} }
] ]
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"]) def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None:
def test_sets_openvino_to_fp16_if_enabled(self, ov_device_ids: list[str], mocker: MockerFixture) -> None:
model_path = "/cache/ViT-B-32__openai/textual/model.onnx" model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16) mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
@@ -280,19 +285,6 @@ class TestOrtSession:
} }
] ]
@pytest.mark.ov_device_ids(["CPU"])
def test_sets_provider_options_for_openvino_cpu(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/model.onnx"
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
assert session.provider_options == [
{
"device_type": "CPU",
"precision": "FP32",
"cache_dir": "/cache/ViT-B-32__openai/openvino",
}
]
def test_sets_provider_options_for_cuda(self) -> None: def test_sets_provider_options_for_cuda(self) -> None:
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
@@ -349,23 +341,6 @@ class TestOrtSession:
assert session.sess_options.inter_op_num_threads == 1 assert session.sess_options.inter_op_num_threads == 1
assert session.sess_options.intra_op_num_threads == 2 assert session.sess_options.intra_op_num_threads == 2
@pytest.mark.ov_device_ids(["CPU"])
def test_sets_default_sess_options_if_openvino_cpu(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/model.onnx"
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
assert session.sess_options.inter_op_num_threads == 0
assert session.sess_options.intra_op_num_threads == 0
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
def test_sets_default_sess_options_if_openvino_gpu(self, ov_device_ids: list[str]) -> None:
model_path = "/cache/ViT-B-32__openai/model.onnx"
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
assert session.sess_options.inter_op_num_threads == 0
assert session.sess_options.intra_op_num_threads == 0
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None: def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
-3
View File
@@ -3,7 +3,6 @@ plugins {
id "kotlin-android" id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
id 'com.google.devtools.ksp' id 'com.google.devtools.ksp'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
} }
@@ -113,8 +112,6 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation 'org.chromium.net:cronet-embedded:143.7445.0' implementation 'org.chromium.net:cronet-embedded:143.7445.0'
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
@@ -12,7 +12,6 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.core.NetworkApiPlugin import app.alextran.immich.core.NetworkApiPlugin
import me.albemala.native_video_player.NativeVideoPlayerPlugin
import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi import app.alextran.immich.images.RemoteImageApi
@@ -32,7 +31,6 @@ class MainActivity : FlutterFragmentActivity() {
companion object { companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx) HttpClientManager.initialize(ctx)
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
flutterEngine.plugins.add(NetworkApiPlugin()) flutterEngine.plugins.add(NetworkApiPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger val messenger = flutterEngine.dartExecutor.binaryMessenger
@@ -3,41 +3,23 @@ package app.alextran.immich.core
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.security.KeyChain import android.security.KeyChain
import androidx.annotation.OptIn
import androidx.core.content.edit import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import app.alextran.immich.BuildConfig import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeBuffer
import okhttp3.Cache import okhttp3.Cache
import okhttp3.ConnectionPool import okhttp3.ConnectionPool
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.Credentials
import okhttp3.Dispatcher import okhttp3.Dispatcher
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine import org.json.JSONObject
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.net.Authenticator
import java.net.CookieHandler
import java.net.PasswordAuthentication
import java.net.Socket import java.net.Socket
import java.net.URI
import java.security.KeyStore import java.security.KeyStore
import java.security.Principal import java.security.Principal
import java.security.PrivateKey import java.security.PrivateKey
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
@@ -50,26 +32,13 @@ private const val CERT_ALIAS = "client_cert"
private const val PREFS_NAME = "immich.ssl" private const val PREFS_NAME = "immich.ssl"
private const val PREFS_CERT_ALIAS = "immich.client_cert" private const val PREFS_CERT_ALIAS = "immich.client_cert"
private const val PREFS_HEADERS = "immich.request_headers" private const val PREFS_HEADERS = "immich.request_headers"
private const val PREFS_SERVER_URLS = "immich.server_urls" private const val PREFS_SERVER_URL = "immich.server_url"
private const val PREFS_COOKIES = "immich.cookies"
private const val COOKIE_EXPIRY_DAYS = 400L
private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
ACCESS_TOKEN("immich_access_token", httpOnly = true),
IS_AUTHENTICATED("immich_is_authenticated", httpOnly = false),
AUTH_TYPE("immich_auth_type", httpOnly = true);
companion object {
val names = entries.map { it.cookieName }.toSet()
}
}
/** /**
* Manages a shared OkHttpClient with SSL configuration support. * Manages a shared OkHttpClient with SSL configuration support.
*/ */
object HttpClientManager { object HttpClientManager {
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
private const val KEEP_ALIVE_CONNECTIONS = 10 private const val KEEP_ALIVE_CONNECTIONS = 10
private const val KEEP_ALIVE_DURATION_MINUTES = 5L private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val MAX_REQUESTS_PER_HOST = 64 private const val MAX_REQUESTS_PER_HOST = 64
@@ -81,11 +50,6 @@ object HttpClientManager {
private lateinit var appContext: Context private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
var cronetEngine: CronetEngine? = null
private set
private lateinit var cronetStorageDir: File
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
var keyChainAlias: String? = null var keyChainAlias: String? = null
@@ -94,8 +58,6 @@ object HttpClientManager {
var headers: Headers = Headers.headersOf() var headers: Headers = Headers.headersOf()
private set private set
private val cookieJar = PersistentCookieJar()
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS) val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
fun initialize(context: Context) { fun initialize(context: Context) {
@@ -107,48 +69,18 @@ object HttpClientManager {
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null) keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
cookieJar.init(prefs)
System.setProperty("http.agent", USER_AGENT)
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? {
val url = requestingURL ?: return null
if (url.userInfo.isNullOrEmpty()) return null
val parts = url.userInfo.split(":", limit = 2)
return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray())
}
})
CookieHandler.setDefault(object : CookieHandler() {
override fun get(uri: URI, requestHeaders: Map<String, List<String>>): Map<String, List<String>> {
val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap()
val cookies = cookieJar.loadForRequest(httpUrl)
if (cookies.isEmpty()) return emptyMap()
return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" }))
}
override fun put(uri: URI, responseHeaders: Map<String, List<String>>) {}
})
val savedHeaders = prefs.getString(PREFS_HEADERS, null) val savedHeaders = prefs.getString(PREFS_HEADERS, null)
if (savedHeaders != null) { if (savedHeaders != null) {
val map = Json.decodeFromString<Map<String, String>>(savedHeaders) val json = JSONObject(savedHeaders)
val builder = Headers.Builder() val builder = Headers.Builder()
for ((key, value) in map) { for (key in json.keys()) {
builder.add(key, value) builder.add(key, json.getString(key))
} }
headers = builder.build() headers = builder.build()
} }
val serverUrlsJson = prefs.getString(PREFS_SERVER_URLS, null)
if (serverUrlsJson != null) {
cookieJar.setServerUrls(Json.decodeFromString<List<String>>(serverUrlsJson))
}
val cacheDir = File(File(context.cacheDir, "okhttp"), "api") val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir) client = build(cacheDir)
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
cronetEngine = buildCronetEngine()
initialized = true initialized = true
} }
} }
@@ -221,97 +153,25 @@ object HttpClientManager {
synchronized(this) { clientChangedListeners.add(listener) } synchronized(this) { clientChangedListeners.add(listener) }
} }
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>, token: String?) { fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>) {
synchronized(this) { synchronized(this) {
val builder = Headers.Builder() val builder = Headers.Builder()
headerMap.forEach { (key, value) -> builder[key] = value } headerMap.forEach { (key, value) -> builder[key] = value }
val newHeaders = builder.build() val newHeaders = builder.build()
val headersChanged = headers != newHeaders val headersChanged = headers != newHeaders
val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null) val newUrl = serverUrls.firstOrNull()
val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null)
if (!headersChanged && !urlChanged) return
headers = newHeaders headers = newHeaders
cookieJar.setServerUrls(serverUrls) prefs.edit {
if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString())
if (headersChanged || urlsChanged) { if (urlChanged) {
prefs.edit { if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL)
putString(PREFS_HEADERS, Json.encodeToString(headerMap))
putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls))
} }
} }
if (token != null) {
val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return
val expiry = System.currentTimeMillis() + COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000
val values = mapOf(
AuthCookie.ACCESS_TOKEN to token,
AuthCookie.IS_AUTHENTICATED to "true",
AuthCookie.AUTH_TYPE to "password",
)
cookieJar.saveFromResponse(url, values.map { (cookie, value) ->
Cookie.Builder().name(cookie.cookieName).value(value).domain(url.host).path("/").expiresAt(expiry)
.apply {
if (url.isHttps) secure()
if (cookie.httpOnly) httpOnly()
}.build()
})
}
} }
} }
fun loadCookieHeader(url: String): String? {
val httpUrl = url.toHttpUrlOrNull() ?: return null
return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() }
?.joinToString("; ") { "${it.name}=${it.value}" }
}
fun getAuthHeaders(url: String): Map<String, String> {
val result = mutableMapOf<String, String>()
headers.forEach { (key, value) -> result[key] = value }
loadCookieHeader(url)?.let { result["Cookie"] = it }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
}
}
return result
}
fun rebuildCronetEngine(): CronetEngine {
val old = cronetEngine!!
cronetEngine = buildCronetEngine()
return old
}
val cronetStoragePath: File get() = cronetStorageDir
@OptIn(UnstableApi::class)
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
return if (isMtls) {
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
} else {
ResolvingDataSource.Factory(
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
) { dataSpec ->
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
newHeaders["Cache-Control"] = "no-store"
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
}
}
}
private fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(cronetStorageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
.build()
}
private fun build(cacheDir: File): OkHttpClient { private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool( val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS, maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -328,7 +188,6 @@ object HttpClientManager {
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
return OkHttpClient.Builder() return OkHttpClient.Builder()
.cookieJar(cookieJar)
.addInterceptor { .addInterceptor {
val request = it.request() val request = it.request()
val builder = request.newBuilder() val builder = request.newBuilder()
@@ -390,131 +249,4 @@ object HttpClientManager {
socket: Socket? socket: Socket?
): String? = null ): String? = null
} }
/**
* Persistent CookieJar that duplicates auth cookies across equivalent server URLs.
* When the server sets cookies for one domain, copies are created for all other known
* server domains (for URL switching between local/remote endpoints of the same server).
*/
private class PersistentCookieJar : CookieJar {
private val store = mutableListOf<Cookie>()
private var serverUrls = listOf<HttpUrl>()
private var prefs: SharedPreferences? = null
fun init(prefs: SharedPreferences) {
this.prefs = prefs
restore()
}
@Synchronized
fun setServerUrls(urls: List<String>) {
val parsed = urls.mapNotNull { it.toHttpUrlOrNull() }
if (parsed.map { it.host } == serverUrls.map { it.host }) return
serverUrls = parsed
if (syncAuthCookies()) persist()
}
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val changed = cookies.any { new ->
store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value }
}
store.removeAll { existing ->
cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
}
store.addAll(cookies)
val synced = serverUrls.any { it.host == url.host } && syncAuthCookies()
if (changed || synced) persist()
}
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val now = System.currentTimeMillis()
if (store.removeAll { it.expiresAt < now }) {
syncAuthCookies()
persist()
}
return store.filter { it.matches(url) }
}
private fun syncAuthCookies(): Boolean {
val serverHosts = serverUrls.map { it.host }.toSet()
val now = System.currentTimeMillis()
val sourceCookies = store
.filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now }
.associateBy { it.name }
if (sourceCookies.isEmpty()) {
return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts }
}
var changed = false
for (url in serverUrls) {
for ((_, source) in sourceCookies) {
if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue
store.removeAll { it.name == source.name && it.domain == url.host }
store.add(rebuildCookie(source, url))
changed = true
}
}
return changed
}
private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie {
return Cookie.Builder()
.name(source.name).value(source.value)
.domain(url.host).path("/")
.expiresAt(source.expiresAt)
.apply {
if (url.isHttps) secure()
if (source.httpOnly) httpOnly()
}
.build()
}
private fun persist() {
val p = prefs ?: return
p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) }
}
private fun restore() {
val p = prefs ?: return
val jsonStr = p.getString(PREFS_COOKIES, null) ?: return
try {
store.addAll(Json.decodeFromString<List<SerializedCookie>>(jsonStr).map { it.toCookie() })
} catch (_: Exception) {
store.clear()
}
}
}
@Serializable
private data class SerializedCookie(
val name: String,
val value: String,
val domain: String,
val path: String,
val expiresAt: Long,
val secure: Boolean,
val httpOnly: Boolean,
val hostOnly: Boolean,
) {
fun toCookie(): Cookie = Cookie.Builder()
.name(name).value(value).path(path).expiresAt(expiresAt)
.apply {
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
if (secure) secure()
if (httpOnly) httpOnly()
}
.build()
companion object {
fun from(cookie: Cookie) = SerializedCookie(
name = cookie.name, value = cookie.value, domain = cookie.domain,
path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure,
httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly,
)
}
}
} }
@@ -184,7 +184,7 @@ interface NetworkApi {
fun removeCertificate(callback: (Result<Unit>) -> Unit) fun removeCertificate(callback: (Result<Unit>) -> Unit)
fun hasCertificate(): Boolean fun hasCertificate(): Boolean
fun getClientPointer(): Long fun getClientPointer(): Long
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?) fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>)
companion object { companion object {
/** The codec used by NetworkApi. */ /** The codec used by NetworkApi. */
@@ -287,9 +287,8 @@ interface NetworkApi {
val args = message as List<Any?> val args = message as List<Any?>
val headersArg = args[0] as Map<String, String> val headersArg = args[0] as Map<String, String>
val serverUrlsArg = args[1] as List<String> val serverUrlsArg = args[1] as List<String>
val tokenArg = args[2] as String?
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg) api.setRequestHeaders(headersArg, serverUrlsArg)
listOf(null) listOf(null)
} catch (exception: Throwable) { } catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception) NetworkPigeonUtils.wrapError(exception)
@@ -39,7 +39,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
} }
} }
private class NetworkApiImpl : NetworkApi { private class NetworkApiImpl() : NetworkApi {
var activity: Activity? = null var activity: Activity? = null
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) { override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
@@ -79,7 +79,7 @@ private class NetworkApiImpl : NetworkApi {
return HttpClientManager.getClientPointer() return HttpClientManager.getClientPointer()
} }
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?) { override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>) {
HttpClientManager.setRequestHeaders(headers, serverUrls, token) HttpClientManager.setRequestHeaders(headers, serverUrls)
} }
} }
@@ -7,6 +7,7 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.USER_AGENT
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.Cache import okhttp3.Cache
import okhttp3.Call import okhttp3.Call
@@ -14,6 +15,9 @@ import okhttp3.Callback
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException import org.chromium.net.CronetException
import org.chromium.net.UrlRequest import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo import org.chromium.net.UrlResponseInfo
@@ -27,6 +31,10 @@ import java.nio.file.Path
import java.nio.file.SimpleFileVisitor import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal) private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -93,6 +101,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
} }
private object ImageFetcherManager { private object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher private lateinit var fetcher: ImageFetcher
private var initialized = false private var initialized = false
@@ -101,6 +110,7 @@ private object ImageFetcherManager {
if (initialized) return if (initialized) return
synchronized(this) { synchronized(this) {
if (initialized) return if (initialized) return
appContext = context.applicationContext
cacheDir = context.cacheDir cacheDir = context.cacheDir
fetcher = build() fetcher = build()
HttpClientManager.addClientChangedListener(::invalidate) HttpClientManager.addClientChangedListener(::invalidate)
@@ -133,7 +143,7 @@ private object ImageFetcherManager {
return if (HttpClientManager.isMtls) { return if (HttpClientManager.isMtls) {
OkHttpImageFetcher.create(cacheDir) OkHttpImageFetcher.create(cacheDir)
} else { } else {
CronetImageFetcher() CronetImageFetcher(appContext, cacheDir)
} }
} }
} }
@@ -151,11 +161,19 @@ private sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit) fun clearCache(onCleared: (Result<Long>) -> Unit)
} }
private class CronetImageFetcher : ImageFetcher { private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val ctx = context
private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
private val stateLock = Any() private val stateLock = Any()
private var activeCount = 0 private var activeCount = 0
private var draining = false private var draining = false
private var onCacheCleared: ((Result<Long>) -> Unit)? = null private var onCacheCleared: ((Result<Long>) -> Unit)? = null
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
init {
engine = build(context)
}
override fun fetch( override fun fetch(
url: String, url: String,
@@ -172,16 +190,29 @@ private class CronetImageFetcher : ImageFetcher {
} }
val callback = FetchCallback(onSuccess, onFailure, ::onComplete) val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = HttpClientManager.cronetEngine!! val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor) HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
HttpClientManager.getAuthHeaders(url).forEach { (key, value) -> url.toHttpUrlOrNull()?.let { httpUrl ->
requestBuilder.addHeader(key, value) if (httpUrl.username.isNotEmpty()) {
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
}
} }
val request = requestBuilder.build() val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel) signal.setOnCancelListener(request::cancel)
request.start() request.start()
} }
private fun build(ctx: Context): CronetEngine {
return CronetEngine.Builder(ctx)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(storageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
.build()
}
private fun onComplete() { private fun onComplete() {
val didDrain = synchronized(stateLock) { val didDrain = synchronized(stateLock) {
activeCount-- activeCount--
@@ -204,16 +235,19 @@ private class CronetImageFetcher : ImageFetcher {
} }
private fun onDrained() { private fun onDrained() {
engine.shutdown()
val onCacheCleared = synchronized(stateLock) { val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared val onCacheCleared = onCacheCleared
this.onCacheCleared = null this.onCacheCleared = null
onCacheCleared onCacheCleared
} }
if (onCacheCleared != null) { if (onCacheCleared == null) {
val oldEngine = HttpClientManager.rebuildCronetEngine() executor.shutdown()
oldEngine.shutdown() } else {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) } val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
engine = build(ctx)
synchronized(stateLock) { draining = false } synchronized(stateLock) { draining = false }
onCacheCleared(result) onCacheCleared(result)
} }
@@ -340,7 +374,7 @@ private class OkHttpImageFetcher private constructor(
val dir = File(cacheDir, "okhttp") val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.getClient().newBuilder() val client = HttpClientManager.getClient().newBuilder()
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES)) .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
.build() .build()
return OkHttpImageFetcher(client) return OkHttpImageFetcher(client)
@@ -16,7 +16,6 @@ import app.alextran.immich.core.ImmichPlugin
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -82,13 +81,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
} }
if (hasSpecialFormatColumn()) { if (hasSpecialFormatColumn()) {
add(SPECIAL_FORMAT_COLUMN) add(SPECIAL_FORMAT_COLUMN)
} else { } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// fallback to mimetype and xmp for playback style detection on older Android versions // Fallback: read XMP from MediaStore to detect Motion Photos
// both only needed if special format column is not available // only needed if SPECIAL_FORMAT column isn't available
add(MediaStore.MediaColumns.MIME_TYPE) add(MediaStore.MediaColumns.XMP)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.XMP)
}
} }
}.toTypedArray() }.toTypedArray()
@@ -135,7 +131,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
@@ -182,20 +177,19 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
val playbackStyle = detectPlaybackStyle( val playbackStyle = detectPlaybackStyle(
numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c numericId, rawMediaType, specialFormatColumn, xmpColumn, c
) )
val isFlipped = orientation == 90 || orientation == 270
val asset = PlatformAsset( val asset = PlatformAsset(
id, id,
name, name,
assetType, assetType,
createdAt, createdAt,
modifiedAt, modifiedAt,
if (isFlipped) height else width, width,
if (isFlipped) width else height, height,
duration, duration,
0L, orientation.toLong(),
isFavorite, isFavorite,
playbackStyle = playbackStyle, playbackStyle = playbackStyle,
) )
@@ -206,14 +200,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
} }
/** /**
* Detects the playback style for an asset using _special_format (SDK Extension 21+) * Detects the playback style for an asset using _special_format (API 33+)
* or XMP / MIME / RIFF header fallbacks. * or XMP / MIME / RIFF header fallbacks (pre-33).
*/ */
@SuppressLint("NewApi") @SuppressLint("NewApi")
private fun detectPlaybackStyle( private fun detectPlaybackStyle(
assetId: Long, assetId: Long,
rawMediaType: Int, rawMediaType: Int,
mimeTypeColumn: Int,
specialFormatColumn: Int, specialFormatColumn: Int,
xmpColumn: Int, xmpColumn: Int,
cursor: Cursor cursor: Cursor
@@ -238,56 +231,46 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
return PlatformAssetPlaybackStyle.UNKNOWN return PlatformAssetPlaybackStyle.UNKNOWN
} }
val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null // Pre-API 33 fallback
// GIFs are always animated and cannot be motion photos; no I/O needed
if (mimeType == "image/gif") {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
val uri = ContentUris.withAppendedId( val uri = ContentUris.withAppendedId(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
assetId assetId
) )
// Only WebP needs a stream check to distinguish static vs animated; // Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
// WebP files are not used as motion photos, so skip XMP detection
if (mimeType == "image/webp") {
try {
val glide = Glide.get(ctx)
ctx.contentResolver.openInputStream(uri)?.use { stream ->
val type = ImageHeaderParserUtils.getType(
listOf(DefaultImageHeaderParser()),
stream,
glide.arrayPool
)
// Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance
if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
// if mimeType is webp but not animated, its just an image.
return PlatformAssetPlaybackStyle.IMAGE
}
// Read XMP from cursor (API 30+)
val xmp: String? = if (xmpColumn != -1) { val xmp: String? = if (xmpColumn != -1) {
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8) cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
} else { } else {
// if xmp column is not available, we are on API 29 or below try {
// theoretically there were motion photos but the Camera:MotionPhoto xmp tag ctx.contentResolver.openInputStream(uri)?.use { stream ->
// was only added in Android 11, so we should not have to worry about parsing XMP on older versions ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
null }
} catch (e: Exception) {
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
null
}
} }
if (xmp != null && "Camera:MotionPhoto" in xmp) { if (xmp != null && "Camera:MotionPhoto" in xmp) {
return PlatformAssetPlaybackStyle.LIVE_PHOTO return PlatformAssetPlaybackStyle.LIVE_PHOTO
} }
try {
ctx.contentResolver.openInputStream(uri)?.use { stream ->
val glide = Glide.get(ctx)
val type = ImageHeaderParserUtils.getType(
glide.registry.imageHeaderParsers,
stream,
glide.arrayPool
)
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
return PlatformAssetPlaybackStyle.IMAGE return PlatformAssetPlaybackStyle.IMAGE
} }
-3
View File
@@ -1,6 +1,5 @@
import BackgroundTasks import BackgroundTasks
import Flutter import Flutter
import native_video_player
import network_info_plus import network_info_plus
import path_provider_foundation import path_provider_foundation
import permission_handler_apple import permission_handler_apple
@@ -19,8 +18,6 @@ import UIKit
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
} }
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
URLSessionManager.patchBackgroundDownloader()
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine, controller: controller) AppDelegate.registerPlugins(with: controller.engine, controller: controller)
+2 -3
View File
@@ -225,7 +225,7 @@ protocol NetworkApi {
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void) func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
func hasCertificate() throws -> Bool func hasCertificate() throws -> Bool
func getClientPointer() throws -> Int64 func getClientPointer() throws -> Int64
func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -315,9 +315,8 @@ class NetworkApiSetup {
let args = message as! [Any?] let args = message as! [Any?]
let headersArg = args[0] as! [String: String] let headersArg = args[0] as! [String: String]
let serverUrlsArg = args[1] as! [String] let serverUrlsArg = args[1] as! [String]
let tokenArg: String? = nilOrValue(args[2])
do { do {
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg) try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg)
reply(wrapResult(nil)) reply(wrapResult(nil))
} catch { } catch {
reply(wrapError(error)) reply(wrapError(error))
+18 -15
View File
@@ -58,39 +58,42 @@ class NetworkApiImpl: NetworkApi {
return Int64(Int(bitPattern: pointer)) return Int64(Int(bitPattern: pointer))
} }
func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws { func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws {
URLSessionManager.setServerUrls(serverUrls) var headers = headers
if let token = headers.removeValue(forKey: "x-immich-user-token") {
if let token = token {
let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60)
for serverUrl in serverUrls { for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue } guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https") let isSecure = serverUrl.hasPrefix("https")
let values: [AuthCookie: String] = [ let cookies: [(String, String, Bool)] = [
.accessToken: token, ("immich_access_token", token, true),
.isAuthenticated: "true", ("immich_is_authenticated", "true", false),
.authType: "password", ("immich_auth_type", "password", true),
] ]
for (cookie, value) in values { let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60)
for (name, value, httpOnly) in cookies {
var properties: [HTTPCookiePropertyKey: Any] = [ var properties: [HTTPCookiePropertyKey: Any] = [
.name: cookie.name, .name: name,
.value: value, .value: value,
.domain: domain, .domain: domain,
.path: "/", .path: "/",
.expires: expiry, .expires: expiry,
] ]
if isSecure { properties[.secure] = "TRUE" } if isSecure { properties[.secure] = "TRUE" }
if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" } if httpOnly { properties[.init("HttpOnly")] = "TRUE" }
if let httpCookie = HTTPCookie(properties: properties) { if let cookie = HTTPCookie(properties: properties) {
URLSessionManager.cookieStorage.setCookie(httpCookie) URLSessionManager.cookieStorage.setCookie(cookie)
} }
} }
} }
} }
if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) {
UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY)
}
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] { if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
UserDefaults.group.set(headers, forKey: HEADERS_KEY) UserDefaults.group.set(headers, forKey: HEADERS_KEY)
URLSessionManager.shared.recreateSession() URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart
} }
} }
} }
+2 -140
View File
@@ -3,30 +3,8 @@ import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers" let HEADERS_KEY = "immich.request_headers"
let SERVER_URLS_KEY = "immich.server_urls" let SERVER_URL_KEY = "immich.server_url"
let APP_GROUP = "group.app.immich.share" let APP_GROUP = "group.app.immich.share"
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
enum AuthCookie: CaseIterable {
case accessToken, isAuthenticated, authType
var name: String {
switch self {
case .accessToken: return "immich_access_token"
case .isAuthenticated: return "immich_is_authenticated"
case .authType: return "immich_auth_type"
}
}
var httpOnly: Bool {
switch self {
case .accessToken, .authType: return true
case .isAuthenticated: return false
}
}
static let names: Set<String> = Set(allCases.map(\.name))
}
extension UserDefaults { extension UserDefaults {
static let group = UserDefaults(suiteName: APP_GROUP)! static let group = UserDefaults(suiteName: APP_GROUP)!
@@ -51,13 +29,11 @@ class URLSessionManager: NSObject {
diskCapacity: 1024 * 1024 * 1024, diskCapacity: 1024 * 1024 * 1024,
directory: cacheDir directory: cacheDir
) )
static let userAgent: String = { private static let userAgent: String = {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
return "Immich_iOS_\(version)" return "Immich_iOS_\(version)"
}() }()
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP) static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
private static var serverUrls: [String] = []
private static var isSyncing = false
var sessionPointer: UnsafeMutableRawPointer { var sessionPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(session).toOpaque() Unmanaged.passUnretained(session).toOpaque()
@@ -67,83 +43,12 @@ class URLSessionManager: NSObject {
delegate = URLSessionManagerDelegate() delegate = URLSessionManagerDelegate()
session = Self.buildSession(delegate: delegate) session = Self.buildSession(delegate: delegate)
super.init() super.init()
Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? []
NotificationCenter.default.addObserver(
Self.self,
selector: #selector(Self.cookiesDidChange),
name: NSNotification.Name.NSHTTPCookieManagerCookiesChanged,
object: Self.cookieStorage
)
} }
func recreateSession() { func recreateSession() {
session = Self.buildSession(delegate: delegate) session = Self.buildSession(delegate: delegate)
} }
static func setServerUrls(_ urls: [String]) {
guard urls != serverUrls else { return }
serverUrls = urls
UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY)
syncAuthCookies()
}
@objc private static func cookiesDidChange(_ notification: Notification) {
guard !isSyncing, !serverUrls.isEmpty else { return }
syncAuthCookies()
}
private static func syncAuthCookies() {
let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host })
let allCookies = cookieStorage.cookies ?? []
let now = Date()
let serverAuthCookies = allCookies.filter {
AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain)
}
var sourceCookies: [String: HTTPCookie] = [:]
for cookie in serverAuthCookies {
if cookie.expiresDate.map({ $0 > now }) ?? true {
sourceCookies[cookie.name] = cookie
}
}
isSyncing = true
defer { isSyncing = false }
if sourceCookies.isEmpty {
for cookie in serverAuthCookies {
cookieStorage.deleteCookie(cookie)
}
return
}
for serverUrl in serverUrls {
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
let isSecure = serverUrl.hasPrefix("https")
for (_, source) in sourceCookies {
if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) {
continue
}
var properties: [HTTPCookiePropertyKey: Any] = [
.name: source.name,
.value: source.value,
.domain: domain,
.path: "/",
.expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60),
]
if isSecure { properties[.secure] = "TRUE" }
if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" }
if let cookie = HTTPCookie(properties: properties) {
cookieStorage.setCookie(cookie)
}
}
}
}
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession { private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.urlCache = urlCache config.urlCache = urlCache
@@ -158,49 +63,6 @@ class URLSessionManager: NSObject {
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
} }
/// Patches background_downloader's URLSession to use shared auth configuration.
/// Must be called before background_downloader creates its session (i.e. early in app startup).
static func patchBackgroundDownloader() {
// Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config
let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:")
let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:))
if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel),
let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) {
method_exchangeImplementations(original, swizzled)
}
// Add auth challenge handling to background_downloader's UrlSessionDelegate
guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return }
let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge,
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
= { _, session, challenge, completion in
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion)
}
class_replaceMethod(targetClass,
NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"),
imp_implementationWithBlock(sessionBlock), "v@:@@@?")
let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge,
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
= { _, session, task, challenge, completion in
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task)
}
class_replaceMethod(targetClass,
NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"),
imp_implementationWithBlock(taskBlock), "v@:@@@@?")
}
}
private extension URLSessionConfiguration {
@objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration {
// After swizzle, this calls the original implementation
let config = immich_background(withIdentifier: id)
config.httpCookieStorage = URLSessionManager.cookieStorage
config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent]
return config
}
} }
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate { class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
@@ -357,12 +357,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
completionHandler: { error in completionHandler: { error in
let result: HashResult? = switch (error) { let result: HashResult? = switch (error) {
case let e as PHPhotosError where e.code == .userCancelled: nil case let e as PHPhotosError where e.code == .userCancelled: nil
case let e as PHPhotosError where e.code == .networkAccessRequired:
HashResult(
assetId: asset.localIdentifier,
error: "ICLOUD_ONLY",
hash: nil
)
case let .some(e): HashResult( case let .some(e): HashResult(
assetId: asset.localIdentifier, assetId: asset.localIdentifier,
error: "Failed to hash asset: \(e.localizedDescription)", error: "Failed to hash asset: \(e.localizedDescription)",
+1 -1
View File
@@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA); const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75); const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
const Color red400 = Color(0xFFEF5350); const Color red400 = Color(0xFFEF5350);
const Color grey200 = Color(0xFFEEEEEE); const Color grey200 = Color(0xFFEEEEEE);
@@ -46,7 +46,6 @@ sealed class BaseAsset {
bool get isVideo => type == AssetType.video; bool get isVideo => type == AssetType.video;
bool get isMotionPhoto => livePhotoVideoId != null; bool get isMotionPhoto => livePhotoVideoId != null;
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
AssetPlaybackStyle get playbackStyle { AssetPlaybackStyle get playbackStyle {
if (isVideo) return AssetPlaybackStyle.video; if (isVideo) return AssetPlaybackStyle.video;
+1 -7
View File
@@ -109,11 +109,9 @@ class HashService {
_log.fine("Hashing ${toHash.length} files"); _log.fine("Hashing ${toHash.length} files");
final hashed = <String, String>{}; final hashed = <String, String>{};
// Never download from iCloud just to hash. iCloud-only assets will be
// uploaded directly and the server will compute + return their checksum.
final hashResults = await _nativeSyncApi.hashAssets( final hashResults = await _nativeSyncApi.hashAssets(
toHash.keys.toList(), toHash.keys.toList(),
allowNetworkAccess: false, allowNetworkAccess: album.backupSelection == BackupSelection.selected,
); );
assert( assert(
hashResults.length == toHash.length, hashResults.length == toHash.length,
@@ -129,10 +127,6 @@ class HashService {
final hashResult = hashResults[i]; final hashResult = hashResults[i];
if (hashResult.hash != null) { if (hashResult.hash != null) {
hashed[hashResult.assetId] = hashResult.hash!; hashed[hashResult.assetId] = hashResult.hash!;
} else if (hashResult.error == 'ICLOUD_ONLY') {
// Asset is in iCloud and not available locally. It will be uploaded
// directly and the server will compute its checksum.
_log.fine("Skipping iCloud-only asset ${hashResult.assetId} from album: ${album.name}");
} else { } else {
final asset = toHash[hashResult.assetId]; final asset = toHash[hashResult.assetId];
_log.warning( _log.warning(
@@ -26,8 +26,8 @@ class NetworkRepository {
} }
} }
static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls, {String? token}) async { static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls) async {
await networkApi.setRequestHeaders(headers, serverUrls, token); await networkApi.setRequestHeaders(headers, serverUrls);
if (Platform.isIOS) { if (Platform.isIOS) {
await init(); await init();
} }
@@ -148,12 +148,10 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
children: [ children: [
Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1), Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1),
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Text(
child: Text( context.t.backup_error_sync_failed,
context.t.backup_error_sync_failed, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), textAlign: TextAlign.center,
textAlign: TextAlign.center,
),
), ),
], ],
), ),
@@ -346,7 +344,6 @@ class _RemainderCard extends ConsumerWidget {
remainderCount.toString(), remainderCount.toString(),
style: context.textTheme.titleLarge?.copyWith( style: context.textTheme.titleLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255), color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
fontFeatures: [const FontFeature.tabularFigures()],
), ),
), ),
if (syncStatus.isRemoteSyncing) if (syncStatus.isRemoteSyncing)
@@ -486,7 +483,6 @@ class _PreparingStatusState extends ConsumerState {
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium?.copyWith(
color: context.colorScheme.primary, color: context.colorScheme.primary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontFeatures: [const FontFeature.tabularFigures()],
), ),
), ),
], ],
@@ -511,7 +507,6 @@ class _PreparingStatusState extends ConsumerState {
style: context.textTheme.titleMedium?.copyWith( style: context.textTheme.titleMedium?.copyWith(
color: context.primaryColor, color: context.primaryColor,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontFeatures: [const FontFeature.tabularFigures()],
), ),
), ),
], ],
+2 -2
View File
@@ -281,7 +281,7 @@ class NetworkApi {
} }
} }
Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token) async { Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -289,7 +289,7 @@ class NetworkApi {
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls, token]); final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);
@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return; if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent()); EventStream.shared.emit(const ViewerReloadAssetEvent());
} }
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) { if (context.mounted) {
@@ -57,13 +57,13 @@ class DeleteActionButton extends ConsumerWidget {
if (confirm != true) return; if (confirm != true) return;
} }
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent()); EventStream.shared.emit(const ViewerReloadAssetEvent());
} }
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()}); final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) { if (context.mounted) {
@@ -35,13 +35,13 @@ class DeletePermanentActionButton extends ConsumerWidget {
false; false;
if (!confirm) return; if (!confirm) return;
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent()); EventStream.shared.emit(const ViewerReloadAssetEvent());
} }
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_permanently_action_prompt'.t( final successMessage = 'delete_permanently_action_prompt'.t(
context: context, context: context,
args: {'count': result.count.toString()}, args: {'count': result.count.toString()},
@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return; if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent()); EventStream.shared.emit(const ViewerReloadAssetEvent());
} }
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'move_to_lock_folder_action_prompt'.t( final successMessage = 'move_to_lock_folder_action_prompt'.t(
context: context, context: context,
args: {'count': result.count.toString()}, args: {'count': result.count.toString()},
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:url_launcher/url_launcher.dart';
class OpenInBrowserActionButton extends ConsumerWidget {
final String remoteId;
final TimelineOrigin origin;
final bool iconOnly;
final bool menuItem;
final Color? iconColor;
const OpenInBrowserActionButton({
super.key,
required this.remoteId,
required this.origin,
this.iconOnly = false,
this.menuItem = false,
this.iconColor,
});
void _onTap() async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
String originPath = '';
switch (origin) {
case TimelineOrigin.favorite:
originPath = '/favorites';
break;
case TimelineOrigin.trash:
originPath = '/trash';
break;
case TimelineOrigin.archive:
originPath = '/archive';
break;
default:
break;
}
final url = '$serverEndpoint$originPath/photos/$remoteId';
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
label: 'open_in_browser'.t(context: context),
iconData: Icons.open_in_browser,
iconColor: iconColor,
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: _onTap,
);
}
}
@@ -29,13 +29,13 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
return; return;
} }
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent()); EventStream.shared.emit(const ViewerReloadAssetEvent());
} }
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'remove_from_album_action_prompt'.t( final successMessage = 'remove_from_album_action_prompt'.t(
context: context, context: context,
args: {'count': result.count.toString()}, args: {'count': result.count.toString()},
@@ -25,13 +25,13 @@ class TrashActionButton extends ConsumerWidget {
return; return;
} }
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent()); EventStream.shared.emit(const ViewerReloadAssetEvent());
} }
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()}); final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) { if (context.mounted) {
@@ -16,13 +16,13 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return; if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent()); EventStream.shared.emit(const ViewerReloadAssetEvent());
} }
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) { if (context.mounted) {
@@ -19,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
@@ -247,6 +248,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
if (scaleState != PhotoViewScaleState.initial) { if (scaleState != PhotoViewScaleState.initial) {
if (_dragStart == null) _viewer.setControls(false); if (_dragStart == null) _viewer.setControls(false);
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
if (heroTag != null) {
ref.read(videoPlayerProvider(heroTag).notifier).pause();
}
return; return;
} }
@@ -81,17 +81,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
late int _currentPage = widget.initialIndex; late int _currentPage = widget.initialIndex;
late int _totalAssets = ref.read(timelineServiceProvider).totalAssets;
StreamSubscription? _reloadSubscription; StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive; KeepAliveLink? _stackChildrenKeepAlive;
bool _assetReloadRequested = false;
void _onTapNavigate(int direction) { void _onTapNavigate(int direction) {
final page = _pageController.page?.toInt(); final page = _pageController.page?.toInt();
if (page == null) return; if (page == null) return;
final target = page + direction; final target = page + direction;
final maxPage = _totalAssets - 1; final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
if (target >= 0 && target <= maxPage) { if (target >= 0 && target <= maxPage) {
_currentPage = target;
_pageController.jumpToPage(target); _pageController.jumpToPage(target);
_onAssetChanged(target); _onAssetChanged(target);
} }
@@ -139,6 +141,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final page = _pageController.page?.round(); final page = _pageController.page?.round();
if (page != null && page != _currentPage) { if (page != null && page != _currentPage) {
_currentPage = page;
_onAssetChanged(page); _onAssetChanged(page);
} }
return false; return false;
@@ -150,9 +153,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
void _onAssetChanged(int index) async { void _onAssetChanged(int index) async {
_currentPage = index; final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
if (asset == null) return; if (asset == null) return;
AssetViewer._setAsset(ref, asset); AssetViewer._setAsset(ref, asset);
@@ -191,20 +193,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
case TimelineReloadEvent(): case TimelineReloadEvent():
_onTimelineReloadEvent(); _onTimelineReloadEvent();
case ViewerReloadAssetEvent(): case ViewerReloadAssetEvent():
_onViewerReloadEvent(); _assetReloadRequested = true;
default: default:
} }
} }
void _onViewerReloadEvent() {
if (_totalAssets <= 1) return;
final index = _pageController.page?.round() ?? 0;
final target = index >= _totalAssets - 1 ? index - 1 : index + 1;
_pageController.animateToPage(target, duration: Durations.medium1, curve: Curves.easeInOut);
_onAssetChanged(target);
}
void _onTimelineReloadEvent() { void _onTimelineReloadEvent() {
final timelineService = ref.read(timelineServiceProvider); final timelineService = ref.read(timelineServiceProvider);
final totalAssets = timelineService.totalAssets; final totalAssets = timelineService.totalAssets;
@@ -214,24 +207,43 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return; return;
} }
var index = _pageController.page?.round() ?? 0;
final currentAsset = ref.read(assetViewerProvider).currentAsset; final currentAsset = ref.read(assetViewerProvider).currentAsset;
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null; if (currentAsset != null) {
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1); final newIndex = timelineService.getIndex(currentAsset.heroTag);
if (newIndex != null && newIndex != index) {
index = newIndex;
_currentPage = index;
_pageController.jumpToPage(index);
}
}
if (index != _currentPage) { if (index >= totalAssets) {
index = totalAssets - 1;
_currentPage = index;
_pageController.jumpToPage(index); _pageController.jumpToPage(index);
_onAssetChanged(index);
} else if (currentAsset != null && assetIndex == null) {
_onAssetChanged(index);
} }
if (_totalAssets != totalAssets) { if (_assetReloadRequested) {
setState(() { _assetReloadRequested = false;
_totalAssets = totalAssets; _onAssetReloadEvent(index);
});
} }
} }
void _onAssetReloadEvent(int index) async {
final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) return;
final currentAsset = ref.read(assetViewerProvider).currentAsset;
// Do not reload if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) return;
_onAssetChanged(index);
}
void _setSystemUIMode(bool controls, bool details) { void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details) final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky ? SystemUiMode.immersiveSticky
@@ -289,7 +301,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
: CurrentPlatform.isIOS : CurrentPlatform.isIOS
? const FastScrollPhysics() ? const FastScrollPhysics()
: const FastClampingScrollPhysics(), : const FastClampingScrollPhysics(),
itemCount: _totalAssets, itemCount: ref.read(timelineServiceProvider).totalAssets,
itemBuilder: (context, index) => itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate), AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
), ),
@@ -61,27 +61,15 @@ class ViewerBottomBar extends ConsumerWidget {
), ),
), ),
child: Container( child: Container(
decoration: const BoxDecoration( color: Colors.black.withAlpha(125),
gradient: LinearGradient( padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
begin: Alignment.bottomCenter, child: Column(
end: Alignment.topCenter, mainAxisSize: MainAxisSize.min,
colors: [Colors.black45, Colors.black12, Colors.transparent], children: [
stops: [0.0, 0.7, 1.0], if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
), if (!isReadonlyModeEnabled)
), Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
child: SafeArea( ],
top: false,
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
),
), ),
), ),
), ),
@@ -10,6 +10,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
@@ -185,7 +186,11 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
final source = await _videoSource; final source = await _videoSource;
if (source == null || !mounted) return; if (source == null || !mounted) return;
await _notifier.load(source); unawaited(
nc.loadVideoSource(source).catchError((error) {
_log.severe('Error loading video source: $error');
}),
);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo); final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await _notifier.setVolume(1); await _notifier.setVolume(1);
@@ -208,28 +213,21 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); // Prevent the provider from being disposed whilst the widget is alive.
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status)); ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
return IgnorePointer( final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
child: Stack(
children: [ return Stack(
Center(child: widget.image), children: [
if (!isCasting) ...[ Center(child: widget.image),
Visibility.maintain( if (!isCasting)
visible: _isVideoReady, Visibility.maintain(
child: NativeVideoPlayerView(onViewReady: _initController), visible: _isVideoReady,
), child: NativeVideoPlayerView(onViewReady: _initController),
Center( ),
child: AnimatedOpacity( if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0, ],
duration: const Duration(milliseconds: 400),
child: const CircularProgressIndicator(),
),
),
],
],
),
); );
} }
} }
@@ -0,0 +1,114 @@
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/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class VideoViewerControls extends HookConsumerWidget {
final BaseAsset asset;
final Duration hideTimerDuration;
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
@override
Widget build(BuildContext context, WidgetRef ref) {
final videoPlayerName = asset.heroTag;
final assetIsVideo = asset.isVideo;
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
final cast = ref.watch(castProvider);
// A timer to hide the controls
final hideTimer = useTimer(hideTimerDuration, () {
if (!context.mounted) {
return;
}
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
// Do not hide on paused
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
hideTimer.reset();
ref.read(assetViewerProvider.notifier).setControls(true);
}
// When playback starts, reset the hide timer
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
if (next == VideoPlaybackStatus.playing) {
hideTimer.reset();
}
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
if (cast.isCasting) {
switch (cast.castState) {
case CastState.playing:
ref.read(castProvider.notifier).pause();
case CastState.paused:
ref.read(castProvider.notifier).play();
default:
}
return;
}
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
switch (status) {
case VideoPlaybackStatus.playing:
notifier.pause();
case VideoPlaybackStatus.completed:
notifier.restart();
default:
notifier.play();
}
}
void toggleControlsVisibility() {
if (showBuffering) return;
if (showControls) {
ref.read(assetViewerProvider.notifier).setControls(false);
} else {
showControlsAndStartHideTimer();
}
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: toggleControlsVisibility,
child: IgnorePointer(
ignoring: !showControls,
child: Stack(
children: [
if (showBuffering)
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
else
CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: status == VideoPlaybackStatus.completed,
isPlaying:
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
],
),
),
);
}
}
@@ -75,29 +75,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: opacity, opacity: opacity,
duration: Durations.short2, duration: Durations.short2,
child: DecoratedBox( child: AppBar(
decoration: BoxDecoration( backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
gradient: showingDetails leading: const _AppBarBackButton(),
? null iconTheme: const IconThemeData(size: 22, color: Colors.white),
: const LinearGradient( actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
begin: Alignment.topCenter, shape: const Border(),
end: Alignment.bottomCenter, actions: showingDetails || isReadonlyModeEnabled
colors: [Colors.black45, Colors.black12, Colors.transparent], ? null
stops: [0.0, 0.7, 1.0], : isInLockedView
), ? lockedViewActions
), : actions,
child: AppBar(
backgroundColor: Colors.transparent,
leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: showingDetails || isReadonlyModeEnabled
? null
: isInLockedView
? lockedViewActions
: actions,
),
), ),
), ),
); );
@@ -113,14 +101,17 @@ class _AppBarBackButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
return Padding( return Padding(
padding: const EdgeInsets.only(left: 12.0), padding: const EdgeInsets.only(left: 12.0),
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent, backgroundColor: backgroundColor,
shape: const CircleBorder(), shape: const CircleBorder(),
iconSize: 22, iconSize: 22,
iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white, iconColor: foregroundColor,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
elevation: showingDetails ? 4 : 0, elevation: showingDetails ? 4 : 0,
), ),
@@ -1,96 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show InformationCollector;
import 'package:flutter/painting.dart';
/// A [MultiFrameImageStreamCompleter] with support for listener tracking
/// which makes resource cleanup possible when no longer needed.
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once any image or the codec has been provided.
// Until then the image cache holds one listener, so "last real listener gone"
// is _listenerCount == 1, not 0.
bool didProvideImage = false;
AnimatedImageStreamCompleter._({
required super.codec,
required super.scale,
super.informationCollector,
void Function()? onLastListenerRemoved,
}) : _onLastListenerRemoved = onLastListenerRemoved;
factory AnimatedImageStreamCompleter({
required Stream<Object> stream,
required double scale,
ImageInfo? initialImage,
InformationCollector? informationCollector,
void Function()? onLastListenerRemoved,
}) {
final codecCompleter = Completer<ui.Codec>();
final self = AnimatedImageStreamCompleter._(
codec: codecCompleter.future,
scale: scale,
informationCollector: informationCollector,
onLastListenerRemoved: onLastListenerRemoved,
);
if (initialImage != null) {
self.didProvideImage = true;
self.setImage(initialImage);
}
stream.listen(
(item) {
if (item is ImageInfo) {
self.didProvideImage = true;
self.setImage(item);
} else if (item is ui.Codec) {
if (!codecCompleter.isCompleted) {
self.didProvideImage = true;
codecCompleter.complete(item);
}
}
},
onError: (Object error, StackTrace stack) {
if (!codecCompleter.isCompleted) {
codecCompleter.completeError(error, stack);
}
},
onDone: () {
// also complete if we are done but no error occurred, and we didn't call complete yet
// could happen on cancellation
if (!codecCompleter.isCompleted) {
codecCompleter.completeError(StateError('Stream closed without providing a codec'));
}
},
);
return self;
}
@override
void addListener(ImageStreamListener listener) {
super.addListener(listener);
_listenerCount++;
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount--;
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;
if (onlyCacheListenerLeft || noListenersAfterCodec) {
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
}
}
}
@@ -140,7 +140,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
final ImageProvider provider; final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) { if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
} else { } else {
final String assetId; final String assetId;
final String thumbhash; final String thumbhash;
@@ -153,12 +153,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
} else { } else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
} }
provider = RemoteFullImageProvider( provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type);
assetId: assetId,
thumbhash: thumbhash,
assetType: asset.type,
isAnimated: asset.isAnimatedImage,
);
} }
return provider; return provider;
@@ -1,10 +1,11 @@
import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.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/presentation/widgets/timeline/constants.dart';
@@ -57,9 +58,8 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final String id; final String id;
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
final bool isAnimated;
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated}); LocalFullImageProvider({required this.id, required this.assetType, required this.size});
@override @override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -68,21 +68,6 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
@override @override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
if (key.isAnimated) {
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
);
}
return OneFramePlaceholderImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)), initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
@@ -90,7 +75,6 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size), DiagnosticsProperty<Size>('Size', key.size),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
], ],
onLastListenerRemoved: cancel, onLastListenerRemoved: cancel,
); );
@@ -126,45 +110,15 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
yield* loadRequest(request, decode); yield* loadRequest(request, decode);
} }
Stream<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final previewRequest = request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType,
);
yield* loadRequest(previewRequest, decode);
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
// always try original for animated, since previews don't support animation
final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType);
final codec = await loadCodecRequest(originalRequest);
if (codec == null) {
throw StateError('Failed to load animated codec for local asset ${key.id}');
}
yield codec;
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is LocalFullImageProvider) { if (other is LocalFullImageProvider) {
return id == other.id && size == other.size && isAnimated == other.isAnimated; return id == other.id && size == other.size;
} }
return false; return false;
} }
@override @override
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode; int get hashCode => id.hashCode ^ size.hashCode;
} }
@@ -4,7 +4,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
@@ -59,14 +58,8 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final String assetId; final String assetId;
final String thumbhash; final String thumbhash;
final AssetType assetType; final AssetType assetType;
final bool isAnimated;
RemoteFullImageProvider({ RemoteFullImageProvider({required this.assetId, required this.thumbhash, required this.assetType});
required this.assetId,
required this.thumbhash,
required this.assetType,
required this.isAnimated,
});
@override @override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -75,27 +68,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
@override @override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
if (key.isAnimated) {
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
],
onLastListenerRemoved: cancel,
);
}
return OneFramePlaceholderImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)), initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId), DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
], ],
onLastListenerRemoved: cancel, onLastListenerRemoved: cancel,
); );
@@ -128,43 +106,16 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
yield* loadRequest(originalRequest, decode); yield* loadRequest(originalRequest, decode);
} }
Stream<Object> _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
);
yield* loadRequest(previewRequest, decode, evictOnError: false);
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
// always try original for animated, since previews don't support animation
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
final codec = await loadCodecRequest(originalRequest);
if (codec == null) {
throw StateError('Failed to load animated codec for asset ${key.assetId}');
}
yield codec;
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) { if (other is RemoteFullImageProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash && isAnimated == other.isAnimated; return assetId == other.assetId && thumbhash == other.thumbhash;
} }
return false; return false;
} }
@override @override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode; int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
} }
@@ -305,8 +305,6 @@ class _AssetTypeIcons extends StatelessWidget {
padding: EdgeInsets.only(right: 10.0, top: 6.0), padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.motion_photos_on_rounded), child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
), ),
if (asset.isAnimatedImage)
const Padding(padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.gif_rounded)),
], ],
); );
} }
@@ -100,11 +100,11 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
return; return;
} }
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
if (showing) {
final heroTag = state.currentAsset?.heroTag; final heroTag = state.currentAsset?.heroTag;
if (heroTag != null) { if (heroTag != null) {
final notifier = ref.read(videoPlayerProvider(heroTag).notifier); ref.read(videoPlayerProvider(heroTag).notifier).pause();
showing ? notifier.hold() : notifier.release(); }
} }
} }
@@ -44,7 +44,10 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
NativeVideoPlayerController? _controller; NativeVideoPlayerController? _controller;
Timer? _bufferingTimer; Timer? _bufferingTimer;
Timer? _seekTimer; Timer? _seekTimer;
VideoPlaybackStatus? _holdStatus;
void attachController(NativeVideoPlayerController controller) {
_controller = controller;
}
@override @override
void dispose() { void dispose() {
@@ -56,19 +59,6 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
super.dispose(); super.dispose();
} }
void attachController(NativeVideoPlayerController controller) {
_controller = controller;
}
Future<void> load(VideoSource source) async {
_startBufferingTimer();
try {
await _controller?.loadVideoSource(source);
} catch (e) {
_log.severe('Error loading video source: $e');
}
}
Future<void> pause() async { Future<void> pause() async {
if (_controller == null) return; if (_controller == null) return;
@@ -104,50 +94,16 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
} }
void seekTo(Duration position) { void seekTo(Duration position) {
if (_controller == null || state.position == position) return; if (_controller == null) return;
state = state.copyWith(position: position); state = state.copyWith(position: position);
if (_seekTimer?.isActive ?? false) return; _seekTimer?.cancel();
_seekTimer = Timer(const Duration(milliseconds: 100), () {
_seekTimer = Timer(const Duration(milliseconds: 150), () { _controller?.seekTo(position.inMilliseconds);
_controller?.seekTo(state.position.inMilliseconds);
}); });
} }
void toggle() {
_holdStatus = null;
switch (state.status) {
case VideoPlaybackStatus.paused:
play();
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
pause();
case VideoPlaybackStatus.completed:
restart();
}
}
/// Pauses playback and preserves the current status for later restoration.
void hold() {
if (_holdStatus != null) return;
_holdStatus = state.status;
pause();
}
/// Restores playback to the status before [hold] was called.
void release() {
final status = _holdStatus;
_holdStatus = null;
switch (status) {
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
play();
default:
}
}
Future<void> restart() async { Future<void> restart() async {
seekTo(Duration.zero); seekTo(Duration.zero);
await play(); await play();
@@ -193,12 +149,13 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
final position = Duration(milliseconds: playbackInfo.position); final position = Duration(milliseconds: playbackInfo.position);
if (state.position == position) return; if (state.position == position) return;
if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer(); if (state.status == VideoPlaybackStatus.buffering) {
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
} else {
state = state.copyWith(position: position);
}
state = state.copyWith( _startBufferingTimer();
position: position,
status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null,
);
} }
void onNativeStatusChanged() { void onNativeStatusChanged() {
@@ -216,7 +173,9 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
onNativePlaybackEnded(); onNativePlaybackEnded();
} }
if (state.status != newStatus) state = state.copyWith(status: newStatus); if (state.status != newStatus) {
state = state.copyWith(status: newStatus);
}
} }
void onNativePlaybackEnded() { void onNativePlaybackEnded() {
@@ -227,7 +186,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
void _startBufferingTimer() { void _startBufferingTimer() {
_bufferingTimer?.cancel(); _bufferingTimer?.cancel();
_bufferingTimer = Timer(const Duration(seconds: 3), () { _bufferingTimer = Timer(const Duration(seconds: 3), () {
if (mounted && state.status != VideoPlaybackStatus.completed) { if (mounted && state.status == VideoPlaybackStatus.playing) {
state = state.copyWith(status: VideoPlaybackStatus.buffering); state = state.copyWith(status: VideoPlaybackStatus.buffering);
} }
}); });
+2 -1
View File
@@ -123,7 +123,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
} }
Future<bool> saveAuthInfo({required String accessToken}) async { Future<bool> saveAuthInfo({required String accessToken}) async {
await Store.put(StoreKey.accessToken, accessToken); await _apiService.setAccessToken(accessToken);
await _apiService.updateHeaders(); await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
@@ -145,6 +145,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
user = serverUser; user = serverUser;
await Store.put(StoreKey.deviceId, deviceId); await Store.put(StoreKey.deviceId, deviceId);
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
await Store.put(StoreKey.accessToken, accessToken);
} }
} on ApiException catch (error, stackTrace) { } on ApiException catch (error, stackTrace) {
if (error.code == 401) { if (error.code == 401) {
-10
View File
@@ -91,16 +91,6 @@ class CastNotifier extends StateNotifier<CastManagerState> {
return discovered; return discovered;
} }
void toggle() {
switch (state.castState) {
case CastState.playing:
pause();
case CastState.paused:
play();
default:
}
}
void play() { void play() {
_gCastService.play(); _gCastService.play();
} }
@@ -38,6 +38,10 @@ class AuthRepository extends DatabaseRepository {
}); });
} }
String getAccessToken() {
return Store.get(StoreKey.accessToken);
}
bool getEndpointSwitchingFeature() { bool getEndpointSwitchingFeature() {
return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false; return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
} }
@@ -136,10 +136,7 @@ class UploadRepository {
try { try {
final responseBody = jsonDecode(responseBodyString); final responseBody = jsonDecode(responseBodyString);
return UploadResult.success( return UploadResult.success(remoteAssetId: responseBody['id'] as String);
remoteAssetId: responseBody['id'] as String,
checksum: responseBody['checksum'] as String?,
);
} catch (e) { } catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response'); return UploadResult.error(errorMessage: 'Failed to parse server response');
} }
@@ -185,7 +182,6 @@ class UploadResult {
final bool isSuccess; final bool isSuccess;
final bool isCancelled; final bool isCancelled;
final String? remoteAssetId; final String? remoteAssetId;
final String? checksum;
final String? errorMessage; final String? errorMessage;
final int? statusCode; final int? statusCode;
@@ -193,13 +189,12 @@ class UploadResult {
required this.isSuccess, required this.isSuccess,
required this.isCancelled, required this.isCancelled,
this.remoteAssetId, this.remoteAssetId,
this.checksum,
this.errorMessage, this.errorMessage,
this.statusCode, this.statusCode,
}); });
factory UploadResult.success({required String remoteAssetId, String? checksum}) { factory UploadResult.success({required String remoteAssetId}) {
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId, checksum: checksum); return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
} }
factory UploadResult.error({String? errorMessage, int? statusCode}) { factory UploadResult.error({String? errorMessage, int? statusCode}) {
+34 -5
View File
@@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class ApiService { class ApiService implements Authentication {
late ApiClient _apiClient; late ApiClient _apiClient;
late UsersApi usersApi; late UsersApi usersApi;
@@ -45,6 +45,7 @@ class ApiService {
setEndpoint(endpoint); setEndpoint(endpoint);
} }
} }
String? _accessToken;
final _log = Logger("ApiService"); final _log = Logger("ApiService");
Future<void> updateHeaders() async { Future<void> updateHeaders() async {
@@ -53,8 +54,11 @@ class ApiService {
} }
setEndpoint(String endpoint) { setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint); _apiClient = ApiClient(basePath: endpoint, authentication: this);
_apiClient.client = NetworkRepository.client; _apiClient.client = NetworkRepository.client;
if (_accessToken != null) {
setAccessToken(_accessToken!);
}
usersApi = UsersApi(_apiClient); usersApi = UsersApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient);
oAuthApi = AuthenticationApi(_apiClient); oAuthApi = AuthenticationApi(_apiClient);
@@ -153,6 +157,11 @@ class ApiService {
return ""; return "";
} }
Future<void> setAccessToken(String accessToken) async {
_accessToken = accessToken;
await Store.put(StoreKey.accessToken, accessToken);
}
Future<void> setDeviceInfoHeader() async { Future<void> setDeviceInfoHeader() async {
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
@@ -176,6 +185,10 @@ class ApiService {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) { if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint); urls.add(serverEndpoint);
} }
final serverUrl = Store.tryGet(StoreKey.serverUrl);
if (serverUrl != null && serverUrl.isNotEmpty) {
urls.add(serverUrl);
}
final localEndpoint = Store.tryGet(StoreKey.localEndpoint); final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) { if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint); urls.add(localEndpoint);
@@ -192,12 +205,28 @@ class ApiService {
} }
static Map<String, String> getRequestHeaders() { static Map<String, String> getRequestHeaders() {
var accessToken = Store.get(StoreKey.accessToken, "");
var customHeadersStr = Store.get(StoreKey.customHeaders, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, "");
if (customHeadersStr.isEmpty) { var header = <String, String>{};
return const {}; if (accessToken.isNotEmpty) {
header['x-immich-user-token'] = accessToken;
} }
return (jsonDecode(customHeadersStr) as Map).cast<String, String>(); if (customHeadersStr.isEmpty) {
return header;
}
var customHeaders = jsonDecode(customHeadersStr) as Map;
customHeaders.forEach((key, value) {
header[key] = value;
});
return header;
}
@override
Future<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) {
return Future.value();
} }
ApiClient get apiClient => _apiClient; ApiClient get apiClient => _apiClient;
@@ -340,6 +340,7 @@ class BackgroundService {
], ],
); );
await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken));
await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");
@@ -162,7 +162,7 @@ class BackgroundUploadService {
await _storageRepository.clearCache(); await _storageRepository.clearCache();
shouldAbortQueuingTasks = false; shouldAbortQueuingTasks = false;
final candidates = await _backupRepository.getCandidates(userId, onlyHashed: false); final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) { if (candidates.isEmpty) {
_logger.info("No new backup candidates found, finishing background upload"); _logger.info("No new backup candidates found, finishing background upload");
return; return;
@@ -210,7 +210,6 @@ class BackgroundUploadService {
switch (update.status) { switch (update.status) {
case TaskStatus.complete: case TaskStatus.complete:
unawaited(_handleLivePhoto(update)); unawaited(_handleLivePhoto(update));
unawaited(_storeServerChecksum(update));
if (CurrentPlatform.isIOS) { if (CurrentPlatform.isIOS) {
try { try {
@@ -228,20 +227,6 @@ class BackgroundUploadService {
} }
} }
Future<void> _storeServerChecksum(TaskStatusUpdate update) async {
try {
if (update.responseBody == null || update.responseBody!.isEmpty) return;
final response = jsonDecode(update.responseBody!);
final checksum = response['checksum'] as String?;
if (checksum == null) return;
final deviceAssetId = update.task.taskId;
if (deviceAssetId.isEmpty) return;
await _localAssetRepository.updateHashes({deviceAssetId: checksum});
} catch (e) {
_logger.warning('Failed to store server checksum: $e');
}
}
Future<void> _handleLivePhoto(TaskStatusUpdate update) async { Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
try { try {
if (update.task.metaData.isEmpty || update.task.metaData == '') { if (update.task.metaData.isEmpty || update.task.metaData == '') {
@@ -74,6 +74,7 @@ class BackupVerificationService {
final lower = compute(_computeSaveToDelete, ( final lower = compute(_computeSaveToDelete, (
deleteCandidates: deleteCandidates.slice(0, half), deleteCandidates: deleteCandidates.slice(0, half),
originals: originals.slice(0, half), originals: originals.slice(0, half),
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint), endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken, rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository, fileMediaRepository: _fileMediaRepository,
@@ -81,6 +82,7 @@ class BackupVerificationService {
final upper = compute(_computeSaveToDelete, ( final upper = compute(_computeSaveToDelete, (
deleteCandidates: deleteCandidates.slice(half), deleteCandidates: deleteCandidates.slice(half),
originals: originals.slice(half), originals: originals.slice(half),
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint), endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken, rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository, fileMediaRepository: _fileMediaRepository,
@@ -90,6 +92,7 @@ class BackupVerificationService {
toDelete = await compute(_computeSaveToDelete, ( toDelete = await compute(_computeSaveToDelete, (
deleteCandidates: deleteCandidates, deleteCandidates: deleteCandidates,
originals: originals, originals: originals,
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint), endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken, rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository, fileMediaRepository: _fileMediaRepository,
@@ -102,6 +105,7 @@ class BackupVerificationService {
({ ({
List<Asset> deleteCandidates, List<Asset> deleteCandidates,
List<Asset> originals, List<Asset> originals,
String auth,
String endpoint, String endpoint,
RootIsolateToken rootIsolateToken, RootIsolateToken rootIsolateToken,
FileMediaRepository fileMediaRepository, FileMediaRepository fileMediaRepository,
@@ -116,6 +120,7 @@ class BackupVerificationService {
await tuple.fileMediaRepository.enableBackgroundAccess(); await tuple.fileMediaRepository.enableBackgroundAccess();
final ApiService apiService = ApiService(); final ApiService apiService = ApiService();
apiService.setEndpoint(tuple.endpoint); apiService.setEndpoint(tuple.endpoint);
await apiService.setAccessToken(tuple.auth);
for (int i = 0; i < tuple.deleteCandidates.length; i++) { for (int i = 0; i < tuple.deleteCandidates.length; i++) {
if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) { if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) {
result.add(tuple.deleteCandidates[i]); result.add(tuple.deleteCandidates[i]);
@@ -11,11 +11,9 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
@@ -39,7 +37,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
return ForegroundUploadService( return ForegroundUploadService(
ref.watch(uploadRepositoryProvider), ref.watch(uploadRepositoryProvider),
ref.watch(storageRepositoryProvider), ref.watch(storageRepositoryProvider),
ref.watch(localAssetRepository),
ref.watch(backupRepositoryProvider), ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider), ref.watch(connectivityApiProvider),
ref.watch(appSettingsServiceProvider), ref.watch(appSettingsServiceProvider),
@@ -56,7 +53,6 @@ class ForegroundUploadService {
ForegroundUploadService( ForegroundUploadService(
this._uploadRepository, this._uploadRepository,
this._storageRepository, this._storageRepository,
this._localAssetRepository,
this._backupRepository, this._backupRepository,
this._connectivityApi, this._connectivityApi,
this._appSettingsService, this._appSettingsService,
@@ -65,7 +61,6 @@ class ForegroundUploadService {
final UploadRepository _uploadRepository; final UploadRepository _uploadRepository;
final StorageRepository _storageRepository; final StorageRepository _storageRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftBackupRepository _backupRepository; final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi; final ConnectivityApi _connectivityApi;
final AppSettingsService _appSettingsService; final AppSettingsService _appSettingsService;
@@ -89,7 +84,7 @@ class ForegroundUploadService {
UploadCallbacks callbacks = const UploadCallbacks(), UploadCallbacks callbacks = const UploadCallbacks(),
bool useSequentialUpload = false, bool useSequentialUpload = false,
}) async { }) async {
final candidates = await _backupRepository.getCandidates(userId, onlyHashed: false); final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) { if (candidates.isEmpty) {
return; return;
} }
@@ -392,10 +387,6 @@ class ForegroundUploadService {
); );
if (result.isSuccess && result.remoteAssetId != null) { if (result.isSuccess && result.remoteAssetId != null) {
// Store server-computed checksum so iCloud-only assets don't need re-upload
if (result.checksum != null) {
await _localAssetRepository.updateHashes({asset.id: result.checksum!});
}
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!); callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) { } else if (result.isCancelled) {
_logger.warning(() => "Backup was cancelled by the user"); _logger.warning(() => "Backup was cancelled by the user");
+2
View File
@@ -62,6 +62,8 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale
), ),
chipTheme: const ChipThemeData(side: BorderSide.none), chipTheme: const ChipThemeData(side: BorderSide.none),
sliderTheme: const SliderThemeData( sliderTheme: const SliderThemeData(
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
trackHeight: 2.0,
// ignore: deprecated_member_use // ignore: deprecated_member_use
year2023: false, year2023: false,
), ),
-10
View File
@@ -18,7 +18,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane
import 'package:immich_mobile/presentation/widgets/action_buttons/download_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/like_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_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/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
@@ -76,7 +75,6 @@ enum ActionButtonType {
viewInTimeline, viewInTimeline,
download, download,
upload, upload,
openInBrowser,
unstack, unstack,
archive, archive,
unarchive, unarchive,
@@ -151,7 +149,6 @@ enum ActionButtonType {
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
context.isStacked, context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
ActionButtonType.likeActivity => ActionButtonType.likeActivity =>
!context.isInLockedView && !context.isInLockedView &&
context.currentAlbum != null && context.currentAlbum != null &&
@@ -239,13 +236,6 @@ enum ActionButtonType {
), ),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
remoteId: context.asset.remoteId!,
origin: context.timelineOrigin,
iconOnly: iconOnly,
menuItem: menuItem,
iconColor: context.originalTheme?.iconTheme.color,
),
ActionButtonType.similarPhotos => SimilarPhotosActionButton( ActionButtonType.similarPhotos => SimilarPhotosActionButton(
assetId: (context.asset as RemoteAsset).id, assetId: (context.asset as RemoteAsset).id,
iconOnly: iconOnly, iconOnly: iconOnly,
+15 -46
View File
@@ -25,10 +25,8 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
@@ -37,7 +35,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager // ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 25; const int targetVersion = 23;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async { Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null; final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -107,20 +105,6 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
await _populateLocalAssetPlaybackStyle(drift); await _populateLocalAssetPlaybackStyle(drift);
} }
if (version < 24 && Store.isBetaTimelineEnabled) {
await _applyLocalAssetOrientation(drift);
}
if (version < 25) {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && accessToken.isNotEmpty) {
final serverUrls = ApiService.getServerUrls();
if (serverUrls.isNotEmpty) {
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
}
}
}
if (version < 22 && !Store.isBetaTimelineEnabled) { if (version < 22 && !Store.isBetaTimelineEnabled) {
await Store.put(StoreKey.needBetaMigration, true); await Store.put(StoreKey.needBetaMigration, true);
} }
@@ -432,41 +416,26 @@ Future<void> _populateLocalAssetPlaybackStyle(Drift db) async {
}); });
} }
if (Platform.isAndroid) { final trashedAssetMap = await nativeApi.getTrashedAssets();
final trashedAssetMap = await nativeApi.getTrashedAssets(); for (final entry in trashedAssetMap.cast<String, List<Object?>>().entries) {
for (final entry in trashedAssetMap.cast<String, List<Object?>>().entries) { final assets = entry.value.cast<PlatformAsset>();
final assets = entry.value.cast<PlatformAsset>(); await db.batch((batch) {
await db.batch((batch) { for (final asset in assets) {
for (final asset in assets) { batch.update(
batch.update( db.trashedLocalAssetEntity,
db.trashedLocalAssetEntity, TrashedLocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
TrashedLocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))), where: (t) => t.id.equals(asset.id),
where: (t) => t.id.equals(asset.id), );
); }
} });
});
}
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets");
} else {
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local assets");
} }
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets");
} catch (error) { } catch (error) {
dPrint(() => "[MIGRATION] Error while populating playbackStyle: $error"); dPrint(() => "[MIGRATION] Error while populating playbackStyle: $error");
} }
} }
Future<void> _applyLocalAssetOrientation(Drift db) {
final query = db.localAssetEntity.update()
..where((filter) => (filter.orientation.equals(90) | (filter.orientation.equals(270))));
return query.write(
LocalAssetEntityCompanion.custom(
width: db.localAssetEntity.height,
height: db.localAssetEntity.width,
orientation: const Variable(0),
),
);
}
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) { AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown, PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image, PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
@@ -1,15 +1,12 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// A widget that animates implicitly between a play and a pause icon. /// A widget that animates implicitly between a play and a pause icon.
class AnimatedPlayPause extends StatefulWidget { class AnimatedPlayPause extends StatefulWidget {
const AnimatedPlayPause({super.key, required this.playing, this.size, this.color, this.shadows}); const AnimatedPlayPause({super.key, required this.playing, this.size, this.color});
final double? size; final double? size;
final bool playing; final bool playing;
final Color? color; final Color? color;
final List<Shadow>? shadows;
@override @override
State<StatefulWidget> createState() => AnimatedPlayPauseState(); State<StatefulWidget> createState() => AnimatedPlayPauseState();
@@ -42,32 +39,12 @@ class AnimatedPlayPauseState extends State<AnimatedPlayPause> with SingleTickerP
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final icon = AnimatedIcon(
color: widget.color,
size: widget.size,
icon: AnimatedIcons.play_pause,
progress: animationController,
);
return Center( return Center(
child: Stack( child: AnimatedIcon(
alignment: Alignment.center, color: widget.color,
children: [ size: widget.size,
for (final shadow in widget.shadows ?? const <Shadow>[]) icon: AnimatedIcons.play_pause,
Transform.translate( progress: animationController,
offset: shadow.offset,
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: shadow.blurRadius / 2, sigmaY: shadow.blurRadius / 2),
child: AnimatedIcon(
color: shadow.color,
size: widget.size,
icon: AnimatedIcons.play_pause,
progress: animationController,
),
),
),
icon,
],
), ),
); );
} }
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
class FormattedDuration extends StatelessWidget {
final Duration data;
const FormattedDuration(this.data, {super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter
child: Text(
data.format(),
style: const TextStyle(fontSize: 14.0, color: Colors.white, fontWeight: FontWeight.w500),
textAlign: TextAlign.center,
),
);
}
}
@@ -1,113 +1,22 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
class VideoControls extends HookConsumerWidget { /// The video controls for the [videoPlayerProvider]
class VideoControls extends ConsumerWidget {
final String videoPlayerName; final String videoPlayerName;
static const List<Shadow> _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))];
const VideoControls({super.key, required this.videoPlayerName}); const VideoControls({super.key, required this.videoPlayerName});
void _toggle(WidgetRef ref, bool isCasting) {
if (isCasting) {
ref.read(castProvider.notifier).toggle();
} else {
ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle();
}
}
void _onSeek(WidgetRef ref, bool isCasting, double value) {
final seekTo = Duration(microseconds: value.toInt());
if (isCasting) {
ref.read(castProvider.notifier).seekTo(seekTo);
return;
}
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo);
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final provider = videoPlayerProvider(videoPlayerName); final isPortrait = context.orientation == Orientation.portrait;
final cast = ref.watch(castProvider); return isPortrait
final isCasting = cast.isCasting; ? VideoPosition(videoPlayerName: videoPlayerName)
: Padding(
final (position, duration) = isCasting padding: const EdgeInsets.symmetric(horizontal: 60.0),
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration))) child: VideoPosition(videoPlayerName: videoPlayerName),
: ref.watch(provider.select((v) => (v.position, v.duration))); );
final videoStatus = ref.watch(provider.select((v) => v.status));
final isPlaying = isCasting
? cast.castState == CastState.playing
: videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering;
final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed;
final hideTimer = useTimer(const Duration(seconds: 5), () {
if (!context.mounted) return;
if (ref.read(provider).status == VideoPlaybackStatus.playing) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset());
final notifier = ref.read(provider.notifier);
final isLoaded = duration != Duration.zero;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
spacing: 16,
children: [
Row(
children: [
IconButton(
iconSize: 32,
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(),
icon: isFinished
? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows)
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows),
onPressed: () => _toggle(ref, isCasting),
),
const Spacer(),
Text(
"${position.format()} / ${duration.format()}",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
shadows: _controlShadows,
),
),
const SizedBox(width: 16),
],
),
Slider(
value: min(position.inMicroseconds.toDouble(), duration.inMicroseconds.toDouble()),
min: 0,
max: max(duration.inMicroseconds.toDouble(), 1),
thumbColor: Colors.white,
activeColor: Colors.white,
inactiveColor: whiteOpacity75,
padding: EdgeInsets.zero,
onChangeStart: (_) => notifier.hold(),
onChangeEnd: (_) => notifier.release(),
onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null,
),
],
),
);
} }
} }
@@ -0,0 +1,110 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
class VideoPosition extends HookConsumerWidget {
final String videoPlayerName;
const VideoPosition({super.key, required this.videoPlayerName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCasting = ref.watch(castProvider).isCasting;
final (position, duration) = isCasting
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
final wasPlaying = useRef<bool>(true);
return duration == Duration.zero
? const _VideoPositionPlaceholder()
: Column(
children: [
Padding(
// align with slider's inherent padding
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [FormattedDuration(position), FormattedDuration(duration)],
),
),
Row(
children: [
Expanded(
child: Slider(
value: min(position.inMicroseconds / duration.inMicroseconds * 100, 100),
min: 0,
max: 100,
thumbColor: Colors.white,
activeColor: Colors.white,
inactiveColor: whiteOpacity75,
onChangeStart: (value) {
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
wasPlaying.value = status != VideoPlaybackStatus.paused;
ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
},
onChangeEnd: (value) {
if (wasPlaying.value) {
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
}
},
onChanged: (value) {
final seekToDuration = (duration * (value / 100.0));
if (isCasting) {
ref.read(castProvider.notifier).seekTo(seekToDuration);
return;
}
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
},
),
),
],
),
],
);
}
}
class _VideoPositionPlaceholder extends StatelessWidget {
const _VideoPositionPlaceholder();
static void _onChangedDummy(_) {}
@override
Widget build(BuildContext context) {
return const Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [FormattedDuration(Duration.zero), FormattedDuration(Duration.zero)],
),
),
Row(
children: [
Expanded(
child: Slider(
value: 0.0,
min: 0,
max: 100,
thumbColor: Colors.white,
activeColor: Colors.white,
inactiveColor: whiteOpacity75,
onChanged: _onChangedDummy,
),
),
],
),
],
);
}
}
+1 -8
View File
@@ -35,12 +35,7 @@ class ImmichImage extends StatelessWidget {
} }
if (asset == null) { if (asset == null) {
return RemoteFullImageProvider( return RemoteFullImageProvider(assetId: assetId!, thumbhash: '', assetType: base_asset.AssetType.video);
assetId: assetId!,
thumbhash: '',
assetType: base_asset.AssetType.video,
isAnimated: false,
);
} }
if (useLocal(asset)) { if (useLocal(asset)) {
@@ -48,14 +43,12 @@ class ImmichImage extends StatelessWidget {
id: asset.localId!, id: asset.localId!,
assetType: base_asset.AssetType.video, assetType: base_asset.AssetType.video,
size: Size(width, height), size: Size(width, height),
isAnimated: false,
); );
} else { } else {
return RemoteFullImageProvider( return RemoteFullImageProvider(
assetId: asset.remoteId!, assetId: asset.remoteId!,
thumbhash: asset.thumbhash ?? '', thumbhash: asset.thumbhash ?? '',
assetType: base_asset.AssetType.video, assetType: base_asset.AssetType.video,
isAnimated: false,
); );
} }
} }
+18 -3
View File
@@ -427,7 +427,11 @@ class SharedLinksApi {
/// * [String] id (required): /// * [String] id (required):
/// ///
/// * [AssetIdsDto] assetIdsDto (required): /// * [AssetIdsDto] assetIdsDto (required):
Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { ///
/// * [String] key:
///
/// * [String] slug:
Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/shared-links/{id}/assets' final apiPath = r'/shared-links/{id}/assets'
.replaceAll('{id}', id); .replaceAll('{id}', id);
@@ -439,6 +443,13 @@ class SharedLinksApi {
final headerParams = <String, String>{}; final headerParams = <String, String>{};
final formParams = <String, String>{}; final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['application/json']; const contentTypes = <String>['application/json'];
@@ -462,8 +473,12 @@ class SharedLinksApi {
/// * [String] id (required): /// * [String] id (required):
/// ///
/// * [AssetIdsDto] assetIdsDto (required): /// * [AssetIdsDto] assetIdsDto (required):
Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async { ///
final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,); /// * [String] key:
///
/// * [String] slug:
Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async {
final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto, key: key, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
-3
View File
@@ -26,7 +26,6 @@ class AudioCodec {
static const mp3 = AudioCodec._(r'mp3'); static const mp3 = AudioCodec._(r'mp3');
static const aac = AudioCodec._(r'aac'); static const aac = AudioCodec._(r'aac');
static const libopus = AudioCodec._(r'libopus'); static const libopus = AudioCodec._(r'libopus');
static const opus = AudioCodec._(r'opus');
static const pcmS16le = AudioCodec._(r'pcm_s16le'); static const pcmS16le = AudioCodec._(r'pcm_s16le');
/// List of all possible values in this [enum][AudioCodec]. /// List of all possible values in this [enum][AudioCodec].
@@ -34,7 +33,6 @@ class AudioCodec {
mp3, mp3,
aac, aac,
libopus, libopus,
opus,
pcmS16le, pcmS16le,
]; ];
@@ -77,7 +75,6 @@ class AudioCodecTypeTransformer {
case r'mp3': return AudioCodec.mp3; case r'mp3': return AudioCodec.mp3;
case r'aac': return AudioCodec.aac; case r'aac': return AudioCodec.aac;
case r'libopus': return AudioCodec.libopus; case r'libopus': return AudioCodec.libopus;
case r'opus': return AudioCodec.opus;
case r'pcm_s16le': return AudioCodec.pcmS16le; case r'pcm_s16le': return AudioCodec.pcmS16le;
default: default:
if (!allowNull) { if (!allowNull) {
+1 -1
View File
@@ -43,5 +43,5 @@ abstract class NetworkApi {
int getClientPointer(); int getClientPointer();
void setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token); void setRequestHeaders(Map<String, String> headers, List<String> serverUrls);
} }
+6 -6
View File
@@ -1194,10 +1194,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.16.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -1218,8 +1218,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
url: "https://github.com/immich-app/native_video_player" url: "https://github.com/immich-app/native_video_player"
source: git source: git
version: "1.3.1" version: "1.3.1"
@@ -1897,10 +1897,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.6"
thumbhash: thumbhash:
dependency: "direct main" dependency: "direct main"
description: description:
+1 -1
View File
@@ -56,7 +56,7 @@ dependencies:
native_video_player: native_video_player:
git: git:
url: https://github.com/immich-app/native_video_player url: https://github.com/immich-app/native_video_player
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6' ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
network_info_plus: ^6.1.3 network_info_plus: ^6.1.3
octo_image: ^2.1.0 octo_image: ^2.1.0
openapi: openapi:
+16 -2
View File
@@ -11605,6 +11605,22 @@
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
} }
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
} }
], ],
"requestBody": { "requestBody": {
@@ -11661,7 +11677,6 @@
"state": "Stable" "state": "Stable"
} }
], ],
"x-immich-permission": "sharedLink.update",
"x-immich-state": "Stable" "x-immich-state": "Stable"
}, },
"put": { "put": {
@@ -17245,7 +17260,6 @@
"mp3", "mp3",
"aac", "aac",
"libopus", "libopus",
"opus",
"pcm_s16le" "pcm_s16le"
], ],
"type": "string" "type": "string"
+1 -1
View File
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.11.0", "@types/node": "^24.10.14",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"repository": { "repository": {
+7 -3
View File
@@ -5987,14 +5987,19 @@ export function updateSharedLink({ id, sharedLinkEditDto }: {
/** /**
* Remove assets from a shared link * Remove assets from a shared link
*/ */
export function removeSharedLinkAssets({ id, assetIdsDto }: { export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: {
id: string; id: string;
key?: string;
slug?: string;
assetIdsDto: AssetIdsDto; assetIdsDto: AssetIdsDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetIdsResponseDto[]; data: AssetIdsResponseDto[];
}>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({ }>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.json({
...opts, ...opts,
method: "DELETE", method: "DELETE",
body: assetIdsDto body: assetIdsDto
@@ -7319,7 +7324,6 @@ export enum AudioCodec {
Mp3 = "mp3", Mp3 = "mp3",
Aac = "aac", Aac = "aac",
Libopus = "libopus", Libopus = "libopus",
Opus = "opus",
PcmS16Le = "pcm_s16le" PcmS16Le = "pcm_s16le"
} }
export enum VideoContainer { export enum VideoContainer {
+935 -922
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -46,14 +46,14 @@
"@nestjs/websockets": "^11.0.4", "@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.213.0", "@opentelemetry/exporter-prometheus": "^0.212.0",
"@opentelemetry/instrumentation-http": "^0.213.0", "@opentelemetry/instrumentation-http": "^0.212.0",
"@opentelemetry/instrumentation-ioredis": "^0.61.0", "@opentelemetry/instrumentation-ioredis": "^0.60.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.59.0", "@opentelemetry/instrumentation-nestjs-core": "^0.58.0",
"@opentelemetry/instrumentation-pg": "^0.65.0", "@opentelemetry/instrumentation-pg": "^0.64.0",
"@opentelemetry/resources": "^2.0.1", "@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.213.0", "@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0", "@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2", "@react-email/render": "^1.1.2",
@@ -66,7 +66,7 @@
"bullmq": "^5.51.0", "bullmq": "^5.51.0",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.0", "class-validator": "^0.14.0",
"compression": "^1.8.0", "compression": "^1.8.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@@ -82,7 +82,7 @@
"jose": "^5.10.0", "jose": "^5.10.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "0.28.11", "kysely": "0.28.2",
"kysely-postgres-js": "^3.0.0", "kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.4.2", "luxon": "^3.4.2",
@@ -136,7 +136,7 @@
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^24.11.0", "@types/node": "^24.10.14",
"@types/nodemailer": "^7.0.0", "@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0", "@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
+1 -1
View File
@@ -206,7 +206,7 @@ export const defaults = Object.freeze<SystemConfig>({
targetVideoCodec: VideoCodec.H264, targetVideoCodec: VideoCodec.H264,
acceptedVideoCodecs: [VideoCodec.H264], acceptedVideoCodecs: [VideoCodec.H264],
targetAudioCodec: AudioCodec.Aac, targetAudioCodec: AudioCodec.Aac,
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus], acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm], acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
targetResolution: '720', targetResolution: '720',
maxBitrate: '0', maxBitrate: '0',
+1 -9
View File
@@ -2,7 +2,7 @@ import { Duration } from 'luxon';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { SemVer } from 'semver'; import { SemVer } from 'semver';
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum'; import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
export const ErrorMessages = { export const ErrorMessages = {
InconsistentMediaLocation: InconsistentMediaLocation:
@@ -201,11 +201,3 @@ export const endpointTags: Record<ApiTag, string> = {
[ApiTag.Workflows]: [ApiTag.Workflows]:
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.', 'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
}; };
export const AUDIO_ENCODER: Record<AudioCodec, string> = {
[AudioCodec.Aac]: 'aac',
[AudioCodec.Mp3]: 'mp3',
[AudioCodec.Libopus]: 'libopus',
[AudioCodec.Opus]: 'libopus',
[AudioCodec.PcmS16le]: 'pcm_s16le',
};

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