mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fe63b70a6 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}}'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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`)
|
||||||
|
|||||||
@@ -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!);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"]:
|
||||||
|
|||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
+2
-2
@@ -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) {
|
||||||
|
|||||||
+3
-3
@@ -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()},
|
||||||
|
|||||||
+3
-3
@@ -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()},
|
||||||
|
|||||||
-61
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-3
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Generated
+935
-922
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user