mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b374d7eb87 | |||
| d20def9f66 | |||
| 13e8a0121f | |||
| fe4c0a95d5 | |||
| c5abd18a64 | |||
| cf1a9ed3f5 | |||
| feacf9b134 | |||
| 67eb33b3a7 | |||
| 23e3d43578 | |||
| 028c8a2276 | |||
| b7f4cc8171 | |||
| 5c11d15008 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different ."
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.7.4"
|
||||
|
||||
@@ -24,7 +24,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
|
||||
# sha is pinning to a commit instead of a tag since the action does not tag versions
|
||||
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# 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).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
|
||||
# ℹ️ 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
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
name: Visual Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
visual-diff:
|
||||
name: Visual Diff Screenshots
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'visual-review') ||
|
||||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'visual-review'))
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine changed web files
|
||||
id: changed-files
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const files = [];
|
||||
const perPage = 100;
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
const webPrefixes = ['web/', 'i18n/', 'open-api/typescript-sdk/'];
|
||||
const webFiles = files
|
||||
.map(f => f.filename)
|
||||
.filter(f => webPrefixes.some(p => f.startsWith(p)));
|
||||
|
||||
console.log(`Total PR files: ${files.length}`);
|
||||
console.log(`Web-related files: ${webFiles.length}`);
|
||||
for (const f of webFiles) {
|
||||
console.log(` ${f}`);
|
||||
}
|
||||
|
||||
core.setOutput('files', webFiles.join('\n'));
|
||||
core.setOutput('has_changes', webFiles.length > 0 ? 'true' : 'false');
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Install e2e dependencies
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
run: pnpm exec playwright install chromium --only-shell
|
||||
|
||||
- name: Analyze affected routes
|
||||
if: steps.changed-files.outputs.has_changes == 'true'
|
||||
id: routes
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
|
||||
run: |
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
echo "---"
|
||||
|
||||
ROUTES=$(echo "$CHANGED_FILES" | xargs pnpm exec tsx src/screenshots/analyze-deps.ts 2>&1 | tee /dev/stderr | grep "^ /" | sed 's/^ //' || true)
|
||||
|
||||
echo "routes<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$ROUTES" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -z "$ROUTES" ]; then
|
||||
echo "has_routes=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_routes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build the scenario filter JSON array
|
||||
SCENARIO_NAMES=$(pnpm exec tsx -e "
|
||||
import { getScenariosForRoutes } from './src/screenshots/page-map.ts';
|
||||
const routes = process.argv.slice(1);
|
||||
const scenarios = getScenariosForRoutes(routes);
|
||||
console.log(JSON.stringify(scenarios.map(s => s.name)));
|
||||
" $ROUTES)
|
||||
echo "scenarios=$SCENARIO_NAMES" >> "$GITHUB_OUTPUT"
|
||||
echo "Scenarios: $SCENARIO_NAMES"
|
||||
fi
|
||||
|
||||
- name: Post initial comment
|
||||
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
AFFECTED_ROUTES: ${{ steps.routes.outputs.routes }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const routes = process.env.AFFECTED_ROUTES || '';
|
||||
const body = `## Visual Review\n\nGenerating screenshots for affected pages...\n\nAffected routes:\n\`\`\`\n${routes}\n\`\`\``;
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
# === Screenshot PR version ===
|
||||
- name: Build SDK (PR)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Build web (PR)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./web
|
||||
|
||||
- name: Take screenshots (PR)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
|
||||
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/pr
|
||||
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
|
||||
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
|
||||
run: |
|
||||
# Start the preview server in background
|
||||
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
|
||||
echo "Server ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run screenshot tests
|
||||
pnpm exec playwright test --config playwright.screenshot.config.ts || true
|
||||
|
||||
# Stop the preview server and all children (pnpm spawns vite as child)
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
sleep 1
|
||||
# Ensure port is fully released — kill any lingering vite process
|
||||
fuser -k 4173/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# === Screenshot base version ===
|
||||
# Disable pnpm's verifyDepsBeforeRun for all base steps since the base
|
||||
# checkout changes package.json files, making them mismatch the lockfile.
|
||||
- name: Checkout base web directory
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
# Restore web directory from base branch
|
||||
git checkout "$BASE_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
|
||||
# Clear SvelteKit build cache to avoid stale artifacts from the PR build
|
||||
rm -rf web/.svelte-kit web/build
|
||||
working-directory: .
|
||||
|
||||
- name: Build SDK (base)
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
continue-on-error: true
|
||||
id: base-sdk
|
||||
env:
|
||||
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||
run: pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
|
||||
- name: Build web (base)
|
||||
if: steps.routes.outputs.has_routes == 'true' && steps.base-sdk.outcome == 'success'
|
||||
continue-on-error: true
|
||||
id: base-web
|
||||
env:
|
||||
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||
run: pnpm build
|
||||
working-directory: ./web
|
||||
|
||||
- name: Take screenshots (base)
|
||||
if: steps.routes.outputs.has_routes == 'true' && steps.base-web.outcome == 'success'
|
||||
env:
|
||||
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
|
||||
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/base
|
||||
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
|
||||
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
|
||||
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||
run: |
|
||||
# Kill any process still on port 4173 from the PR step
|
||||
fuser -k 4173/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Start the preview server in background
|
||||
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
|
||||
echo "Server ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run screenshot tests
|
||||
pnpm exec playwright test --config playwright.screenshot.config.ts || true
|
||||
|
||||
# Stop the preview server
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
fuser -k 4173/tcp 2>/dev/null || true
|
||||
|
||||
- name: Restore PR source
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
git checkout "$HEAD_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
|
||||
working-directory: .
|
||||
|
||||
# === Compare and report ===
|
||||
- name: Compare screenshots
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
env:
|
||||
WORKSPACE_DIR: ${{ github.workspace }}
|
||||
run: |
|
||||
# Ensure directories exist even if base screenshots were skipped
|
||||
mkdir -p "$WORKSPACE_DIR/screenshots/base" "$WORKSPACE_DIR/screenshots/pr" "$WORKSPACE_DIR/screenshots/diff"
|
||||
pnpm exec tsx src/screenshots/compare.ts \
|
||||
"$WORKSPACE_DIR/screenshots/base" \
|
||||
"$WORKSPACE_DIR/screenshots/pr" \
|
||||
"$WORKSPACE_DIR/screenshots/diff"
|
||||
|
||||
- name: Upload screenshot artifacts
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: visual-review-screenshots
|
||||
path: screenshots/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload HTML report
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
id: html-report
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
path: screenshots/diff/visual-review.html
|
||||
archive: false
|
||||
retention-days: 14
|
||||
|
||||
- name: Post comparison results
|
||||
if: steps.routes.outputs.has_routes == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
REPORT_URL: ${{ steps.html-report.outputs.artifact-url }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const reportPath = path.join(process.env.GITHUB_WORKSPACE, 'screenshots', 'diff', 'report.md');
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = fs.readFileSync(reportPath, 'utf8');
|
||||
} catch {
|
||||
body = '## Visual Review\n\nScreenshot comparison failed. Check the workflow artifacts for details.';
|
||||
}
|
||||
|
||||
// Append links to the HTML report artifact and workflow run
|
||||
const reportUrl = process.env.REPORT_URL;
|
||||
const runUrl = process.env.RUN_URL;
|
||||
body += '\n---\n';
|
||||
if (reportUrl) {
|
||||
body += `[View full visual comparison](${reportUrl}) | `;
|
||||
}
|
||||
body += `[Download all screenshots](${runUrl}#artifacts)\n`;
|
||||
|
||||
// Find and update existing comment or create new one
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.data.find(c =>
|
||||
c.body && c.body.includes('## Visual Review')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: No web changes
|
||||
if: steps.changed-files.outputs.has_changes != 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const body = '## Visual Review\n\nNo web-related file changes detected in this PR. Visual review not needed.';
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
comment_id: existing.id, body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: context.issue.number, body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: No affected routes
|
||||
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes != 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
const body = '## Visual Review\n\nChanged files don\'t affect any pages with screenshot scenarios configured.\nTo add coverage, define new scenarios in `e2e/src/screenshots/page-map.ts`.';
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
comment_id: existing.id, body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: context.issue.number, body,
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,7 @@ open-api/typescript-sdk/build
|
||||
mobile/android/fastlane/report.xml
|
||||
mobile/ios/fastlane/report.xml
|
||||
|
||||
screenshots-output
|
||||
vite.config.js.timestamp-*
|
||||
.pnpm-store
|
||||
.devcontainer/library
|
||||
|
||||
Vendored
+1
-8
@@ -5,13 +5,6 @@
|
||||
"dbaeumer.vscode-eslint",
|
||||
"dart-code.flutter",
|
||||
"dart-code.dart-code",
|
||||
"dcmdev.dcm-vscode-extension",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"ms-playwright.playwright",
|
||||
"vitest.explorer",
|
||||
"editorconfig.editorconfig",
|
||||
"foxundermoon.shell-format",
|
||||
"timonwong.shellcheck",
|
||||
"bluebrown.yamlfmt"
|
||||
"dcmdev.dcm-vscode-extension"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+1
-33
@@ -15,7 +15,6 @@
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.removeUnusedImports": "explicit"
|
||||
},
|
||||
@@ -35,7 +34,6 @@
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.removeUnusedImports": "explicit"
|
||||
},
|
||||
@@ -45,7 +43,6 @@
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.removeUnusedImports": "explicit"
|
||||
},
|
||||
@@ -54,43 +51,14 @@
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"cSpell.words": ["immich"],
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.guides.bracketPairs": "active",
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||
"eslint.workingDirectories": [
|
||||
{ "directory": "cli", "changeProcessCWD": true },
|
||||
{ "directory": "e2e", "changeProcessCWD": true },
|
||||
{ "directory": "server", "changeProcessCWD": true },
|
||||
{ "directory": "web", "changeProcessCWD": true }
|
||||
],
|
||||
"files.watcherExclude": {
|
||||
"**/.jj/**": true,
|
||||
"**/.git/**": true,
|
||||
"**/node_modules/**": true,
|
||||
"**/build/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/.svelte-kit/**": true
|
||||
},
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/build": true,
|
||||
"**/dist": true,
|
||||
"**/.svelte-kit": true,
|
||||
"**/open-api/typescript-sdk/src": true
|
||||
},
|
||||
"svelte.enable-ts-plugin": true,
|
||||
"tailwindCSS.experimental.configFile": {
|
||||
"web/src/app.css": "web/src/**"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"vitest.maximumConfigs": 10
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ Please try to keep pull requests as focused as possible. A PR should do exactly
|
||||
|
||||
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
|
||||
|
||||
We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR.
|
||||
|
||||
## Use of generative AI
|
||||
|
||||
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
|
||||
|
||||
+5
-5
@@ -20,8 +20,8 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.14",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"@types/node": "^24.10.13",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^12.0.0",
|
||||
@@ -37,7 +37,7 @@
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-tsconfig-paths": "^6.0.0",
|
||||
"vitest": "^4.0.0",
|
||||
"vitest": "^3.0.0",
|
||||
"vitest-fetch-mock": "^0.4.0",
|
||||
"yaml": "^2.3.1"
|
||||
},
|
||||
@@ -49,8 +49,8 @@
|
||||
"prepack": "pnpm run build",
|
||||
"test": "vitest",
|
||||
"test:cov": "vitest --coverage",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('uploadFiles', () => {
|
||||
});
|
||||
|
||||
it('returns new assets when upload file is successful', async () => {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
|
||||
@@ -75,7 +75,7 @@ describe('uploadFiles', () => {
|
||||
|
||||
it('returns new assets when upload file retry is successful', async () => {
|
||||
let counter = 0;
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||
counter++;
|
||||
if (counter < retry) {
|
||||
throw new Error('Network error');
|
||||
@@ -96,7 +96,7 @@ describe('uploadFiles', () => {
|
||||
});
|
||||
|
||||
it('returns new assets when upload file retry is failed', async () => {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||
throw new Error('Network error');
|
||||
});
|
||||
|
||||
@@ -236,19 +236,16 @@ describe('startWatch', () => {
|
||||
await sleep(100); // to debounce the watcher from considering the test file as a existing file
|
||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: [
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: [
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out unsupported files', async () => {
|
||||
@@ -260,19 +257,16 @@ describe('startWatch', () => {
|
||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
}),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
@@ -297,19 +291,16 @@ describe('startWatch', () => {
|
||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
}),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
|
||||
+2
-6
@@ -1,4 +1,4 @@
|
||||
import { defineConfig, UserConfig } from 'vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -17,8 +17,4 @@ export default defineConfig({
|
||||
noExternal: /^(?!node:).*$/,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
name: 'cli:unit',
|
||||
globals: true,
|
||||
},
|
||||
} as UserConfig);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "0.99.4"
|
||||
opentofu = "1.11.5"
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -155,7 +155,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
||||
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
user: '1000:1000'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -67,8 +67,7 @@ graph TD
|
||||
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
||||
D --> E[Smart Search]
|
||||
D --> F[Face Detection]
|
||||
D --> G[OCR]
|
||||
D --> H[Video Transcoding]
|
||||
E --> I[Duplicate Detection]
|
||||
F --> J[Facial Recognition]
|
||||
D --> G[Video Transcoding]
|
||||
E --> H[Duplicate Detection]
|
||||
F --> I[Facial Recognition]
|
||||
```
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"start": "docusaurus start --port 3005",
|
||||
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||
"build": "pnpm run copy:openapi && docusaurus build",
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
+10
-7
@@ -14,11 +14,14 @@
|
||||
"start:web": "pnpm exec playwright test --ui --project=web",
|
||||
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
||||
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "pnpm run lint --fix",
|
||||
"check": "tsc --noEmit"
|
||||
"check": "tsc --noEmit",
|
||||
"screenshots": "pnpm exec playwright test --config playwright.screenshot.config.ts",
|
||||
"screenshots:compare": "pnpm exec tsx src/screenshots/compare.ts",
|
||||
"screenshots:analyze": "pnpm exec tsx src/screenshots/analyze-deps.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -27,12 +30,12 @@
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@immich/cli": "workspace:*",
|
||||
"@immich/e2e-auth-server": "workspace:*",
|
||||
"@immich/e2e-auth-server": "workspace:*",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
@@ -51,11 +54,11 @@
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"utimes": "^5.2.1",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.0"
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const baseUrl = process.env.SCREENSHOT_BASE_URL ?? 'http://127.0.0.1:4173';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src/screenshots',
|
||||
testMatch: /run-scenarios\.ts/,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: baseUrl,
|
||||
screenshot: 'off',
|
||||
trace: 'off',
|
||||
},
|
||||
workers: 1,
|
||||
projects: [
|
||||
{
|
||||
name: 'screenshots',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Reverse dependency analyzer for the Immich web app.
|
||||
*
|
||||
* Given a list of changed files, traces upward through the import graph
|
||||
* to find which +page.svelte routes are affected, then maps those to URL paths.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
|
||||
const WEB_SRC = resolve(import.meta.dirname, '../../../web/src');
|
||||
const LIB_ALIAS = resolve(WEB_SRC, 'lib');
|
||||
|
||||
/** Collect all .svelte, .ts, .js files under web/src/ */
|
||||
function collectFiles(dir: string): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === 'node_modules' || entry === '.svelte-kit') {
|
||||
continue;
|
||||
}
|
||||
results.push(...collectFiles(full));
|
||||
} else if (/\.(svelte|ts|js)$/.test(entry)) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Extract import specifiers from a file's source text. */
|
||||
function extractImports(source: string): string[] {
|
||||
const specifiers: string[] = [];
|
||||
|
||||
// Match: import ... from '...' / import '...' / export ... from '...'
|
||||
const importRegex = /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
||||
let match;
|
||||
while ((match = importRegex.exec(source)) !== null) {
|
||||
specifiers.push(match[1]);
|
||||
}
|
||||
|
||||
// Match dynamic imports: import('...')
|
||||
const dynamicRegex = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
while ((match = dynamicRegex.exec(source)) !== null) {
|
||||
specifiers.push(match[1]);
|
||||
}
|
||||
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
/** Resolve an import specifier to an absolute file path (or null if external). */
|
||||
function resolveImport(specifier: string, fromFile: string, allFiles: Set<string>): string | null {
|
||||
// Handle $lib alias
|
||||
let resolved: string;
|
||||
if (specifier.startsWith('$lib/') || specifier === '$lib') {
|
||||
resolved = specifier.replace('$lib', LIB_ALIAS);
|
||||
} else if (specifier.startsWith('./') || specifier.startsWith('../')) {
|
||||
resolved = resolve(dirname(fromFile), specifier);
|
||||
} else {
|
||||
// External package import — not relevant
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try exact match, then common extensions
|
||||
const extensions = ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js', '/index.svelte'];
|
||||
for (const ext of extensions) {
|
||||
const candidate = resolved + ext;
|
||||
if (allFiles.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the forward dependency graph: file → set of files it imports. */
|
||||
function buildDependencyGraph(files: string[]): Map<string, Set<string>> {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map<string, Set<string>>();
|
||||
|
||||
for (const file of files) {
|
||||
const deps = new Set<string>();
|
||||
graph.set(file, deps);
|
||||
|
||||
try {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const specifier of extractImports(source)) {
|
||||
const resolved = resolveImport(specifier, file, fileSet);
|
||||
if (resolved) {
|
||||
deps.add(resolved);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Invert the dependency graph: file → set of files that import it. */
|
||||
function buildReverseDependencyGraph(forwardGraph: Map<string, Set<string>>): Map<string, Set<string>> {
|
||||
const reverse = new Map<string, Set<string>>();
|
||||
|
||||
for (const [file, deps] of forwardGraph) {
|
||||
for (const dep of deps) {
|
||||
let importers = reverse.get(dep);
|
||||
if (!importers) {
|
||||
importers = new Set();
|
||||
reverse.set(dep, importers);
|
||||
}
|
||||
importers.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
return reverse;
|
||||
}
|
||||
|
||||
/** BFS from changed files upward through reverse deps to find +page.svelte files. */
|
||||
function findAffectedPages(changedFiles: string[], reverseGraph: Map<string, Set<string>>): Set<string> {
|
||||
const visited = new Set<string>();
|
||||
const pages = new Set<string>();
|
||||
const queue = [...changedFiles];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const file = queue.shift()!;
|
||||
if (visited.has(file)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(file);
|
||||
|
||||
if (file.endsWith('+page.svelte') || file.endsWith('+layout.svelte')) {
|
||||
pages.add(file);
|
||||
// If it's a layout, keep tracing upward because the layout itself
|
||||
// isn't a page — but the pages under it are affected.
|
||||
// If it's a +page.svelte, we still want to continue in case
|
||||
// this page is imported by others.
|
||||
}
|
||||
|
||||
const importers = reverseGraph.get(file);
|
||||
if (importers) {
|
||||
for (const importer of importers) {
|
||||
if (!visited.has(importer)) {
|
||||
queue.push(importer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For +layout.svelte hits, also find all +page.svelte under the same directory tree
|
||||
const layoutDirs: string[] = [];
|
||||
for (const page of pages) {
|
||||
if (page.endsWith('+layout.svelte')) {
|
||||
layoutDirs.push(dirname(page));
|
||||
pages.delete(page);
|
||||
}
|
||||
}
|
||||
|
||||
if (layoutDirs.length > 0) {
|
||||
for (const file of reverseGraph.keys()) {
|
||||
if (file.endsWith('+page.svelte')) {
|
||||
for (const layoutDir of layoutDirs) {
|
||||
if (file.startsWith(layoutDir)) {
|
||||
pages.add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check the forward graph keys for page files under layout dirs
|
||||
for (const layoutDir of layoutDirs) {
|
||||
const allFiles = collectFiles(layoutDir);
|
||||
for (const f of allFiles) {
|
||||
if (f.endsWith('+page.svelte')) {
|
||||
pages.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** Convert a +page.svelte file path to its URL route. */
|
||||
export function pageFileToRoute(pageFile: string): string {
|
||||
const routesDir = resolve(WEB_SRC, 'routes');
|
||||
let rel = relative(routesDir, dirname(pageFile));
|
||||
|
||||
// Remove SvelteKit group markers: (user), (list), etc.
|
||||
rel = rel.replaceAll(/\([^)]+\)\/?/g, '');
|
||||
|
||||
// Remove parameter segments: [albumId=id], [[photos=photos]], [[assetId=id]]
|
||||
rel = rel.replaceAll(/\[\[?[^\]]+\]\]?\/?/g, '');
|
||||
|
||||
// Clean up trailing slashes and normalize
|
||||
rel = rel.replaceAll(/\/+/g, '/').replace(/\/$/, '');
|
||||
|
||||
return '/' + rel;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
affectedPages: string[];
|
||||
affectedRoutes: string[];
|
||||
}
|
||||
|
||||
/** Main entry: analyze which routes are affected by the given changed files. */
|
||||
export function analyzeAffectedRoutes(changedFiles: string[]): AnalysisResult {
|
||||
// Resolve changed files to absolute paths relative to web/src
|
||||
const webRoot = resolve(WEB_SRC, '..');
|
||||
const resolvedChanged = changedFiles
|
||||
.filter((f) => f.startsWith('web/'))
|
||||
.map((f) => resolve(webRoot, '..', f))
|
||||
.filter((f) => statSync(f, { throwIfNoEntry: false })?.isFile());
|
||||
|
||||
if (resolvedChanged.length === 0) {
|
||||
return { affectedPages: [], affectedRoutes: [] };
|
||||
}
|
||||
|
||||
const allFiles = collectFiles(WEB_SRC);
|
||||
const forwardGraph = buildDependencyGraph(allFiles);
|
||||
const reverseGraph = buildReverseDependencyGraph(forwardGraph);
|
||||
|
||||
const pages = findAffectedPages(resolvedChanged, reverseGraph);
|
||||
|
||||
const affectedPages = [...pages].toSorted();
|
||||
const affectedRoutes = [...new Set(affectedPages.map((f) => pageFileToRoute(f)))].toSorted();
|
||||
|
||||
return { affectedPages, affectedRoutes };
|
||||
}
|
||||
|
||||
// CLI usage: node --import tsx analyze-deps.ts file1 file2 ...
|
||||
if (process.argv[1]?.endsWith('analyze-deps.ts') || process.argv[1]?.endsWith('analyze-deps.js')) {
|
||||
const files = process.argv.slice(2);
|
||||
if (files.length === 0) {
|
||||
console.log('Usage: analyze-deps.ts <changed-file1> <changed-file2> ...');
|
||||
console.log('Files should be relative to the repo root (e.g. web/src/lib/components/Button.svelte)');
|
||||
throw new Error('No files provided');
|
||||
}
|
||||
|
||||
const result = analyzeAffectedRoutes(files);
|
||||
console.log('Affected pages:');
|
||||
for (const page of result.affectedPages) {
|
||||
console.log(` ${page}`);
|
||||
}
|
||||
console.log('\nAffected routes:');
|
||||
for (const route of result.affectedRoutes) {
|
||||
console.log(` ${route}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Pixel-level comparison of base vs PR screenshots.
|
||||
*
|
||||
* Uses pixelmatch to generate diff images and calculate change percentages.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx e2e/src/screenshots/compare.ts <base-dir> <pr-dir> <output-dir>
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
// pixelmatch is a lightweight dependency — use a simple inline implementation
|
||||
// based on the approach from the pixelmatch library to avoid adding a new dependency.
|
||||
// The e2e package already has pngjs.
|
||||
|
||||
function pixelMatch(img1Data: Uint8Array, img2Data: Uint8Array, diffData: Uint8Array): number {
|
||||
let diffCount = 0;
|
||||
|
||||
for (let i = 0; i < img1Data.length; i += 4) {
|
||||
const r1 = img1Data[i];
|
||||
const g1 = img1Data[i + 1];
|
||||
const b1 = img1Data[i + 2];
|
||||
|
||||
const r2 = img2Data[i];
|
||||
const g2 = img2Data[i + 1];
|
||||
const b2 = img2Data[i + 2];
|
||||
|
||||
const dr = Math.abs(r1 - r2);
|
||||
const dg = Math.abs(g1 - g2);
|
||||
const db = Math.abs(b1 - b2);
|
||||
|
||||
// Threshold: if any channel differs by more than 25, mark as different
|
||||
const isDiff = dr > 25 || dg > 25 || db > 25;
|
||||
|
||||
if (isDiff) {
|
||||
// Red highlight for diff pixels
|
||||
diffData[i] = 255;
|
||||
diffData[i + 1] = 0;
|
||||
diffData[i + 2] = 0;
|
||||
diffData[i + 3] = 255;
|
||||
diffCount++;
|
||||
} else {
|
||||
// Dimmed original for unchanged pixels
|
||||
const gray = Math.round(0.299 * r1 + 0.587 * g1 + 0.114 * b1);
|
||||
diffData[i] = gray;
|
||||
diffData[i + 1] = gray;
|
||||
diffData[i + 2] = gray;
|
||||
diffData[i + 3] = 128;
|
||||
}
|
||||
}
|
||||
|
||||
return diffCount;
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
name: string;
|
||||
baseExists: boolean;
|
||||
prExists: boolean;
|
||||
diffPixels: number;
|
||||
totalPixels: number;
|
||||
changePercent: number;
|
||||
diffImagePath: string | null;
|
||||
baseImagePath: string | null;
|
||||
prImagePath: string | null;
|
||||
}
|
||||
|
||||
export function compareScreenshots(baseDir: string, prDir: string, outputDir: string): ComparisonResult[] {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// Collect all screenshot names from both directories
|
||||
const baseFiles = existsSync(baseDir)
|
||||
? new Set(readdirSync(baseDir).filter((f) => f.endsWith('.png')))
|
||||
: new Set<string>();
|
||||
const prFiles = existsSync(prDir) ? new Set(readdirSync(prDir).filter((f) => f.endsWith('.png'))) : new Set<string>();
|
||||
|
||||
const allNames = new Set([...baseFiles, ...prFiles]);
|
||||
const results: ComparisonResult[] = [];
|
||||
|
||||
for (const fileName of [...allNames].toSorted()) {
|
||||
const name = basename(fileName, '.png');
|
||||
const basePath = join(baseDir, fileName);
|
||||
const prPath = join(prDir, fileName);
|
||||
const baseExists = baseFiles.has(fileName);
|
||||
const prExists = prFiles.has(fileName);
|
||||
|
||||
if (!baseExists || !prExists) {
|
||||
// New or removed page
|
||||
results.push({
|
||||
name,
|
||||
baseExists,
|
||||
prExists,
|
||||
diffPixels: -1,
|
||||
totalPixels: -1,
|
||||
changePercent: 100,
|
||||
diffImagePath: null,
|
||||
baseImagePath: baseExists ? basePath : null,
|
||||
prImagePath: prExists ? prPath : null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load both PNGs
|
||||
const basePng = PNG.sync.read(readFileSync(basePath));
|
||||
const prPng = PNG.sync.read(readFileSync(prPath));
|
||||
|
||||
// Handle size mismatches by comparing the overlapping region
|
||||
const width = Math.max(basePng.width, prPng.width);
|
||||
const height = Math.max(basePng.height, prPng.height);
|
||||
|
||||
// Resize images to the same dimensions (pad with transparent)
|
||||
const normalizedBase = normalizeImage(basePng, width, height);
|
||||
const normalizedPr = normalizeImage(prPng, width, height);
|
||||
|
||||
const diffPng = new PNG({ width, height });
|
||||
const totalPixels = width * height;
|
||||
const diffPixels = pixelMatch(normalizedBase, normalizedPr, diffPng.data as unknown as Uint8Array);
|
||||
|
||||
const diffImagePath = join(outputDir, `${name}-diff.png`);
|
||||
writeFileSync(diffImagePath, PNG.sync.write(diffPng));
|
||||
|
||||
results.push({
|
||||
name,
|
||||
baseExists,
|
||||
prExists,
|
||||
diffPixels,
|
||||
totalPixels,
|
||||
changePercent: totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0,
|
||||
diffImagePath,
|
||||
baseImagePath: basePath,
|
||||
prImagePath: prPath,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function normalizeImage(png: PNG, targetWidth: number, targetHeight: number): Uint8Array {
|
||||
if (png.width === targetWidth && png.height === targetHeight) {
|
||||
return png.data as unknown as Uint8Array;
|
||||
}
|
||||
|
||||
const data = new Uint8Array(targetWidth * targetHeight * 4);
|
||||
for (let y = 0; y < targetHeight; y++) {
|
||||
for (let x = 0; x < targetWidth; x++) {
|
||||
const targetIdx = (y * targetWidth + x) * 4;
|
||||
if (x < png.width && y < png.height) {
|
||||
const sourceIdx = (y * png.width + x) * 4;
|
||||
data[targetIdx] = png.data[sourceIdx];
|
||||
data[targetIdx + 1] = png.data[sourceIdx + 1];
|
||||
data[targetIdx + 2] = png.data[sourceIdx + 2];
|
||||
data[targetIdx + 3] = png.data[sourceIdx + 3];
|
||||
} else {
|
||||
// Transparent padding
|
||||
data[targetIdx + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Generate a text-only markdown summary for the PR comment. */
|
||||
export function generateMarkdownReport(results: ComparisonResult[]): string {
|
||||
const changed = results.filter((r) => r.changePercent > 0.1);
|
||||
const unchanged = results.filter((r) => r.changePercent <= 0.1);
|
||||
|
||||
if (changed.length === 0) {
|
||||
return '## Visual Review\n\nNo visual changes detected in the affected pages.';
|
||||
}
|
||||
|
||||
let md = '## Visual Review\n\n';
|
||||
md += `Found **${changed.length}** page(s) with visual changes`;
|
||||
if (unchanged.length > 0) {
|
||||
md += ` (${unchanged.length} unchanged)`;
|
||||
}
|
||||
md += '.\n\n';
|
||||
|
||||
md += '| Page | Status | Change |\n';
|
||||
md += '|------|--------|--------|\n';
|
||||
|
||||
for (const result of changed) {
|
||||
if (result.baseExists && result.prExists) {
|
||||
md += `| ${result.name} | Changed | ${result.changePercent.toFixed(1)}% |\n`;
|
||||
} else if (result.prExists) {
|
||||
md += `| ${result.name} | New | - |\n`;
|
||||
} else {
|
||||
md += `| ${result.name} | Removed | - |\n`;
|
||||
}
|
||||
}
|
||||
|
||||
md += '\n';
|
||||
|
||||
if (unchanged.length > 0) {
|
||||
md += '<details>\n<summary>Unchanged pages</summary>\n\n';
|
||||
for (const result of unchanged) {
|
||||
md += `- ${result.name}\n`;
|
||||
}
|
||||
md += '\n</details>\n';
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
function imgTag(filePath: string | null, alt: string): string {
|
||||
if (!filePath || !existsSync(filePath)) {
|
||||
return `<div class="no-image">${alt} not available</div>`;
|
||||
}
|
||||
const data = readFileSync(filePath);
|
||||
return `<img src="data:image/png;base64,${data.toString('base64')}" alt="${alt}" loading="lazy" />`;
|
||||
}
|
||||
|
||||
/** Generate an HTML report with embedded base64 images for the artifact. */
|
||||
export function generateHtmlReport(results: ComparisonResult[]): string {
|
||||
const changed = results.filter((r) => r.changePercent > 0.1);
|
||||
const unchanged = results.filter((r) => r.changePercent <= 0.1);
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visual Review</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
background: #0d1117; color: #e6edf3; padding: 32px; line-height: 1.5; }
|
||||
.container { max-width: 1800px; margin: 0 auto; }
|
||||
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 24px; }
|
||||
.summary { color: #8b949e; margin-bottom: 32px; font-size: 16px; }
|
||||
.scenario { margin-bottom: 40px; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
|
||||
.scenario-header { background: #161b22; padding: 12px 16px; display: flex; align-items: center; gap: 12px; }
|
||||
.scenario-header h2 { font-size: 16px; font-weight: 600; }
|
||||
.badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.badge-changed { background: #da363380; color: #f85149; }
|
||||
.badge-new { background: #1f6feb80; color: #58a6ff; }
|
||||
.badge-removed { background: #6e767e80; color: #8b949e; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1px; background: #30363d; }
|
||||
.grid-cell { background: #0d1117; }
|
||||
.grid-label { text-align: center; padding: 8px; font-size: 13px; color: #8b949e; font-weight: 600;
|
||||
background: #161b22; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.grid-cell img { width: 100%; display: block; }
|
||||
.no-image { padding: 40px; text-align: center; color: #484f58; font-style: italic; }
|
||||
.unchanged-section { margin-top: 32px; color: #8b949e; }
|
||||
.unchanged-section summary { cursor: pointer; font-size: 14px; }
|
||||
.unchanged-section ul { margin-top: 8px; padding-left: 24px; }
|
||||
.unchanged-section li { font-size: 14px; margin: 4px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Visual Review</h1>
|
||||
`;
|
||||
|
||||
if (changed.length === 0) {
|
||||
html += '<p class="summary">No visual changes detected in the affected pages.</p>';
|
||||
} else {
|
||||
html += `<p class="summary">Found <strong>${changed.length}</strong> page(s) with visual changes`;
|
||||
if (unchanged.length > 0) {
|
||||
html += ` (${unchanged.length} unchanged)`;
|
||||
}
|
||||
html += '.</p>\n';
|
||||
|
||||
for (const result of changed) {
|
||||
html += '<div class="scenario">\n<div class="scenario-header">\n';
|
||||
html += `<h2>${result.name}</h2>\n`;
|
||||
|
||||
if (!result.baseExists) {
|
||||
html += '<span class="badge badge-new">New</span>\n';
|
||||
html += '</div>\n';
|
||||
html += `<div style="padding: 16px;">${imgTag(result.prImagePath, 'PR')}</div>\n`;
|
||||
html += '</div>\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.prExists) {
|
||||
html += '<span class="badge badge-removed">Removed</span>\n';
|
||||
html += '</div>\n</div>\n';
|
||||
continue;
|
||||
}
|
||||
|
||||
html += `<span class="badge badge-changed">${result.changePercent.toFixed(1)}% changed</span>\n`;
|
||||
html += '</div>\n';
|
||||
html += '<div class="grid">\n';
|
||||
html += `<div class="grid-cell"><div class="grid-label">Base</div>${imgTag(result.baseImagePath, 'Base')}</div>\n`;
|
||||
html += `<div class="grid-cell"><div class="grid-label">PR</div>${imgTag(result.prImagePath, 'PR')}</div>\n`;
|
||||
html += `<div class="grid-cell"><div class="grid-label">Diff</div>${imgTag(result.diffImagePath, 'Diff')}</div>\n`;
|
||||
html += '</div>\n</div>\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (unchanged.length > 0) {
|
||||
html += '<div class="unchanged-section">\n<details>\n<summary>Unchanged pages</summary>\n<ul>\n';
|
||||
for (const result of unchanged) {
|
||||
html += `<li>${result.name}</li>\n`;
|
||||
}
|
||||
html += '</ul>\n</details>\n</div>\n';
|
||||
}
|
||||
|
||||
html += '</div>\n</body>\n</html>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// CLI usage
|
||||
if (process.argv[1]?.endsWith('compare.ts') || process.argv[1]?.endsWith('compare.js')) {
|
||||
const [baseDir, prDir, outputDir] = process.argv.slice(2);
|
||||
|
||||
if (!baseDir || !prDir || !outputDir) {
|
||||
throw new Error('Usage: compare.ts <base-dir> <pr-dir> <output-dir>');
|
||||
}
|
||||
|
||||
const resolvedOutputDir = resolve(outputDir);
|
||||
const results = compareScreenshots(resolve(baseDir), resolve(prDir), resolvedOutputDir);
|
||||
|
||||
console.log('\nComparison Results:');
|
||||
console.log('==================');
|
||||
for (const r of results) {
|
||||
const status = r.changePercent > 0.1 ? 'CHANGED' : 'unchanged';
|
||||
console.log(` ${r.name}: ${status} (${r.changePercent.toFixed(1)}%)`);
|
||||
}
|
||||
|
||||
const report = generateMarkdownReport(results);
|
||||
const reportPath = join(resolvedOutputDir, 'report.md');
|
||||
writeFileSync(reportPath, report);
|
||||
console.log(`\nMarkdown report written to: ${reportPath}`);
|
||||
|
||||
const htmlReport = generateHtmlReport(results);
|
||||
const htmlPath = join(resolvedOutputDir, 'visual-review.html');
|
||||
writeFileSync(htmlPath, htmlReport);
|
||||
console.log(`HTML report written to: ${htmlPath}`);
|
||||
|
||||
const jsonPath = join(resolvedOutputDir, 'results.json');
|
||||
writeFileSync(jsonPath, JSON.stringify(results, null, 2));
|
||||
console.log(`Results JSON written to: ${jsonPath}`);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Maps URL routes to screenshot scenario keys.
|
||||
*
|
||||
* Routes discovered by the dependency analyzer are matched against this map
|
||||
* to determine which screenshot scenarios to run. Routes not in this map
|
||||
* are skipped (they don't have a scenario defined yet).
|
||||
*/
|
||||
|
||||
export interface ScenarioDefinition {
|
||||
/** The URL path to navigate to */
|
||||
url: string;
|
||||
/** Human-readable name for the screenshot file */
|
||||
name: string;
|
||||
/** Which mock networks this scenario needs */
|
||||
mocks: ('base' | 'timeline' | 'memory')[];
|
||||
/** Optional: selector to wait for before screenshotting */
|
||||
waitForSelector?: string;
|
||||
/** Optional: time to wait after page load (ms) for animations to settle */
|
||||
settleTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map from route paths (as output by analyze-deps) to scenario definitions.
|
||||
* A single route might map to multiple scenarios (e.g., different states).
|
||||
*/
|
||||
export const PAGE_SCENARIOS: Record<string, ScenarioDefinition[]> = {
|
||||
'/photos': [
|
||||
{
|
||||
url: '/photos',
|
||||
name: 'photos-timeline',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '[data-thumbnail-focus-container]',
|
||||
settleTime: 500,
|
||||
},
|
||||
],
|
||||
'/albums': [
|
||||
{
|
||||
url: '/albums',
|
||||
name: 'albums-list',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/explore': [
|
||||
{
|
||||
url: '/explore',
|
||||
name: 'explore',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/favorites': [
|
||||
{
|
||||
url: '/favorites',
|
||||
name: 'favorites',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '#asset-grid',
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/archive': [
|
||||
{
|
||||
url: '/archive',
|
||||
name: 'archive',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '#asset-grid',
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/trash': [
|
||||
{
|
||||
url: '/trash',
|
||||
name: 'trash',
|
||||
mocks: ['base', 'timeline'],
|
||||
waitForSelector: '#asset-grid',
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/people': [
|
||||
{
|
||||
url: '/people',
|
||||
name: 'people',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/sharing': [
|
||||
{
|
||||
url: '/sharing',
|
||||
name: 'sharing',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/search': [
|
||||
{
|
||||
url: '/search',
|
||||
name: 'search',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/memory': [
|
||||
{
|
||||
url: '/memory',
|
||||
name: 'memory',
|
||||
mocks: ['base', 'memory'],
|
||||
settleTime: 500,
|
||||
},
|
||||
],
|
||||
'/user-settings': [
|
||||
{
|
||||
url: '/user-settings',
|
||||
name: 'user-settings',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/map': [
|
||||
{
|
||||
url: '/map',
|
||||
name: 'map',
|
||||
mocks: ['base'],
|
||||
settleTime: 500,
|
||||
},
|
||||
],
|
||||
'/admin': [
|
||||
{
|
||||
url: '/admin',
|
||||
name: 'admin-dashboard',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/admin/system-settings': [
|
||||
{
|
||||
url: '/admin/system-settings',
|
||||
name: 'admin-system-settings',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/admin/users': [
|
||||
{
|
||||
url: '/admin/users',
|
||||
name: 'admin-users',
|
||||
mocks: ['base'],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/auth/login': [
|
||||
{
|
||||
url: '/auth/login',
|
||||
name: 'login',
|
||||
mocks: [],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
'/': [
|
||||
{
|
||||
url: '/',
|
||||
name: 'landing',
|
||||
mocks: [],
|
||||
settleTime: 300,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Given a list of routes from the analyzer, return the matching scenarios. */
|
||||
export function getScenariosForRoutes(routes: string[]): ScenarioDefinition[] {
|
||||
const scenarios: ScenarioDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const route of routes) {
|
||||
const defs = PAGE_SCENARIOS[route];
|
||||
if (defs) {
|
||||
for (const def of defs) {
|
||||
if (!seen.has(def.name)) {
|
||||
seen.add(def.name);
|
||||
scenarios.push(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scenarios;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Playwright script to capture screenshots for visual diff scenarios.
|
||||
*
|
||||
* Usage:
|
||||
* npx playwright test --config e2e/playwright.screenshot.config.ts
|
||||
*
|
||||
* Environment variables:
|
||||
* SCREENSHOT_SCENARIOS - JSON array of scenario names to run (from page-map.ts)
|
||||
* If not set, runs all scenarios.
|
||||
* SCREENSHOT_OUTPUT_DIR - Directory to save screenshots to. Defaults to e2e/screenshots-output.
|
||||
*/
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory';
|
||||
import {
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
type TimelineAssetConfig,
|
||||
type TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
import { PAGE_SCENARIOS, type ScenarioDefinition } from './page-map';
|
||||
|
||||
const OUTPUT_DIR = process.env.SCREENSHOT_OUTPUT_DIR || resolve(import.meta.dirname, '../../../screenshots-output');
|
||||
const SCENARIO_FILTER: string[] | null = process.env.SCREENSHOT_SCENARIOS
|
||||
? JSON.parse(process.env.SCREENSHOT_SCENARIOS)
|
||||
: null;
|
||||
|
||||
// Collect scenarios to run
|
||||
const allScenarios: ScenarioDefinition[] = [];
|
||||
for (const defs of Object.values(PAGE_SCENARIOS)) {
|
||||
for (const def of defs) {
|
||||
if (!SCENARIO_FILTER || SCENARIO_FILTER.includes(def.name)) {
|
||||
allScenarios.push(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use a fixed seed so screenshots are deterministic across runs
|
||||
faker.seed(42);
|
||||
|
||||
let adminUserId: string;
|
||||
let timelineData: TimelineData;
|
||||
let timelineAssets: TimelineAssetConfig[];
|
||||
let memories: MemoryResponseDto[];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
timelineData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
|
||||
timelineAssets = [];
|
||||
for (const timeBucket of timelineData.buckets.values()) {
|
||||
timelineAssets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
timelineAssets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 3 },
|
||||
{ year: 2023, assetCount: 2 },
|
||||
],
|
||||
42,
|
||||
);
|
||||
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
for (const scenario of allScenarios) {
|
||||
test(`Screenshot: ${scenario.name}`, async ({ context, page }) => {
|
||||
// Set up mocks based on scenario requirements
|
||||
if (scenario.mocks.includes('base')) {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
}
|
||||
|
||||
if (scenario.mocks.includes('timeline')) {
|
||||
const testContext = new TimelineTestContext();
|
||||
testContext.adminId = adminUserId;
|
||||
await setupTimelineMockApiRoutes(
|
||||
context,
|
||||
timelineData,
|
||||
{
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
},
|
||||
testContext,
|
||||
);
|
||||
}
|
||||
|
||||
if (scenario.mocks.includes('memory')) {
|
||||
await setupMemoryMockApiRoutes(context, memories, {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
// Navigate to the page. Use networkidle so SvelteKit hydrates and API
|
||||
// calls complete, but fall back to domcontentloaded if it times out
|
||||
// (e.g. a persistent connection the catch-all mock didn't cover).
|
||||
try {
|
||||
await page.goto(scenario.url, { waitUntil: 'networkidle', timeout: 15_000 });
|
||||
} catch {
|
||||
console.warn(`networkidle timed out for ${scenario.name}, falling back to current state`);
|
||||
// Page has already navigated, just continue with what we have
|
||||
}
|
||||
|
||||
// Wait for specific selector if specified
|
||||
if (scenario.waitForSelector) {
|
||||
try {
|
||||
await page.waitForSelector(scenario.waitForSelector, { timeout: 15_000 });
|
||||
} catch {
|
||||
console.warn(`Selector ${scenario.waitForSelector} not found for ${scenario.name}, continuing...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for loading spinners to disappear
|
||||
await page
|
||||
.waitForFunction(() => document.querySelectorAll('[data-testid="loading-spinner"]').length === 0, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Wait for animations/transitions to settle
|
||||
await page.waitForTimeout(scenario.settleTime ?? 500);
|
||||
|
||||
// Take the screenshot
|
||||
await page.screenshot({
|
||||
path: resolve(OUTPUT_DIR, `${scenario.name}.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
async function ensureDetailPanelVisible(page: Page) {
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const isVisible = await page.locator('#detail-panel').isVisible();
|
||||
if (!isVisible) {
|
||||
await page.keyboard.press('i');
|
||||
await page.waitForSelector('#detail-panel');
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Asset Viewer stack', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let assetOne: AssetMediaResponseDto;
|
||||
let assetTwo: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
|
||||
|
||||
assetOne = await utils.createAsset(admin.accessToken);
|
||||
assetTwo = await utils.createAsset(admin.accessToken);
|
||||
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
|
||||
|
||||
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
|
||||
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
|
||||
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
|
||||
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
|
||||
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
|
||||
});
|
||||
|
||||
test('stack slideshow is visible', async ({ page, context }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/photos/${assetOne.id}`);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await expect(stackAssets.first()).toBeVisible();
|
||||
await expect(stackAssets.nth(1)).toBeVisible();
|
||||
});
|
||||
|
||||
test('tags of primary asset are visible', async ({ page, context }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/photos/${assetOne.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/1');
|
||||
});
|
||||
|
||||
test('tags of second asset are visible', async ({ page, context }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/photos/${assetOne.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await stackAssets.nth(1).click();
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/2');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
function imageLocator(page: Page) {
|
||||
return page.getByAltText('Image taken').locator('visible=true');
|
||||
}
|
||||
test.describe('Photo Viewer', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
@@ -23,42 +26,31 @@ test.describe('Photo Viewer', () => {
|
||||
|
||||
test('loads original photo when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect(original).toBeInViewport();
|
||||
await expect(original).toHaveAttribute('src', /original/);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||
});
|
||||
|
||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||
await page.goto(`/photos/${rawAsset.id}`);
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||
});
|
||||
|
||||
test('reloads photo when checksum changes', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const initialSrc = await thumbnail.getAttribute('src');
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const initialSrc = await imageLocator(page).getAttribute('src');
|
||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
|
||||
// Block socket.io connections — these are persistent WebSocket connections
|
||||
// that prevent networkidle from resolving since there's no real server.
|
||||
await context.route('**/api/socket.io**', async (route) => {
|
||||
return route.abort('connectionrefused');
|
||||
});
|
||||
|
||||
// Catch-all for any /api/ endpoint not explicitly mocked below.
|
||||
// Registered FIRST so specific routes (registered after) take priority
|
||||
// (Playwright checks routes in reverse registration order).
|
||||
// Without this, unmocked API calls hit the static preview server which
|
||||
// either hangs or returns HTML, preventing networkidle and causing timeouts.
|
||||
await context.route('**/api/**', async (route) => {
|
||||
const method = route.request().method();
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: method === 'GET' ? [] : {},
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/users/me', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline';
|
||||
|
||||
export type MockStack = {
|
||||
id: string;
|
||||
primaryAssetId: string;
|
||||
assets: AssetResponseDto[];
|
||||
brokenAssetIds: Set<string>;
|
||||
assetMap: Map<string, AssetResponseDto>;
|
||||
};
|
||||
|
||||
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
||||
const assetId = faker.string.uuid();
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: assetId,
|
||||
deviceAssetId: `device-${assetId}`,
|
||||
ownerId,
|
||||
owner: {
|
||||
id: ownerId,
|
||||
email: 'admin@immich.cloud',
|
||||
name: 'Admin',
|
||||
profileImagePath: '',
|
||||
profileChangedAt: now,
|
||||
avatarColor: 'blue' as never,
|
||||
},
|
||||
libraryId: `library-${ownerId}`,
|
||||
deviceId: `device-${ownerId}`,
|
||||
type: AssetTypeEnum.Image,
|
||||
originalPath: `/original/${assetId}.jpg`,
|
||||
originalFileName: `${assetId}.jpg`,
|
||||
originalMimeType: 'image/jpeg',
|
||||
thumbhash: null,
|
||||
fileCreatedAt: now,
|
||||
fileModifiedAt: now,
|
||||
localDateTime: now,
|
||||
updatedAt: now,
|
||||
createdAt: now,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isTrashed: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
duration: '0:00:00.00000',
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
exifImageWidth: 3000,
|
||||
exifImageHeight: 4000,
|
||||
fileSizeInByte: null,
|
||||
orientation: null,
|
||||
dateTimeOriginal: now,
|
||||
modifyDate: null,
|
||||
timeZone: null,
|
||||
lensModel: null,
|
||||
fNumber: null,
|
||||
focalLength: null,
|
||||
iso: null,
|
||||
exposureTime: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
city: null,
|
||||
country: null,
|
||||
state: null,
|
||||
description: null,
|
||||
},
|
||||
livePhotoVideoId: null,
|
||||
tags: [],
|
||||
people: [],
|
||||
unassignedFaces: [],
|
||||
stack: null,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
duplicateId: null,
|
||||
resized: true,
|
||||
checksum: faker.string.alphanumeric({ length: 28 }),
|
||||
width: 3000,
|
||||
height: 4000,
|
||||
isEdited: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockStack = (
|
||||
primaryAssetDto: AssetResponseDto,
|
||||
additionalAssets: AssetResponseDto[],
|
||||
brokenAssetIds?: Set<string>,
|
||||
): MockStack => {
|
||||
const stackId = faker.string.uuid();
|
||||
const allAssets = [primaryAssetDto, ...additionalAssets];
|
||||
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
|
||||
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
|
||||
|
||||
primaryAssetDto.stack = {
|
||||
id: stackId,
|
||||
assetCount: allAssets.length,
|
||||
primaryAssetId: primaryAssetDto.id,
|
||||
};
|
||||
|
||||
return {
|
||||
id: stackId,
|
||||
primaryAssetId: primaryAssetDto.id,
|
||||
assets: allAssets,
|
||||
brokenAssetIds: resolvedBrokenIds,
|
||||
assetMap,
|
||||
};
|
||||
};
|
||||
|
||||
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
|
||||
await context.route('**/api/stacks/*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const stackResponse: StackResponseDto = {
|
||||
id: mockStack.id,
|
||||
primaryAssetId: mockStack.primaryAssetId,
|
||||
assets: mockStack.assets,
|
||||
};
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: stackResponse,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const segments = url.pathname.split('/');
|
||||
const assetId = segments.at(-1);
|
||||
if (assetId && mockStack.assetMap.has(assetId)) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: mockStack.assetMap.get(assetId),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
|
||||
return route.fallback();
|
||||
}
|
||||
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
|
||||
return route.fulfill({ status: 404 });
|
||||
}
|
||||
const asset = mockStack.assetMap.get(match.groups.assetId)!;
|
||||
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
|
||||
const body =
|
||||
match.groups.size === 'preview'
|
||||
? await randomPreview(match.groups.assetId, ratio)
|
||||
: await randomThumbnail(match.groups.assetId, ratio);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomThumbnail } from 'src/ui/generators/timeline';
|
||||
|
||||
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
|
||||
const MINIMAL_MP4_BASE64 =
|
||||
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
|
||||
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
|
||||
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
|
||||
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
|
||||
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
|
||||
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
|
||||
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
|
||||
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
|
||||
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
|
||||
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
|
||||
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
|
||||
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
|
||||
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
|
||||
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
|
||||
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
|
||||
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
|
||||
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
|
||||
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
|
||||
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
|
||||
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
|
||||
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
|
||||
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
|
||||
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
|
||||
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
|
||||
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
|
||||
|
||||
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
|
||||
|
||||
export type MockPerson = {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string | null;
|
||||
isHidden: boolean;
|
||||
thumbnailPath: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const createMockPeople = (count: number): MockPerson[] => {
|
||||
const names = [
|
||||
'Alice Johnson',
|
||||
'Bob Smith',
|
||||
'Charlie Brown',
|
||||
'Diana Prince',
|
||||
'Eve Adams',
|
||||
'Frank Castle',
|
||||
'Grace Lee',
|
||||
'Hank Pym',
|
||||
'Iris West',
|
||||
'Jack Ryan',
|
||||
];
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `person-${index}`,
|
||||
name: names[index % names.length],
|
||||
birthDate: null,
|
||||
isHidden: false,
|
||||
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
}));
|
||||
};
|
||||
|
||||
export type FaceCreateCapture = {
|
||||
requests: Array<{
|
||||
assetId: string;
|
||||
personId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const setupFaceEditorMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
mockPeople: MockPerson[],
|
||||
faceCreateCapture: FaceCreateCapture,
|
||||
) => {
|
||||
await context.route('**/api/people?*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
hasNextPage: false,
|
||||
hidden: 0,
|
||||
people: mockPeople,
|
||||
total: mockPeople.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/faces', async (route, request) => {
|
||||
if (request.method() !== 'POST') {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
const body = request.postDataJSON();
|
||||
faceCreateCapture.requests.push(body);
|
||||
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'text/plain',
|
||||
body: 'OK',
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/people/*/thumbnail', async (route) => {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail('person-thumb', 1),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { sleep } from 'src/ui/specs/timeline/utils';
|
||||
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
|
||||
|
||||
export class TimelineTestContext {
|
||||
slowBucket = false;
|
||||
@@ -99,13 +98,13 @@ export const setupTimelineMockApiRoutes = async (
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail|fullsize)/;
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||
}
|
||||
|
||||
if (match.groups.size === 'preview' || match.groups.size === 'fullsize') {
|
||||
if (match.groups.size === 'preview') {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
@@ -136,14 +135,6 @@ export const setupTimelineMockApiRoutes = async (
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/video/playback*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: MINIMAL_MP4_BUFFER,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/albums/**', async (route, request) => {
|
||||
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||
if (albumsMatch) {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockStack,
|
||||
createMockStackAsset,
|
||||
MockStack,
|
||||
setupBrokenAssetMockApiRoutes,
|
||||
} from 'src/ui/mock-network/broken-asset-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('broken-asset responsiveness', () => {
|
||||
const fixture = setupAssetViewerFixture(889);
|
||||
let mockStack: MockStack;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
|
||||
const brokenAssets = [
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
];
|
||||
|
||||
mockStack = createMockStack(primaryAssetDto, brokenAssets);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
});
|
||||
|
||||
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
||||
await expect(brokenAssets.first()).toBeVisible();
|
||||
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
|
||||
|
||||
for (const brokenAsset of await brokenAssets.all()) {
|
||||
await expect(brokenAsset.locator('svg')).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
||||
await expect(brokenAssets.first()).toBeVisible();
|
||||
|
||||
for (const brokenAsset of await brokenAssets.all()) {
|
||||
const messageSpan = brokenAsset.locator('span');
|
||||
await expect(messageSpan).toHaveClass(/text-xs/);
|
||||
}
|
||||
});
|
||||
|
||||
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
||||
await context.route(
|
||||
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
|
||||
async (route) => {
|
||||
return route.fulfill({ status: 404 });
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]');
|
||||
await expect(viewerBrokenAsset).toBeVisible();
|
||||
|
||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||
|
||||
const messageSpan = viewerBrokenAsset.locator('span');
|
||||
await expect(messageSpan).toHaveClass(/text-base/);
|
||||
});
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockPeople,
|
||||
FaceCreateCapture,
|
||||
MockPerson,
|
||||
setupFaceEditorMockApiRoutes,
|
||||
} from 'src/ui/mock-network/face-editor-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
const waitForSelectorTransition = async (page: Page) => {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const selector = document.querySelector('#face-selector') as HTMLElement | null;
|
||||
if (!selector) {
|
||||
return false;
|
||||
}
|
||||
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 1000, polling: 50 },
|
||||
);
|
||||
};
|
||||
|
||||
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.keyboard.press('i');
|
||||
await page.locator('#detail-panel').waitFor({ state: 'visible' });
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
await waitForSelectorTransition(page);
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('face-editor', () => {
|
||||
const fixture = setupAssetViewerFixture(777);
|
||||
const rng = new SeededRandom(777);
|
||||
let mockPeople: MockPerson[];
|
||||
let faceCreateCapture: FaceCreateCapture;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
mockPeople = createMockPeople(8);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
faceCreateCapture = { requests: [] };
|
||||
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
|
||||
});
|
||||
|
||||
type ScreenRect = { top: number; left: number; width: number; height: number };
|
||||
|
||||
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
|
||||
const dataEl = page.locator('#face-editor-data');
|
||||
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
|
||||
const canvasBox = await page.locator('#face-editor').boundingBox();
|
||||
if (!canvasBox) {
|
||||
throw new Error('Canvas element not found');
|
||||
}
|
||||
const left = Number(await dataEl.getAttribute('data-face-left'));
|
||||
const top = Number(await dataEl.getAttribute('data-face-top'));
|
||||
const width = Number(await dataEl.getAttribute('data-face-width'));
|
||||
const height = Number(await dataEl.getAttribute('data-face-height'));
|
||||
return {
|
||||
top: canvasBox.y + top,
|
||||
left: canvasBox.x + left,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
|
||||
const box = await page.locator('#face-selector').boundingBox();
|
||||
if (!box) {
|
||||
throw new Error('Face selector element not found');
|
||||
}
|
||||
return { top: box.y, left: box.x, width: box.width, height: box.height };
|
||||
};
|
||||
|
||||
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
|
||||
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
|
||||
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
|
||||
return overlapX * overlapY;
|
||||
};
|
||||
|
||||
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const centerX = faceBox.left + faceBox.width / 2;
|
||||
const centerY = faceBox.top + faceBox.height / 2;
|
||||
await page.mouse.move(centerX, centerY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
test('Face editor opens with person list', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeVisible();
|
||||
await expect(page.locator('#face-editor')).toBeVisible();
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Search filters people by name', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const searchInput = page.locator('#face-selector input');
|
||||
await searchInput.fill('Alice');
|
||||
|
||||
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
|
||||
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
|
||||
|
||||
await searchInput.clear();
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Search with no results shows empty message', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const searchInput = page.locator('#face-selector input');
|
||||
await searchInput.fill('Nonexistent Person XYZ');
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
|
||||
}
|
||||
});
|
||||
|
||||
test('Selecting a person shows confirmation dialog', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const personToTag = mockPeople[0];
|
||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const personToTag = mockPeople[0];
|
||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByRole('button', { name: /confirm/i }).click();
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
await expect(page.locator('#face-editor')).toBeHidden();
|
||||
|
||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
||||
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
|
||||
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
|
||||
});
|
||||
|
||||
test('Cancel button closes face editor', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeVisible();
|
||||
await expect(page.locator('#face-editor')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
await expect(page.locator('#face-editor')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Selector does not overlap face box on initial open', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 0, 150);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 200, 0);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, -300, -300);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 300, 300);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector stays within viewport bounds', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const viewportSize = page.viewportSize()!;
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
|
||||
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||
});
|
||||
|
||||
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, -400, -400);
|
||||
|
||||
const viewportSize = page.viewportSize()!;
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
|
||||
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||
});
|
||||
|
||||
test('Face box is draggable on the canvas', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const beforeDrag = await getFaceBoxRect(page);
|
||||
await dragFaceBox(page, 100, 50);
|
||||
const afterDrag = await getFaceBoxRect(page);
|
||||
|
||||
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
|
||||
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockStack,
|
||||
createMockStackAsset,
|
||||
MockStack,
|
||||
setupBrokenAssetMockApiRoutes,
|
||||
} from 'src/ui/mock-network/broken-asset-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer stack', () => {
|
||||
const fixture = setupAssetViewerFixture(888);
|
||||
let mockStack: MockStack;
|
||||
let primaryAssetDto: AssetResponseDto;
|
||||
let secondAssetDto: AssetResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
primaryAssetDto.tags = [
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
name: '1',
|
||||
value: 'test/1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
||||
secondAssetDto.tags = [
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
name: '2',
|
||||
value: 'test/2',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
});
|
||||
|
||||
test('stack slideshow is visible', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const stackAssets = stackSlideshow.locator('[data-asset]');
|
||||
await expect(stackAssets).toHaveCount(mockStack.assets.length);
|
||||
});
|
||||
|
||||
test('tags of primary asset are visible', async ({ context, page }) => {
|
||||
await enableTagsPreference(context);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/1');
|
||||
});
|
||||
|
||||
test('tags of second asset are visible', async ({ context, page }) => {
|
||||
await enableTagsPreference(context);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await stackAssets.nth(1).click();
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/2');
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext, Page, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
export type AssetViewerTestFixture = {
|
||||
adminUserId: string;
|
||||
timelineRestData: TimelineData;
|
||||
assets: TimelineAssetConfig[];
|
||||
testContext: TimelineTestContext;
|
||||
changes: Changes;
|
||||
primaryAsset: TimelineAssetConfig;
|
||||
primaryAssetDto: AssetResponseDto;
|
||||
};
|
||||
|
||||
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
|
||||
const rng = new SeededRandom(seed);
|
||||
const testContext = new TimelineTestContext();
|
||||
|
||||
const fixture: AssetViewerTestFixture = {
|
||||
adminUserId: undefined!,
|
||||
timelineRestData: undefined!,
|
||||
assets: [],
|
||||
testContext,
|
||||
changes: {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
},
|
||||
primaryAsset: undefined!,
|
||||
primaryAssetDto: undefined!,
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.fail(
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||
);
|
||||
utils.initSdk();
|
||||
fixture.adminUserId = faker.string.uuid();
|
||||
testContext.adminId = fixture.adminUserId;
|
||||
fixture.timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: fixture.adminUserId,
|
||||
});
|
||||
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
|
||||
fixture.assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
fixture.primaryAsset = selectRandom(
|
||||
fixture.assets.filter((a) => a.isImage),
|
||||
rng,
|
||||
);
|
||||
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, fixture.adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
fixture.testContext.slowBucket = false;
|
||||
fixture.changes.albumAdditions = [];
|
||||
fixture.changes.assetDeletions = [];
|
||||
fixture.changes.assetArchivals = [];
|
||||
fixture.changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
return fixture;
|
||||
}
|
||||
|
||||
export async function ensureDetailPanelVisible(page: Page) {
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const isVisible = await page.locator('#detail-panel').isVisible();
|
||||
if (!isVisible) {
|
||||
await page.keyboard.press('i');
|
||||
await page.waitForSelector('#detail-panel');
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableTagsPreference(context: BrowserContext) {
|
||||
await context.route('**/users/me/preferences', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
albums: { defaultAssetOrder: 'desc' },
|
||||
folders: { enabled: false, sidebarWeb: false },
|
||||
memories: { enabled: true, duration: 5 },
|
||||
people: { enabled: true, sidebarWeb: false },
|
||||
sharedLinks: { enabled: true, sidebarWeb: false },
|
||||
ratings: { enabled: false },
|
||||
tags: { enabled: true, sidebarWeb: false },
|
||||
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
|
||||
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
|
||||
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
|
||||
cast: { gCastEnabled: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
@@ -54,7 +53,7 @@ test.describe('search gallery-viewer', () => {
|
||||
assets: {
|
||||
total: searchAssets.length,
|
||||
count: searchAssets.length,
|
||||
items: searchAssets.map((asset) => toAssetResponseDto(asset)),
|
||||
items: searchAssets,
|
||||
facets: [],
|
||||
nextPage: null,
|
||||
},
|
||||
|
||||
@@ -163,11 +163,13 @@ export const assetViewerUtils = {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||
const previewUrl = `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true`;
|
||||
await page
|
||||
.getByTestId('preview')
|
||||
.and(page.locator(`[src="${previewUrl}"]`))
|
||||
.or(page.locator(`video[poster="${previewUrl}"]`))
|
||||
.locator(
|
||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
||||
)
|
||||
.or(
|
||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
||||
)
|
||||
.waitFor();
|
||||
},
|
||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||
|
||||
+1
-1
@@ -17,6 +17,6 @@
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"include": ["src/**/*.ts", "vitest*.config.ts"],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
@@ -15,14 +14,15 @@ if (!skipDockerSetup) {
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'e2e:server',
|
||||
retry: process.env.CI ? 4 : 0,
|
||||
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
||||
globalSetup,
|
||||
testTimeout: 15_000,
|
||||
pool: 'threads',
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
@@ -15,14 +14,15 @@ if (!skipDockerSetup) {
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'e2e:maintenance',
|
||||
retry: process.env.CI ? 4 : 0,
|
||||
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
|
||||
globalSetup,
|
||||
testTimeout: 15_000,
|
||||
pool: 'threads',
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
|
||||
+4
-4
@@ -871,8 +871,8 @@
|
||||
"current_pin_code": "Current PIN code",
|
||||
"current_server_address": "Current server address",
|
||||
"custom_date": "Custom date",
|
||||
"custom_locale": "Custom locale",
|
||||
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
|
||||
"custom_locale": "Custom Locale",
|
||||
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
||||
"custom_url": "Custom URL",
|
||||
"cutoff_date_description": "Keep photos from the last…",
|
||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||
@@ -895,6 +895,8 @@
|
||||
"deduplication_criteria_2": "Count of EXIF data",
|
||||
"deduplication_info": "Deduplication Info",
|
||||
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
||||
"default_locale": "Default Locale",
|
||||
"default_locale_description": "Format dates and numbers based on your browser locale",
|
||||
"delete": "Delete",
|
||||
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
||||
"delete_action_prompt": "{count} deleted",
|
||||
@@ -2336,8 +2338,6 @@
|
||||
"url": "URL",
|
||||
"usage": "Usage",
|
||||
"use_biometric": "Use biometric",
|
||||
"use_browser_locale": "Use browser locale",
|
||||
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
||||
"use_current_connection": "Use current connection",
|
||||
"use_custom_date_range": "Use custom date range instead",
|
||||
"user": "User",
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@
|
||||
"version": "2.5.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different ."
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.7.4",
|
||||
|
||||
@@ -16,9 +16,9 @@ config_roots = [
|
||||
[tools]
|
||||
node = "24.13.1"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.30.3"
|
||||
terragrunt = "0.99.4"
|
||||
opentofu = "1.11.5"
|
||||
pnpm = "10.30.0"
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
java = "21.0.2"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.database.Cursor
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ext.SdkExtensions
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
@@ -79,22 +78,15 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||
}
|
||||
if (hasSpecialFormatColumn()) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
add(SPECIAL_FORMAT_COLUMN)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Fallback: read XMP from MediaStore to detect Motion Photos
|
||||
// only needed if SPECIAL_FORMAT column isn't available
|
||||
add(MediaStore.MediaColumns.XMP)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||
|
||||
// _special_format requires S Extensions 21+
|
||||
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
|
||||
private fun hasSpecialFormatColumn(): Boolean =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21
|
||||
}
|
||||
|
||||
protected fun getCursor(
|
||||
|
||||
@@ -3,21 +3,30 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
class SearchResult {
|
||||
final List<BaseAsset> assets;
|
||||
final double scrollOffset;
|
||||
final int? nextPage;
|
||||
|
||||
const SearchResult({required this.assets, this.nextPage});
|
||||
const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});
|
||||
|
||||
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
|
||||
return SearchResult(
|
||||
assets: assets ?? this.assets,
|
||||
nextPage: nextPage ?? this.nextPage,
|
||||
scrollOffset: scrollOffset ?? this.scrollOffset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
|
||||
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchResult other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.assets, assets) && other.nextPage == nextPage;
|
||||
return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assets.hashCode ^ nextPage.hashCode;
|
||||
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
|
||||
}
|
||||
|
||||
@@ -78,9 +78,6 @@ class TimelineFactory {
|
||||
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||
TimelineService(_timelineRepository.fromAssets(assets, type));
|
||||
|
||||
TimelineService fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin type) =>
|
||||
TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type));
|
||||
|
||||
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
||||
|
||||
@@ -115,7 +112,7 @@ class TimelineService {
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
_buffer = [];
|
||||
_buffer.clear();
|
||||
} else {
|
||||
final int offset;
|
||||
final int count;
|
||||
|
||||
@@ -33,27 +33,12 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
||||
);
|
||||
}
|
||||
|
||||
class SnapScrollController extends ScrollController {
|
||||
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
|
||||
|
||||
@override
|
||||
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) =>
|
||||
SnapScrollPosition(physics: physics, context: context, oldPosition: oldPosition);
|
||||
}
|
||||
|
||||
class SnapScrollPosition extends ScrollPositionWithSingleContext {
|
||||
double snapOffset;
|
||||
|
||||
SnapScrollPosition({required super.physics, required super.context, super.oldPosition, this.snapOffset = 0.0});
|
||||
|
||||
@override
|
||||
bool get shouldIgnorePointer => false;
|
||||
}
|
||||
|
||||
class SnapScrollPhysics extends ScrollPhysics {
|
||||
static const _minFlingVelocity = 700.0;
|
||||
static const minSnapDistance = 30.0;
|
||||
|
||||
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
|
||||
|
||||
const SnapScrollPhysics({super.parent});
|
||||
|
||||
@override
|
||||
@@ -81,21 +66,91 @@ class SnapScrollPhysics extends ScrollPhysics {
|
||||
}
|
||||
}
|
||||
|
||||
return ScrollSpringSimulation(spring, position.pixels, target(position, velocity, snapOffset), velocity);
|
||||
return ScrollSpringSimulation(
|
||||
_spring,
|
||||
position.pixels,
|
||||
target(position, velocity, snapOffset),
|
||||
velocity,
|
||||
tolerance: toleranceFor(position),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
SpringDescription get spring => SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
|
||||
|
||||
@override
|
||||
bool get allowImplicitScrolling => false;
|
||||
|
||||
@override
|
||||
bool get allowUserScrolling => false;
|
||||
|
||||
static double target(ScrollMetrics position, double velocity, double snapOffset) {
|
||||
if (velocity > _minFlingVelocity) return snapOffset;
|
||||
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
|
||||
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
|
||||
}
|
||||
}
|
||||
|
||||
class SnapScrollPosition extends ScrollPositionWithSingleContext {
|
||||
double snapOffset;
|
||||
|
||||
SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition});
|
||||
}
|
||||
|
||||
class ProxyScrollController extends ScrollController {
|
||||
final ScrollController scrollController;
|
||||
|
||||
ProxyScrollController({required this.scrollController});
|
||||
|
||||
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
|
||||
|
||||
@override
|
||||
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
|
||||
return ProxyScrollPosition(
|
||||
scrollController: scrollController,
|
||||
physics: physics,
|
||||
context: context,
|
||||
oldPosition: oldPosition,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ProxyScrollPosition extends SnapScrollPosition {
|
||||
final ScrollController scrollController;
|
||||
|
||||
ProxyScrollPosition({
|
||||
required this.scrollController,
|
||||
required super.physics,
|
||||
required super.context,
|
||||
super.oldPosition,
|
||||
});
|
||||
|
||||
@override
|
||||
double setPixels(double newPixels) {
|
||||
final overscroll = super.setPixels(newPixels);
|
||||
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
||||
scrollController.position.forcePixels(pixels);
|
||||
}
|
||||
return overscroll;
|
||||
}
|
||||
|
||||
@override
|
||||
void forcePixels(double value) {
|
||||
super.forcePixels(value);
|
||||
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
||||
scrollController.position.forcePixels(pixels);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
||||
? scrollController.position.maxScrollExtent
|
||||
: super.maxScrollExtent;
|
||||
|
||||
@override
|
||||
double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
||||
? scrollController.position.minScrollExtent
|
||||
: super.minScrollExtent;
|
||||
|
||||
@override
|
||||
double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension
|
||||
? scrollController.position.viewportDimension
|
||||
: super.viewportDimension;
|
||||
}
|
||||
|
||||
@@ -276,19 +276,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
origin: origin,
|
||||
);
|
||||
|
||||
TimelineQuery fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin origin) =>
|
||||
(
|
||||
bucketSource: () async* {
|
||||
yield _generateBuckets(getAssets().length);
|
||||
yield* assetCount.map(_generateBuckets);
|
||||
},
|
||||
assetSource: (offset, count) {
|
||||
final assets = getAssets();
|
||||
return Future.value(assets.skip(offset).take(count).toList(growable: false));
|
||||
},
|
||||
origin: origin,
|
||||
);
|
||||
|
||||
TimelineQuery fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin origin) {
|
||||
// Sort assets by date descending and group by day
|
||||
final sorted = List<BaseAsset>.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
@@ -366,6 +367,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
stackIndex.value = 0;
|
||||
|
||||
ref.read(currentAssetProvider.notifier).set(newAsset);
|
||||
if (newAsset.isVideo || newAsset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
|
||||
// Wait for page change animation to finish, then precache the next image
|
||||
Timer(const Duration(milliseconds: 400), () {
|
||||
|
||||
@@ -11,14 +11,18 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.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_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/asset.service.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
@@ -38,10 +42,18 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final videoId = asset.id.toString();
|
||||
final controller = useState<NativeVideoPlayerController?>(null);
|
||||
final lastVideoPosition = useRef(-1);
|
||||
final isBuffering = useRef(false);
|
||||
|
||||
// Used to track whether the video should play when the app
|
||||
// is brought back to the foreground
|
||||
final shouldPlayOnForeground = useRef(true);
|
||||
|
||||
// When a video is opened through the timeline, `isCurrent` will immediately be true.
|
||||
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
|
||||
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
|
||||
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
|
||||
final currentAsset = useState(ref.read(currentAssetProvider));
|
||||
final isCurrent = currentAsset.value == asset;
|
||||
|
||||
@@ -105,45 +117,127 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
void checkIfBuffering() {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoPlayback = ref.read(videoPlaybackValueProvider);
|
||||
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
|
||||
videoPlayback.state != VideoPlaybackState.buffering) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
|
||||
state: VideoPlaybackState.buffering,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Timer to mark videos as buffering if the position does not change
|
||||
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
||||
|
||||
// When the position changes, seek to the position
|
||||
// Debounce the seek to avoid seeking too often
|
||||
// But also don't delay the seek too much to maintain visual feedback
|
||||
final seekDebouncer = useDebouncer(
|
||||
interval: const Duration(milliseconds: 100),
|
||||
maxWaitTime: const Duration(milliseconds: 200),
|
||||
);
|
||||
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
|
||||
final playerController = controller.value;
|
||||
if (playerController == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final playbackInfo = playerController.playbackInfo;
|
||||
if (playbackInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final oldSeek = oldControls?.position.inMilliseconds;
|
||||
final newSeek = newControls.position.inMilliseconds;
|
||||
if (oldSeek != newSeek || newControls.restarted) {
|
||||
seekDebouncer.run(() => playerController.seekTo(newSeek));
|
||||
}
|
||||
|
||||
if (oldControls?.pause != newControls.pause || newControls.restarted) {
|
||||
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
|
||||
}
|
||||
});
|
||||
|
||||
void onPlaybackReady() async {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !isCurrent || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||
notifier.onNativePlaybackReady();
|
||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
|
||||
isVideoReady.value = true;
|
||||
|
||||
try {
|
||||
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
|
||||
if (autoPlayVideo) {
|
||||
await notifier.play();
|
||||
await videoController.play();
|
||||
}
|
||||
await notifier.setVolume(1);
|
||||
await videoController.setVolume(0.9);
|
||||
} catch (error) {
|
||||
log.severe('Error playing video: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackStatusChanged() {
|
||||
if (!context.mounted) return;
|
||||
ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged();
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
||||
if (videoPlayback.state == VideoPlaybackState.playing) {
|
||||
// Sync with the controls playing
|
||||
WakelockPlus.enable();
|
||||
} else {
|
||||
// Sync with the controls pause
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
|
||||
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state;
|
||||
}
|
||||
|
||||
void onPlaybackPositionChanged() {
|
||||
if (!context.mounted) return;
|
||||
ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged();
|
||||
// When seeking, these events sometimes move the slider to an older position
|
||||
if (seekDebouncer.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final playbackInfo = videoController.playbackInfo;
|
||||
if (playbackInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
|
||||
|
||||
// Check if the video is buffering
|
||||
if (playbackInfo.status == PlaybackStatus.playing) {
|
||||
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
|
||||
lastVideoPosition.value = playbackInfo.position;
|
||||
} else {
|
||||
isBuffering.value = false;
|
||||
lastVideoPosition.value = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackEnded() {
|
||||
if (!context.mounted) return;
|
||||
|
||||
ref.read(videoPlayerProvider(videoId).notifier).onNativePlaybackEnded();
|
||||
|
||||
final videoController = controller.value;
|
||||
if (videoController?.playbackInfo?.status == PlaybackStatus.stopped &&
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoController.playbackInfo?.status == PlaybackStatus.stopped &&
|
||||
!ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo)) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
}
|
||||
@@ -160,15 +254,14 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
if (controller.value != null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(videoPlayerControlsProvider.notifier).reset();
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
|
||||
final source = await videoSource;
|
||||
if (source == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||
notifier.attachController(nc);
|
||||
|
||||
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||
@@ -180,9 +273,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
}),
|
||||
);
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
await notifier.setLoop(loopVideo);
|
||||
unawaited(nc.setLoop(loopVideo));
|
||||
|
||||
controller.value = nc;
|
||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||
}
|
||||
|
||||
ref.listen(currentAssetProvider, (_, value) {
|
||||
@@ -206,6 +300,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
// Delay the video playback to avoid a stutter in the swipe animation
|
||||
// Note, in some circumstances a longer delay is needed (eg: memories),
|
||||
// the playbackDelayFactor can be used for this
|
||||
// This delay seems like a hacky way to resolve underlying bugs in video
|
||||
// playback, but other resolutions failed thus far
|
||||
Timer(
|
||||
Platform.isIOS
|
||||
? Duration(milliseconds: 300 * playbackDelayFactor)
|
||||
@@ -239,18 +337,19 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
playerController.stop().catchError((error) {
|
||||
log.fine('Error stopping video: $error');
|
||||
});
|
||||
|
||||
WakelockPlus.disable();
|
||||
};
|
||||
}, const []);
|
||||
|
||||
useOnAppLifecycleStateChange((_, state) async {
|
||||
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
||||
await notifier.play();
|
||||
await controller.value?.play();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
final videoPlaying = await controller.value?.isPlaying();
|
||||
if (videoPlaying ?? true) {
|
||||
shouldPlayOnForeground.value = true;
|
||||
await notifier.pause();
|
||||
await controller.value?.pause();
|
||||
} else {
|
||||
shouldPlayOnForeground.value = false;
|
||||
}
|
||||
@@ -275,8 +374,39 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showControls) Center(child: CustomVideoPlayerControls(videoId: videoId)),
|
||||
if (showControls) const Center(child: CustomVideoPlayerControls()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onPauseChange(
|
||||
BuildContext context,
|
||||
NativeVideoPlayerController controller,
|
||||
Debouncer seekDebouncer,
|
||||
bool isPaused,
|
||||
) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the last seek is complete before pausing or playing
|
||||
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
||||
if (seekDebouncer.isActive) {
|
||||
await seekDebouncer.drain();
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isPaused) {
|
||||
await controller.pause();
|
||||
} else {
|
||||
await controller.play();
|
||||
}
|
||||
} catch (error) {
|
||||
log.severe('Error pausing or playing video: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -12,8 +11,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
@@ -131,16 +129,11 @@ class _BottomPanelState extends State<_BottomPanel> {
|
||||
return;
|
||||
}
|
||||
|
||||
final db = Drift();
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
final file = File(path.join(dir.path, 'immich.sqlite$suffix'));
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return;
|
||||
await db.reset();
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||
import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart';
|
||||
@@ -165,6 +166,9 @@ class MemoryPage extends HookConsumerWidget {
|
||||
final asset = currentMemory.value.assets[otherIndex];
|
||||
currentAsset.value = asset;
|
||||
ref.read(currentAssetProvider.notifier).set(asset);
|
||||
if (asset.isVideo || asset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
|
||||
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||
|
||||
@@ -7,15 +7,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
|
||||
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
|
||||
|
||||
/// Expects the current asset to be set via [assetViewerProvider] before navigating to this page
|
||||
/// Expects [currentAssetNotifier] to be set before navigating to this page
|
||||
@RoutePage()
|
||||
class DriftMemoryPage extends HookConsumerWidget {
|
||||
final List<DriftMemory> memories;
|
||||
@@ -25,7 +26,11 @@ class DriftMemoryPage extends HookConsumerWidget {
|
||||
|
||||
static void setMemory(WidgetRef ref, DriftMemory memory) {
|
||||
if (memory.assets.isNotEmpty) {
|
||||
ref.read(assetViewerProvider.notifier).setAsset(memory.assets.first);
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first);
|
||||
|
||||
if (memory.assets.first.isVideo) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +172,11 @@ class DriftMemoryPage extends HookConsumerWidget {
|
||||
|
||||
final asset = currentMemory.value.assets[otherIndex];
|
||||
currentAsset.value = asset;
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
||||
// if (asset.isVideo || asset.isMotionPhoto) {
|
||||
if (asset.isVideo) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
|
||||
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||
@@ -264,12 +273,7 @@ class DriftMemoryPage extends HookConsumerWidget {
|
||||
children: [
|
||||
Container(
|
||||
color: Colors.black,
|
||||
child: DriftMemoryCard(
|
||||
asset: asset,
|
||||
title: title,
|
||||
showTitle: index == 0,
|
||||
isCurrent: mIndex == currentMemoryIndex.value && index == currentAssetPage.value,
|
||||
),
|
||||
child: DriftMemoryCard(asset: asset, title: title, showTitle: index == 0),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Row(
|
||||
|
||||
@@ -80,28 +80,51 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||
|
||||
final isSearching = useState(false);
|
||||
|
||||
final userPreferences = ref.watch(userMetadataPreferencesProvider);
|
||||
|
||||
searchFilter(SearchFilter filter) {
|
||||
SnackBar searchInfoSnackBar(String message) {
|
||||
return SnackBar(
|
||||
content: Text(message, style: context.textTheme.labelLarge),
|
||||
showCloseIcon: true,
|
||||
behavior: SnackBarBehavior.fixed,
|
||||
closeIconColor: context.colorScheme.onSurface,
|
||||
);
|
||||
}
|
||||
|
||||
searchFilter(SearchFilter filter) async {
|
||||
if (filter.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preFilter == null && filter == previousFilter.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(paginatedSearchProvider.notifier).clear();
|
||||
isSearching.value = true;
|
||||
ref.watch(paginatedSearchProvider.notifier).clear();
|
||||
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter);
|
||||
|
||||
if (filter.isEmpty) {
|
||||
previousFilter.value = null;
|
||||
return;
|
||||
if (!hasResult) {
|
||||
context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context)));
|
||||
}
|
||||
|
||||
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
|
||||
previousFilter.value = filter;
|
||||
isSearching.value = false;
|
||||
}
|
||||
|
||||
search() => searchFilter(filter.value);
|
||||
|
||||
loadMoreSearchResults() {
|
||||
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value));
|
||||
loadMoreSearchResult() async {
|
||||
isSearching.value = true;
|
||||
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
|
||||
|
||||
if (!hasResult) {
|
||||
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context)));
|
||||
}
|
||||
|
||||
isSearching.value = false;
|
||||
}
|
||||
|
||||
searchPreFilter() {
|
||||
@@ -719,10 +742,10 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (filter.value.isEmpty)
|
||||
const _SearchSuggestions()
|
||||
if (isSearching.value)
|
||||
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
|
||||
else
|
||||
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),
|
||||
_SearchResultGrid(onScrollEnd: loadMoreSearchResult),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -734,85 +757,45 @@ class _SearchResultGrid extends ConsumerWidget {
|
||||
|
||||
const _SearchResultGrid({required this.onScrollEnd});
|
||||
|
||||
bool _onScrollUpdateNotification(ScrollNotification notification) {
|
||||
final metrics = notification.metrics;
|
||||
|
||||
if (metrics.axis != Axis.vertical) return false;
|
||||
|
||||
final isBottomSheet = notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
|
||||
final remaining = metrics.maxScrollExtent - metrics.pixels;
|
||||
|
||||
if (remaining < metrics.viewportDimension && !isBottomSheet) {
|
||||
onScrollEnd();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget? _bottomWidget(BuildContext context, WidgetRef ref) {
|
||||
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
|
||||
|
||||
if (isLoading) {
|
||||
return const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null));
|
||||
|
||||
if (hasMore) return null;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'search_no_more_result'.t(context: context),
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty));
|
||||
final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
|
||||
final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets));
|
||||
|
||||
if (!hasAssets && !isLoading) {
|
||||
return const _SearchNoResults();
|
||||
if (assets.isEmpty) {
|
||||
return const _SearchEmptyContent();
|
||||
}
|
||||
|
||||
return NotificationListener<ScrollUpdateNotification>(
|
||||
onNotification: _onScrollUpdateNotification,
|
||||
return NotificationListener<ScrollEndNotification>(
|
||||
onNotification: (notification) {
|
||||
final isBottomSheetNotification =
|
||||
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
|
||||
|
||||
final metrics = notification.metrics;
|
||||
final isVerticalScroll = metrics.axis == Axis.vertical;
|
||||
|
||||
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) {
|
||||
onScrollEnd();
|
||||
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
child: SliverFillRemaining(
|
||||
child: ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith((ref) {
|
||||
final notifier = ref.read(paginatedSearchProvider.notifier);
|
||||
final service = ref
|
||||
.watch(timelineFactoryProvider)
|
||||
.fromAssetStream(
|
||||
() => ref.read(paginatedSearchProvider).assets,
|
||||
notifier.assetCount,
|
||||
TimelineOrigin.search,
|
||||
);
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
],
|
||||
child: Timeline(
|
||||
key: ValueKey(assets.length),
|
||||
groupBy: GroupAssetsBy.none,
|
||||
appBar: null,
|
||||
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
|
||||
snapToMonth: false,
|
||||
loadingWidget: const SizedBox.shrink(),
|
||||
bottomSliverWidget: _bottomWidget(context, ref),
|
||||
initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -820,35 +803,8 @@ class _SearchResultGrid extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchNoResults extends StatelessWidget {
|
||||
const _SearchNoResults();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(48),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.search_off_rounded, size: 72, color: context.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'search_no_result'.t(context: context),
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchSuggestions extends StatelessWidget {
|
||||
const _SearchSuggestions();
|
||||
class _SearchEmptyContent extends StatelessWidget {
|
||||
const _SearchEmptyContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/search_result.model.dart';
|
||||
import 'package:immich_mobile/domain/services/search.service.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
||||
@@ -23,52 +21,40 @@ class SearchFilterProvider extends Notifier<SearchFilter?> {
|
||||
}
|
||||
}
|
||||
|
||||
class SearchState {
|
||||
final List<BaseAsset> assets;
|
||||
final int? nextPage;
|
||||
final bool isLoading;
|
||||
|
||||
const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false});
|
||||
}
|
||||
|
||||
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
|
||||
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
|
||||
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
||||
);
|
||||
|
||||
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
|
||||
class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
|
||||
final SearchService _searchService;
|
||||
final _assetCountController = StreamController<int>.broadcast();
|
||||
|
||||
PaginatedSearchNotifier(this._searchService) : super(const SearchState());
|
||||
PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1));
|
||||
|
||||
Stream<int> get assetCount => _assetCountController.stream;
|
||||
|
||||
Future<void> search(SearchFilter filter) async {
|
||||
if (state.nextPage == null || state.isLoading) return;
|
||||
|
||||
state = SearchState(assets: state.assets, nextPage: state.nextPage, isLoading: true);
|
||||
Future<bool> search(SearchFilter filter) async {
|
||||
if (state.nextPage == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await _searchService.search(filter, state.nextPage!);
|
||||
|
||||
if (result == null) {
|
||||
state = SearchState(assets: state.assets, nextPage: state.nextPage);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
final assets = [...state.assets, ...result.assets];
|
||||
state = SearchState(assets: assets, nextPage: result.nextPage);
|
||||
state = SearchResult(
|
||||
assets: [...state.assets, ...result.assets],
|
||||
nextPage: result.nextPage,
|
||||
scrollOffset: state.scrollOffset,
|
||||
);
|
||||
|
||||
_assetCountController.add(assets.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = const SearchState();
|
||||
_assetCountController.add(0);
|
||||
void setScrollOffset(double offset) {
|
||||
state = state.copyWith(scrollOffset: offset);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_assetCountController.close();
|
||||
super.dispose();
|
||||
clear() {
|
||||
state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
@@ -49,7 +49,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
}
|
||||
|
||||
List<Widget> _buildMenuChildren() {
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) return [];
|
||||
|
||||
final user = ref.read(currentUserProvider);
|
||||
@@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
}
|
||||
|
||||
void _openAlbumSelector() {
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
if (currentAsset == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
return;
|
||||
@@ -133,7 +133,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
}
|
||||
|
||||
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
|
||||
final latest = ref.read(assetViewerProvider).currentAsset;
|
||||
final latest = ref.read(currentAssetNotifier);
|
||||
|
||||
if (latest == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
@@ -169,7 +169,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class EditImageActionButton extends ConsumerWidget {
|
||||
@@ -12,7 +12,7 @@ class EditImageActionButton extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
final currentAsset = ref.watch(currentAssetNotifier);
|
||||
|
||||
onPress() {
|
||||
if (currentAsset == null) {
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
@@ -20,7 +20,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentRemoteAlbumProvider);
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?;
|
||||
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class SimilarPhotosActionButton extends ConsumerWidget {
|
||||
|
||||
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -809,7 +809,7 @@ class CreateAlbumButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
|
||||
if (asset == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart';
|
||||
@@ -12,36 +11,34 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/te
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
|
||||
class AssetDetails extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final double minHeight;
|
||||
|
||||
const AssetDetails({super.key, required this.asset, required this.minHeight});
|
||||
const AssetDetails({required this.minHeight, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
|
||||
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const DragHandle(),
|
||||
DateTimeDetails(asset: asset, exifInfo: exifInfo),
|
||||
PeopleDetails(asset: asset),
|
||||
LocationDetails(asset: asset, exifInfo: exifInfo),
|
||||
TechnicalDetails(asset: asset, exifInfo: exifInfo),
|
||||
RatingDetails(exifInfo: exifInfo),
|
||||
AppearsInDetails(asset: asset),
|
||||
SizedBox(height: context.padding.bottom + 48),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const DragHandle(),
|
||||
const DateTimeDetails(),
|
||||
const PeopleDetails(),
|
||||
const LocationDetails(),
|
||||
const TechnicalDetails(),
|
||||
const RatingDetails(),
|
||||
const AppearsInDetails(),
|
||||
SizedBox(height: context.padding.bottom + 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+11
-9
@@ -8,25 +8,27 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class AppearsInDetails extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const AppearsInDetails({super.key, required this.asset});
|
||||
const AppearsInDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (!asset.hasRemote) return const SizedBox.shrink();
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
|
||||
|
||||
final remoteAssetId = switch (asset) {
|
||||
RemoteAsset(:final id) => id,
|
||||
LocalAsset(:final remoteAssetId) => remoteAssetId,
|
||||
};
|
||||
String? remoteAssetId;
|
||||
if (asset is RemoteAsset) {
|
||||
remoteAssetId = asset.id;
|
||||
} else if (asset is LocalAsset) {
|
||||
remoteAssetId = asset.remoteAssetId;
|
||||
}
|
||||
|
||||
if (remoteAssetId == null) return const SizedBox.shrink();
|
||||
|
||||
|
||||
+10
-8
@@ -10,6 +10,7 @@ import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -17,15 +18,14 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class DateTimeDetails extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const DateTimeDetails({super.key, required this.asset, this.exifInfo});
|
||||
const DateTimeDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = this.asset;
|
||||
final exifInfo = this.exifInfo;
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
|
||||
return Column(
|
||||
@@ -106,7 +106,9 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentDescription = widget.exif.description ?? '';
|
||||
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
final currentDescription = currentExifInfo?.description ?? '';
|
||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
||||
context: context,
|
||||
);
|
||||
@@ -132,7 +134,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
),
|
||||
onTapOutside: (_) => saveDescription(widget.exif.description),
|
||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+14
-14
@@ -8,14 +8,12 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class LocationDetails extends ConsumerStatefulWidget {
|
||||
final BaseAsset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const LocationDetails({super.key, required this.asset, this.exifInfo});
|
||||
const LocationDetails({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _LocationDetailsState();
|
||||
@@ -42,25 +40,27 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
_mapController = controller;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LocationDetails oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.exifInfo != oldWidget.exifInfo) {
|
||||
final exif = widget.exifInfo;
|
||||
if (exif != null && exif.hasCoordinates) {
|
||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exif.latitude!, exif.longitude!)));
|
||||
}
|
||||
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
||||
final currentExif = current.valueOrNull;
|
||||
if (currentExif != null && currentExif.hasCoordinates) {
|
||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
|
||||
}
|
||||
|
||||
void editLocation() async {
|
||||
await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = widget.asset;
|
||||
final exifInfo = widget.exifInfo;
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||
|
||||
// Guard local assets
|
||||
|
||||
+10
-6
@@ -7,6 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
@@ -14,14 +15,17 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/utils/people.utils.dart';
|
||||
|
||||
class PeopleDetails extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const PeopleDetails({super.key, required this.asset});
|
||||
class PeopleDetails extends ConsumerStatefulWidget {
|
||||
const PeopleDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = this.asset;
|
||||
ConsumerState createState() => _PeopleDetailsState();
|
||||
}
|
||||
|
||||
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset is! RemoteAsset) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,18 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
|
||||
class RatingDetails extends ConsumerWidget {
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const RatingDetails({super.key, this.exifInfo});
|
||||
const RatingDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -22,6 +20,8 @@ class RatingDetails extends ConsumerWidget {
|
||||
|
||||
if (!isRatingEnabled) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: Column(
|
||||
|
||||
+6
-5
@@ -6,20 +6,21 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class TechnicalDetails extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const TechnicalDetails({super.key, required this.asset, this.exifInfo});
|
||||
const TechnicalDetails({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final exifInfo = this.exifInfo;
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
|
||||
|
||||
@@ -12,15 +12,16 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.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/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.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/photo_view/photo_view.dart';
|
||||
@@ -50,7 +51,10 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
bool _showingDetails = false;
|
||||
bool _isZoomed = false;
|
||||
|
||||
final _scrollController = SnapScrollController();
|
||||
final _scrollController = ScrollController();
|
||||
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
||||
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
|
||||
|
||||
double _snapOffset = 0.0;
|
||||
|
||||
DragStartDetails? _dragStart;
|
||||
@@ -62,19 +66,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
super.initState();
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
_scrollController.snapPosition.snapOffset = _snapOffset;
|
||||
if (!mounted || !_proxyScrollController.hasClients) return;
|
||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
||||
if (_showingDetails && _snapOffset > 0) {
|
||||
_scrollController.jumpTo(_snapOffset);
|
||||
_proxyScrollController.jumpTo(_snapOffset);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_proxyScrollController.dispose();
|
||||
_scaleBoundarySub?.cancel();
|
||||
_eventSubscription?.cancel();
|
||||
_videoScaleStateNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -87,20 +92,21 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
}
|
||||
|
||||
void _showDetails() {
|
||||
if (!_scrollController.hasClients || _snapOffset <= 0) return;
|
||||
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
|
||||
_viewer.setShowingDetails(true);
|
||||
_scrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
|
||||
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
bool _willClose(double scrollVelocity) =>
|
||||
_scrollController.hasClients &&
|
||||
_snapOffset > 0 &&
|
||||
_scrollController.position.pixels < _snapOffset &&
|
||||
SnapScrollPhysics.target(_scrollController.position, scrollVelocity, _snapOffset) <
|
||||
SnapScrollPhysics.minSnapDistance;
|
||||
bool _willClose(double scrollVelocity) {
|
||||
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false;
|
||||
|
||||
final position = _proxyScrollController.position;
|
||||
return _proxyScrollController.position.pixels < _snapOffset &&
|
||||
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
|
||||
}
|
||||
|
||||
void _syncShowingDetails() {
|
||||
final offset = _scrollController.offset;
|
||||
final offset = _proxyScrollController.offset;
|
||||
if (offset > SnapScrollPhysics.minSnapDistance) {
|
||||
_viewer.setShowingDetails(true);
|
||||
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
|
||||
@@ -122,8 +128,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
}
|
||||
|
||||
void _startProxyDrag() {
|
||||
if (_scrollController.hasClients && _dragStart != null) {
|
||||
_drag = _scrollController.position.drag(_dragStart!, () => _drag = null);
|
||||
if (_proxyScrollController.hasClients && _dragStart != null) {
|
||||
_drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,16 +249,17 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
|
||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||
_isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering;
|
||||
_isZoomed =
|
||||
scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.covering ||
|
||||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
|
||||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
|
||||
_viewer.setZoomed(_isZoomed);
|
||||
|
||||
if (scaleState != PhotoViewScaleState.initial) {
|
||||
if (_dragStart == null) _viewer.setControls(false);
|
||||
|
||||
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
|
||||
if (heroTag != null) {
|
||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||
}
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -287,20 +294,22 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_listenForScaleBoundaries(controller);
|
||||
}
|
||||
|
||||
Widget _buildPhotoView({
|
||||
required BaseAsset asset,
|
||||
required PhotoViewHeroAttributes? heroAttributes,
|
||||
required bool isCurrent,
|
||||
Widget _buildPhotoView(
|
||||
BaseAsset displayAsset,
|
||||
BaseAsset asset, {
|
||||
required bool isCurrentPage,
|
||||
required bool showingDetails,
|
||||
required bool isPlayingMotionVideo,
|
||||
required BoxDecoration backgroundDecoration,
|
||||
}) {
|
||||
final size = context.sizeData;
|
||||
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
|
||||
|
||||
if (asset.isImage && !isPlayingMotionVideo) {
|
||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||
final size = context.sizeData;
|
||||
return PhotoView(
|
||||
key: Key(asset.heroTag),
|
||||
key: Key(displayAsset.heroTag),
|
||||
index: widget.index,
|
||||
imageProvider: getFullImageProvider(asset, size: size),
|
||||
imageProvider: getFullImageProvider(displayAsset, size: size),
|
||||
heroAttributes: heroAttributes,
|
||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
@@ -308,7 +317,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
enablePanAlways: true,
|
||||
disableScaleGestures: _showingDetails,
|
||||
disableScaleGestures: showingDetails,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
onDragStart: _onDragStart,
|
||||
@@ -316,30 +325,25 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
onTapUp: _onTapUp,
|
||||
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
|
||||
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null,
|
||||
errorBuilder: (_, __, ___) => SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
|
||||
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return PhotoView.customChild(
|
||||
key: Key(asset.heroTag),
|
||||
childSize: asset.width != null && asset.height != null
|
||||
? Size(asset.width!.toDouble(), asset.height!.toDouble())
|
||||
: null,
|
||||
key: Key(displayAsset.heroTag),
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
onTapUp: _onTapUp,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
heroAttributes: heroAttributes,
|
||||
filterQuality: FilterQuality.high,
|
||||
basePosition: Alignment.center,
|
||||
disableScaleGestures: _showingDetails,
|
||||
disableScaleGestures: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
tightMode: true,
|
||||
@@ -347,11 +351,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
enablePanAlways: true,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
child: NativeVideoViewer(
|
||||
key: _NativeVideoViewerKey(asset.heroTag),
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
||||
asset: displayAsset,
|
||||
scaleStateNotifier: _videoScaleStateNotifier,
|
||||
disableScaleGestures: showingDetails,
|
||||
image: Image(
|
||||
image: getFullImageProvider(asset, size: size),
|
||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
@@ -377,8 +384,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
displayAsset = stackChildren.elementAt(stackIndex);
|
||||
}
|
||||
|
||||
final isCurrent = currentHeroTag == displayAsset.heroTag;
|
||||
|
||||
final viewportWidth = MediaQuery.widthOf(context);
|
||||
final viewportHeight = MediaQuery.heightOf(context);
|
||||
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
|
||||
@@ -388,60 +393,69 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
|
||||
_snapOffset = detailsOffset - snapTarget;
|
||||
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.snapPosition.snapOffset = _snapOffset;
|
||||
if (_proxyScrollController.hasClients) {
|
||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const SnapScrollPhysics(),
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
child: _buildPhotoView(
|
||||
asset: displayAsset,
|
||||
heroAttributes: isCurrent
|
||||
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
|
||||
: null,
|
||||
isCurrent: isCurrent,
|
||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !_showingDetails,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: detailsOffset),
|
||||
GestureDetector(
|
||||
onVerticalDragStart: _beginDrag,
|
||||
onVerticalDragUpdate: _updateDrag,
|
||||
onVerticalDragEnd: _endDrag,
|
||||
onVerticalDragCancel: _onDragCancel,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showingDetails ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (stackChildren != null && stackChildren.isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: context.padding.bottom,
|
||||
child: AssetStackRow(stack: stackChildren),
|
||||
),
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)),
|
||||
currentAssetExifProvider.overrideWith((ref) {
|
||||
final a = ref.watch(currentAssetNotifier);
|
||||
if (a == null) return Future.value(null);
|
||||
return ref.watch(assetServiceProvider).getExif(a);
|
||||
}),
|
||||
],
|
||||
child: Stack(
|
||||
children: [
|
||||
Offstage(
|
||||
child: SingleChildScrollView(
|
||||
controller: _proxyScrollController,
|
||||
physics: const SnapScrollPhysics(),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
child: _buildPhotoView(
|
||||
displayAsset,
|
||||
asset,
|
||||
isCurrentPage: currentHeroTag == asset.heroTag,
|
||||
showingDetails: _showingDetails,
|
||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !_showingDetails,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: detailsOffset),
|
||||
GestureDetector(
|
||||
onVerticalDragStart: _beginDrag,
|
||||
onVerticalDragUpdate: _updateDrag,
|
||||
onVerticalDragEnd: _endDrag,
|
||||
onVerticalDragCancel: _onDragCancel,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showingDetails ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: AssetDetails(minHeight: viewportHeight - snapTarget),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,53 @@
|
||||
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/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
|
||||
class AssetStackRow extends ConsumerWidget {
|
||||
final List<RemoteAsset> stack;
|
||||
|
||||
const AssetStackRow({super.key, required this.stack});
|
||||
const AssetStackRow({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (stack.isEmpty) {
|
||||
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
|
||||
if (stackChildren == null || stackChildren.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 1.0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: Durations.short2,
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 5.0,
|
||||
children: List.generate(stack.length, (i) {
|
||||
final asset = stack[i];
|
||||
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
|
||||
}),
|
||||
),
|
||||
),
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
if (showingDetails) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _StackList(stack: stackChildren);
|
||||
}
|
||||
}
|
||||
|
||||
class _StackList extends ConsumerWidget {
|
||||
final List<RemoteAsset> stack;
|
||||
|
||||
const _StackList({required this.stack});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 5.0,
|
||||
children: List.generate(stack.length, (i) {
|
||||
final asset = stack[i];
|
||||
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -56,9 +67,8 @@ class _StackItem extends ConsumerStatefulWidget {
|
||||
|
||||
class _StackItemState extends ConsumerState<_StackItem> {
|
||||
void _onTap() {
|
||||
final notifier = ref.read(assetViewerProvider.notifier);
|
||||
notifier.setAsset(widget.asset);
|
||||
notifier.setStackIndex(widget.index);
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
|
||||
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -17,10 +17,13 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_statu
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
@@ -69,7 +72,15 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
// Always holds the current asset from the timeline
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
// The currentAssetNotifier actually holds the current asset that is displayed
|
||||
// which could be stack children as well
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
||||
if (asset.isVideo || asset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
}
|
||||
// Hide controls by default for videos
|
||||
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
@@ -80,8 +91,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
late final _pageController = PageController(initialPage: widget.initialIndex);
|
||||
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
||||
|
||||
late int _currentPage = widget.initialIndex;
|
||||
|
||||
StreamSubscription? _reloadSubscription;
|
||||
KeepAliveLink? _stackChildrenKeepAlive;
|
||||
|
||||
@@ -93,9 +102,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final target = page + direction;
|
||||
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
|
||||
if (target >= 0 && target <= maxPage) {
|
||||
_currentPage = target;
|
||||
_pageController.jumpToPage(target);
|
||||
_onAssetChanged(target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +110,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
|
||||
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
|
||||
@@ -127,26 +134,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// The normal onPageChange callback listens to OnScrollUpdate events, and will
|
||||
// round the current page and update whenever that value changes. In practise,
|
||||
// this means that the page will change when swiped half way, and may flip
|
||||
// whilst dragging.
|
||||
//
|
||||
// Changing the page at the end of a scroll should be more robust, and allow
|
||||
// the page to be dragged more than half way whilst keeping the current video
|
||||
// playing, and preventing the video on the next page from becoming ready
|
||||
// unnecessarily.
|
||||
bool _onScrollEnd(ScrollEndNotification notification) {
|
||||
if (notification.depth != 0) return false;
|
||||
|
||||
final page = _pageController.page?.round();
|
||||
if (page != null && page != _currentPage) {
|
||||
_currentPage = page;
|
||||
_onAssetChanged(page);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _onAssetInit(Duration timeStamp) {
|
||||
_preloader.preload(widget.initialIndex, context.sizeData);
|
||||
_handleCasting();
|
||||
@@ -166,7 +153,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
void _handleCasting() {
|
||||
if (!ref.read(castProvider).isCasting) return;
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) return;
|
||||
|
||||
if (asset is RemoteAsset) {
|
||||
@@ -208,19 +195,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
var index = _pageController.page?.round() ?? 0;
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
if (currentAsset != null) {
|
||||
final newIndex = timelineService.getIndex(currentAsset.heroTag);
|
||||
if (newIndex != null && newIndex != index) {
|
||||
index = newIndex;
|
||||
_currentPage = index;
|
||||
_pageController.jumpToPage(index);
|
||||
}
|
||||
}
|
||||
|
||||
if (index >= totalAssets) {
|
||||
index = totalAssets - 1;
|
||||
_currentPage = index;
|
||||
_pageController.jumpToPage(index);
|
||||
}
|
||||
|
||||
@@ -236,7 +221,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final newAsset = await timelineService.getAssetAsync(index);
|
||||
if (newAsset == null) return;
|
||||
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
|
||||
// Do not reload if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
||||
@@ -273,26 +258,25 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
_setSystemUIMode(controls, details);
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: IgnorePointer(
|
||||
ignoring: !showingControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showingControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
|
||||
child: Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: IgnorePointer(
|
||||
ignoring: !showingControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showingControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollEndNotification>(
|
||||
onNotification: _onScrollEnd,
|
||||
child: PhotoViewGestureDetectorScope(
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
@@ -302,20 +286,21 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
? const FastScrollPhysics()
|
||||
: const FastClampingScrollPhysics(),
|
||||
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
||||
onPageChanged: (index) => _onAssetChanged(index),
|
||||
itemBuilder: (context, index) =>
|
||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+6
-22
@@ -1,7 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
class AssetViewerState {
|
||||
@@ -70,12 +68,6 @@ class AssetViewerState {
|
||||
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
@override
|
||||
AssetViewerState build() {
|
||||
ref.listen(_watchedCurrentAssetProvider, (_, next) {
|
||||
final updated = next.valueOrNull;
|
||||
if (updated != null) {
|
||||
state = state.copyWith(currentAsset: updated);
|
||||
}
|
||||
});
|
||||
return const AssetViewerState();
|
||||
}
|
||||
|
||||
@@ -83,8 +75,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
state = const AssetViewerState();
|
||||
}
|
||||
|
||||
void setAsset(BaseAsset asset) {
|
||||
if (asset == state.currentAsset) return;
|
||||
void setAsset(BaseAsset? asset) {
|
||||
if (asset == state.currentAsset) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||
}
|
||||
|
||||
@@ -101,10 +95,7 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
}
|
||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||
if (showing) {
|
||||
final heroTag = state.currentAsset?.heroTag;
|
||||
if (heroTag != null) {
|
||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||
}
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +126,3 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
}
|
||||
|
||||
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||
|
||||
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
|
||||
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (asset == null) return const Stream.empty();
|
||||
return ref.read(assetServiceProvider).watchAsset(asset);
|
||||
});
|
||||
@@ -9,7 +9,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -20,7 +21,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -64,9 +65,9 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
|
||||
@@ -1,233 +1,469 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.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/presentation/widgets/asset_viewer/asset_viewer.state.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/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_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||
bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
|
||||
if (asset is RemoteAsset) {
|
||||
return switch (currentAsset) {
|
||||
RemoteAsset remoteAsset => remoteAsset.id == asset.id,
|
||||
LocalAsset localAsset => localAsset.remoteId == asset.id,
|
||||
_ => false,
|
||||
};
|
||||
} else if (asset is LocalAsset) {
|
||||
return switch (currentAsset) {
|
||||
RemoteAsset remoteAsset => remoteAsset.localId == asset.id,
|
||||
LocalAsset localAsset => localAsset.id == asset.id,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class NativeVideoViewer extends HookConsumerWidget {
|
||||
static final log = Logger('NativeVideoViewer');
|
||||
final BaseAsset asset;
|
||||
final bool isCurrent;
|
||||
final bool showControls;
|
||||
final int playbackDelayFactor;
|
||||
final Widget image;
|
||||
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
|
||||
final bool disableScaleGestures;
|
||||
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.image,
|
||||
this.isCurrent = false,
|
||||
this.showControls = true,
|
||||
this.playbackDelayFactor = 1,
|
||||
this.scaleStateNotifier,
|
||||
this.disableScaleGestures = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<NativeVideoViewer> createState() => _NativeVideoViewerState();
|
||||
}
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useState<NativeVideoPlayerController?>(null);
|
||||
final lastVideoPosition = useRef(-1);
|
||||
final isBuffering = useRef(false);
|
||||
|
||||
class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
|
||||
static final _log = Logger('NativeVideoViewer');
|
||||
// Used to track whether the video should play when the app
|
||||
// is brought back to the foreground
|
||||
final shouldPlayOnForeground = useRef(true);
|
||||
|
||||
NativeVideoPlayerController? _controller;
|
||||
late final Future<VideoSource?> _videoSource;
|
||||
Timer? _loadTimer;
|
||||
bool _isVideoReady = false;
|
||||
bool _shouldPlayOnForeground = true;
|
||||
// When a video is opened through the timeline, `isCurrent` will immediately be true.
|
||||
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
|
||||
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
|
||||
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
|
||||
final currentAsset = useState(ref.read(currentAssetNotifier));
|
||||
final isCurrent = _isCurrentAsset(asset, currentAsset.value);
|
||||
|
||||
VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_videoSource = _createSource();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(NativeVideoViewer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return;
|
||||
|
||||
if (!widget.isCurrent) {
|
||||
_loadTimer?.cancel();
|
||||
_notifier.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent unnecessary loading when swiping between assets.
|
||||
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_loadTimer?.cancel();
|
||||
_removeListeners();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) async {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
if (_shouldPlayOnForeground) await _notifier.play();
|
||||
case AppLifecycleState.paused:
|
||||
_shouldPlayOnForeground = await _controller?.isPlaying() ?? true;
|
||||
if (_shouldPlayOnForeground) await _notifier.pause();
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
Future<VideoSource?> _createSource() async {
|
||||
if (!mounted) return null;
|
||||
|
||||
final videoAsset = await ref.read(assetServiceProvider).getAsset(widget.asset) ?? widget.asset;
|
||||
if (!mounted) return null;
|
||||
|
||||
try {
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
if (!mounted) return null;
|
||||
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
|
||||
// Pass a file:// URI so Android's Uri.parse doesn't
|
||||
// interpret characters like '#' as fragment identifiers.
|
||||
return VideoSource.init(
|
||||
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
||||
type: VideoSourceType.file,
|
||||
);
|
||||
}
|
||||
|
||||
final remoteId = (videoAsset as RemoteAsset).id;
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||
|
||||
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
|
||||
} catch (error) {
|
||||
_log.severe('Error creating video source for asset ${videoAsset.name}: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPlaybackReady() async {
|
||||
if (!mounted || !widget.isCurrent) return;
|
||||
|
||||
_notifier.onNativePlaybackReady();
|
||||
|
||||
// onPlaybackReady may be called multiple times, usually when more data
|
||||
// loads. If this is not the first time that the player has become ready, we
|
||||
// should not autoplay.
|
||||
if (_isVideoReady) return;
|
||||
|
||||
setState(() => _isVideoReady = true);
|
||||
|
||||
if (ref.read(assetViewerProvider).showingDetails) return;
|
||||
|
||||
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
|
||||
if (autoPlayVideo) await _notifier.play();
|
||||
}
|
||||
|
||||
void _onPlaybackEnded() {
|
||||
if (!mounted) return;
|
||||
|
||||
_notifier.onNativePlaybackEnded();
|
||||
|
||||
if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPlaybackPositionChanged() {
|
||||
if (!mounted) return;
|
||||
_notifier.onNativePositionChanged();
|
||||
}
|
||||
|
||||
void _onPlaybackStatusChanged() {
|
||||
if (!mounted) return;
|
||||
_notifier.onNativeStatusChanged();
|
||||
}
|
||||
|
||||
void _removeListeners() {
|
||||
_controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged);
|
||||
_controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged);
|
||||
_controller?.onPlaybackReady.removeListener(_onPlaybackReady);
|
||||
_controller?.onPlaybackEnded.removeListener(_onPlaybackEnded);
|
||||
}
|
||||
|
||||
void _loadVideo() async {
|
||||
final nc = _controller;
|
||||
if (nc == null || nc.videoSource != null || !mounted) return;
|
||||
|
||||
final source = await _videoSource;
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
unawaited(
|
||||
nc.loadVideoSource(source).catchError((error) {
|
||||
_log.severe('Error loading video source: $error');
|
||||
}),
|
||||
);
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
||||
await _notifier.setVolume(1);
|
||||
}
|
||||
|
||||
void _initController(NativeVideoPlayerController nc) {
|
||||
if (_controller != null || !mounted) return;
|
||||
|
||||
_notifier.attachController(nc);
|
||||
|
||||
nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged);
|
||||
nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged);
|
||||
nc.onPlaybackReady.addListener(_onPlaybackReady);
|
||||
nc.onPlaybackEnded.addListener(_onPlaybackEnded);
|
||||
|
||||
_controller = nc;
|
||||
|
||||
if (widget.isCurrent) _loadVideo();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Prevent the provider from being disposed whilst the widget is alive.
|
||||
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
|
||||
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
|
||||
final isVisible = useState(Platform.isIOS && asset.hasLocal);
|
||||
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Center(child: widget.image),
|
||||
if (!isCasting)
|
||||
Visibility.maintain(
|
||||
visible: _isVideoReady,
|
||||
child: NativeVideoPlayerView(onViewReady: _initController),
|
||||
),
|
||||
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
|
||||
],
|
||||
Future<VideoSource?> createSource() async {
|
||||
if (!context.mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset;
|
||||
if (!context.mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
if (!context.mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
|
||||
// Pass a file:// URI so Android's Uri.parse doesn't
|
||||
// interpret characters like '#' as fragment identifiers.
|
||||
final source = await VideoSource.init(
|
||||
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
||||
type: VideoSourceType.file,
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
final remoteId = (videoAsset as RemoteAsset).id;
|
||||
|
||||
// Use a network URL for the video player controller
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||
|
||||
final source = await VideoSource.init(
|
||||
path: videoUrl,
|
||||
type: VideoSourceType.network,
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
);
|
||||
return source;
|
||||
} catch (error) {
|
||||
log.severe('Error creating video source for asset ${videoAsset.name}: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||
final aspectRatio = useState<double?>(null);
|
||||
|
||||
useMemoized(() async {
|
||||
if (!context.mounted || aspectRatio.value != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset);
|
||||
} catch (error) {
|
||||
log.severe('Error getting aspect ratio for asset ${asset.name}: $error');
|
||||
}
|
||||
}, [asset.heroTag]);
|
||||
|
||||
void checkIfBuffering() {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoPlayback = ref.read(videoPlaybackValueProvider);
|
||||
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
|
||||
videoPlayback.state != VideoPlaybackState.buffering) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
|
||||
state: VideoPlaybackState.buffering,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Timer to mark videos as buffering if the position does not change
|
||||
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
||||
|
||||
// When the position changes, seek to the position
|
||||
// Debounce the seek to avoid seeking too often
|
||||
// But also don't delay the seek too much to maintain visual feedback
|
||||
final seekDebouncer = useDebouncer(
|
||||
interval: const Duration(milliseconds: 100),
|
||||
maxWaitTime: const Duration(milliseconds: 200),
|
||||
);
|
||||
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
|
||||
final playerController = controller.value;
|
||||
if (playerController == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final playbackInfo = playerController.playbackInfo;
|
||||
if (playbackInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final oldSeek = oldControls?.position.inMilliseconds;
|
||||
final newSeek = newControls.position.inMilliseconds;
|
||||
if (oldSeek != newSeek || newControls.restarted) {
|
||||
seekDebouncer.run(() => playerController.seekTo(newSeek));
|
||||
}
|
||||
|
||||
if (oldControls?.pause != newControls.pause || newControls.restarted) {
|
||||
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
|
||||
}
|
||||
});
|
||||
|
||||
void onPlaybackReady() async {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !isCurrent || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
|
||||
if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
|
||||
if (autoPlayVideo) {
|
||||
await videoController.play();
|
||||
}
|
||||
await videoController.setVolume(0.9);
|
||||
} catch (error) {
|
||||
log.severe('Error playing video: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackStatusChanged() {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
||||
if (videoPlayback.state == VideoPlaybackState.playing) {
|
||||
// Sync with the controls playing
|
||||
WakelockPlus.enable();
|
||||
} else {
|
||||
// Sync with the controls pause
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
|
||||
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state;
|
||||
}
|
||||
|
||||
void onPlaybackPositionChanged() {
|
||||
// When seeking, these events sometimes move the slider to an older position
|
||||
if (seekDebouncer.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final playbackInfo = videoController.playbackInfo;
|
||||
if (playbackInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
|
||||
|
||||
// Check if the video is buffering
|
||||
if (playbackInfo.status == PlaybackStatus.playing) {
|
||||
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
|
||||
lastVideoPosition.value = playbackInfo.position;
|
||||
} else {
|
||||
isBuffering.value = false;
|
||||
lastVideoPosition.value = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackEnded() {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoController.playbackInfo?.status == PlaybackStatus.stopped) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
}
|
||||
}
|
||||
|
||||
void removeListeners(NativeVideoPlayerController controller) {
|
||||
controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged);
|
||||
controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged);
|
||||
controller.onPlaybackReady.removeListener(onPlaybackReady);
|
||||
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
|
||||
}
|
||||
|
||||
void initController(NativeVideoPlayerController nc) async {
|
||||
if (controller.value != null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(videoPlayerControlsProvider.notifier).reset();
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
|
||||
final source = await videoSource;
|
||||
if (source == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
||||
|
||||
unawaited(
|
||||
nc.loadVideoSource(source).catchError((error) {
|
||||
log.severe('Error loading video source: $error');
|
||||
}),
|
||||
);
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo));
|
||||
|
||||
controller.value = nc;
|
||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||
}
|
||||
|
||||
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) {
|
||||
Size? videoContextSize;
|
||||
if (videoAspectRatio == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
final contextAspectRatio = context.width / context.height;
|
||||
if (videoAspectRatio > contextAspectRatio) {
|
||||
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
|
||||
} else {
|
||||
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
|
||||
}
|
||||
return videoContextSize;
|
||||
}
|
||||
|
||||
ref.listen(currentAssetNotifier, (_, value) {
|
||||
final playerController = controller.value;
|
||||
if (playerController != null && value != asset) {
|
||||
removeListeners(playerController);
|
||||
}
|
||||
|
||||
if (value != null) {
|
||||
isVisible.value = _isCurrentAsset(value, asset);
|
||||
}
|
||||
final curAsset = currentAsset.value;
|
||||
if (curAsset == asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
final imageToVideo = curAsset != null && !curAsset.isVideo;
|
||||
|
||||
// No need to delay video playback when swiping from an image to a video
|
||||
if (imageToVideo && Platform.isIOS) {
|
||||
currentAsset.value = value;
|
||||
onPlaybackReady();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delay the video playback to avoid a stutter in the swipe animation
|
||||
// Note, in some circumstances a longer delay is needed (eg: memories),
|
||||
// the playbackDelayFactor can be used for this
|
||||
// This delay seems like a hacky way to resolve underlying bugs in video
|
||||
// playback, but other resolutions failed thus far
|
||||
Timer(
|
||||
Platform.isIOS
|
||||
? Duration(milliseconds: 300 * playbackDelayFactor)
|
||||
: imageToVideo
|
||||
? Duration(milliseconds: 200 * playbackDelayFactor)
|
||||
: Duration(milliseconds: 400 * playbackDelayFactor),
|
||||
() {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentAsset.value = value;
|
||||
if (currentAsset.value == asset) {
|
||||
onPlaybackReady();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() {
|
||||
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
|
||||
final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true);
|
||||
|
||||
return () {
|
||||
timer?.cancel();
|
||||
final playerController = controller.value;
|
||||
if (playerController == null) {
|
||||
return;
|
||||
}
|
||||
removeListeners(playerController);
|
||||
playerController.stop().catchError((error) {
|
||||
log.fine('Error stopping video: $error');
|
||||
});
|
||||
|
||||
WakelockPlus.disable();
|
||||
};
|
||||
}, const []);
|
||||
|
||||
useOnAppLifecycleStateChange((_, state) async {
|
||||
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
||||
await controller.value?.play();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
final videoPlaying = await controller.value?.isPlaying();
|
||||
if (videoPlaying ?? true) {
|
||||
shouldPlayOnForeground.value = true;
|
||||
await controller.value?.pause();
|
||||
} else {
|
||||
shouldPlayOnForeground.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return SizedBox(
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
|
||||
if (!isVisible.value || controller.value == null) Center(child: image),
|
||||
if (aspectRatio.value != null && !isCasting && isCurrent)
|
||||
Visibility.maintain(
|
||||
visible: isVisible.value,
|
||||
child: PhotoView.customChild(
|
||||
enableRotation: false,
|
||||
disableScaleGestures: disableScaleGestures,
|
||||
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
|
||||
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
|
||||
childSize: videoContextSize(aspectRatio.value, context),
|
||||
child: NativeVideoPlayerView(onViewReady: initController),
|
||||
),
|
||||
),
|
||||
if (showControls) const Center(child: VideoViewerControls()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onPauseChange(
|
||||
BuildContext context,
|
||||
NativeVideoPlayerController controller,
|
||||
Debouncer seekDebouncer,
|
||||
bool isPaused,
|
||||
) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the last seek is complete before pausing or playing
|
||||
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
||||
if (seekDebouncer.isActive) {
|
||||
await seekDebouncer.drain();
|
||||
}
|
||||
|
||||
try {
|
||||
if (isPaused) {
|
||||
await controller.pause();
|
||||
} else {
|
||||
await controller.play();
|
||||
}
|
||||
} catch (error) {
|
||||
log.severe('Error pausing or playing video: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.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)});
|
||||
const VideoViewerControls({super.key, 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 assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
|
||||
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
if (showingDetails) {
|
||||
showControls = false;
|
||||
}
|
||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||
|
||||
final cast = ref.watch(castProvider);
|
||||
|
||||
@@ -29,14 +32,14 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
|
||||
// Do not hide on paused
|
||||
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
|
||||
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
});
|
||||
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
@@ -44,11 +47,9 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
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();
|
||||
}
|
||||
// When we change position, show or hide timer
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
@@ -56,30 +57,34 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
showControlsAndStartHideTimer();
|
||||
|
||||
if (cast.isCasting) {
|
||||
switch (cast.castState) {
|
||||
case CastState.playing:
|
||||
ref.read(castProvider.notifier).pause();
|
||||
case CastState.paused:
|
||||
ref.read(castProvider.notifier).play();
|
||||
default:
|
||||
if (cast.castState == CastState.playing) {
|
||||
ref.read(castProvider.notifier).pause();
|
||||
} else if (cast.castState == CastState.paused) {
|
||||
ref.read(castProvider.notifier).play();
|
||||
} else if (cast.castState == CastState.idle) {
|
||||
// resend the play command since its finished
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
// ref.read(castProvider.notifier).loadMedia(asset, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
|
||||
switch (status) {
|
||||
case VideoPlaybackStatus.playing:
|
||||
notifier.pause();
|
||||
case VideoPlaybackStatus.completed:
|
||||
notifier.restart();
|
||||
default:
|
||||
notifier.play();
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else if (state == VideoPlaybackState.completed) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).restart();
|
||||
} else {
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
}
|
||||
}
|
||||
|
||||
void toggleControlsVisibility() {
|
||||
if (showBuffering) return;
|
||||
|
||||
if (showBuffering) {
|
||||
return;
|
||||
}
|
||||
if (showControls) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
} else {
|
||||
@@ -100,9 +105,9 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: status == VideoPlaybackStatus.completed,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isPlaying:
|
||||
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
show: assetIsVideo && showControls,
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||
|
||||
class ViewerBottomAppBar extends ConsumerWidget {
|
||||
@@ -8,12 +9,24 @@ class ViewerBottomAppBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0.0;
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 1.0,
|
||||
child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const ViewerBottomBar()),
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: Durations.short2,
|
||||
child: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [AssetStackRow(), ViewerBottomBar()],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -21,7 +21,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
@@ -21,7 +22,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -34,13 +35,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
||||
ref.watch(albumActivityProvider(album.id, asset.id));
|
||||
}
|
||||
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
if (!showControls) {
|
||||
opacity = 0.0;
|
||||
}
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
|
||||
@@ -13,14 +13,12 @@ class DriftMemoryCard extends StatelessWidget {
|
||||
final RemoteAsset asset;
|
||||
final String title;
|
||||
final bool showTitle;
|
||||
final bool isCurrent;
|
||||
final Function()? onVideoEnded;
|
||||
|
||||
const DriftMemoryCard({
|
||||
required this.asset,
|
||||
required this.title,
|
||||
required this.showTitle,
|
||||
this.isCurrent = false,
|
||||
this.onVideoEnded,
|
||||
super.key,
|
||||
});
|
||||
@@ -39,35 +37,33 @@ class DriftMemoryCard extends StatelessWidget {
|
||||
SizedBox.expand(child: _BlurredBackdrop(asset: asset)),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final r = asset.width != null && asset.height != null
|
||||
? asset.width! / asset.height!
|
||||
: constraints.maxWidth / constraints.maxHeight;
|
||||
|
||||
// Determine the fit using the aspect ratio
|
||||
BoxFit fit = BoxFit.contain;
|
||||
if (asset.width != null && asset.height != null) {
|
||||
final aspectRatio = asset.width! / asset.height!;
|
||||
final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight;
|
||||
// Look for a 25% difference in either direction
|
||||
if (phoneAspectRatio * .75 < r && phoneAspectRatio * 1.25 > r) {
|
||||
if (phoneAspectRatio * .75 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) {
|
||||
// Cover to look nice if we have nearly the same aspect ratio
|
||||
fit = BoxFit.cover;
|
||||
}
|
||||
}
|
||||
|
||||
if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
|
||||
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: r,
|
||||
if (asset.isImage) {
|
||||
return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
|
||||
} else {
|
||||
return SizedBox(
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
child: NativeVideoViewer(
|
||||
key: ValueKey(asset.id),
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
showControls: false,
|
||||
image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
|
||||
playbackDelayFactor: 2,
|
||||
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (showTitle)
|
||||
|
||||
@@ -5,7 +5,6 @@ const Size kTimelineFixedTileExtent = Size.square(256);
|
||||
const double kTimelineSpacing = 2.0;
|
||||
const int kTimelineColumnCount = 3;
|
||||
|
||||
const double kScrubberThumbHeight = 48.0;
|
||||
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
||||
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
||||
|
||||
|
||||
@@ -530,14 +530,12 @@ class _CircularThumb extends StatelessWidget {
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(kScrubberThumbHeight),
|
||||
bottomLeft: Radius.circular(kScrubberThumbHeight),
|
||||
topLeft: Radius.circular(48.0),
|
||||
bottomLeft: Radius.circular(48.0),
|
||||
topRight: Radius.circular(4.0),
|
||||
bottomRight: Radius.circular(4.0),
|
||||
),
|
||||
child: Container(
|
||||
constraints: BoxConstraints.tight(const Size(kScrubberThumbHeight * 0.6, kScrubberThumbHeight)),
|
||||
),
|
||||
child: Container(constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
@@ -35,7 +34,6 @@ class Timeline extends StatelessWidget {
|
||||
super.key,
|
||||
this.topSliverWidget,
|
||||
this.topSliverWidgetHeight,
|
||||
this.bottomSliverWidget,
|
||||
this.showStorageIndicator = false,
|
||||
this.withStack = false,
|
||||
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
|
||||
@@ -43,14 +41,13 @@ class Timeline extends StatelessWidget {
|
||||
this.groupBy,
|
||||
this.withScrubber = true,
|
||||
this.snapToMonth = true,
|
||||
this.initialScrollOffset,
|
||||
this.readOnly = false,
|
||||
this.persistentBottomBar = false,
|
||||
this.loadingWidget,
|
||||
});
|
||||
|
||||
final Widget? topSliverWidget;
|
||||
final double? topSliverWidgetHeight;
|
||||
final Widget? bottomSliverWidget;
|
||||
final bool showStorageIndicator;
|
||||
final Widget? appBar;
|
||||
final Widget? bottomSheet;
|
||||
@@ -58,9 +55,9 @@ class Timeline extends StatelessWidget {
|
||||
final GroupAssetsBy? groupBy;
|
||||
final bool withScrubber;
|
||||
final bool snapToMonth;
|
||||
final double? initialScrollOffset;
|
||||
final bool readOnly;
|
||||
final bool persistentBottomBar;
|
||||
final Widget? loadingWidget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -85,14 +82,13 @@ class Timeline extends StatelessWidget {
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
bottomSliverWidget: bottomSliverWidget,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
initialScrollOffset: initialScrollOffset,
|
||||
maxWidth: constraints.maxWidth,
|
||||
loadingWidget: loadingWidget,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -115,26 +111,24 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
const _SliverTimeline({
|
||||
this.topSliverWidget,
|
||||
this.topSliverWidgetHeight,
|
||||
this.bottomSliverWidget,
|
||||
this.appBar,
|
||||
this.bottomSheet,
|
||||
this.withScrubber = true,
|
||||
this.persistentBottomBar = false,
|
||||
this.snapToMonth = true,
|
||||
this.initialScrollOffset,
|
||||
this.maxWidth,
|
||||
this.loadingWidget,
|
||||
});
|
||||
|
||||
final Widget? topSliverWidget;
|
||||
final double? topSliverWidgetHeight;
|
||||
final Widget? bottomSliverWidget;
|
||||
final Widget? appBar;
|
||||
final Widget? bottomSheet;
|
||||
final bool withScrubber;
|
||||
final bool persistentBottomBar;
|
||||
final bool snapToMonth;
|
||||
final double? initialScrollOffset;
|
||||
final double? maxWidth;
|
||||
final Widget? loadingWidget;
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _SliverTimelineState();
|
||||
@@ -158,7 +152,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||
_scrollController = ScrollController(
|
||||
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
|
||||
onAttach: _restoreAssetPosition,
|
||||
);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow);
|
||||
@@ -376,7 +373,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
},
|
||||
child: asyncSegments.widgetWhen(
|
||||
onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null,
|
||||
onData: (segments) {
|
||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||
final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
||||
@@ -384,9 +380,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
: 0;
|
||||
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
||||
|
||||
const scrubberBottomPadding = 100.0;
|
||||
const bottomSheetOpenModifier = 120.0;
|
||||
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
||||
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
|
||||
final bottomPadding =
|
||||
context.padding.bottom +
|
||||
(widget.appBar == null ? 0 : scrubberBottomPadding) +
|
||||
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
||||
|
||||
final grid = CustomScrollView(
|
||||
primary: true,
|
||||
@@ -409,8 +408,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
addRepaintBoundaries: false,
|
||||
),
|
||||
),
|
||||
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -421,7 +419,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: topPadding,
|
||||
bottomPadding: scrubberBottomPadding,
|
||||
bottomPadding: bottomPadding,
|
||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||
hasAppBar: widget.appBar != null,
|
||||
child: grid,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
|
||||
class VideoPlaybackControls {
|
||||
const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false});
|
||||
|
||||
final Duration position;
|
||||
final bool pause;
|
||||
final bool restarted;
|
||||
}
|
||||
|
||||
final videoPlayerControlsProvider = StateNotifierProvider<VideoPlayerControls, VideoPlaybackControls>((ref) {
|
||||
return VideoPlayerControls(ref);
|
||||
});
|
||||
|
||||
const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false);
|
||||
|
||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
VideoPlaybackControls get value => state;
|
||||
|
||||
set value(VideoPlaybackControls value) {
|
||||
state = value;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = videoPlayerControlsDefault;
|
||||
}
|
||||
|
||||
Duration get position => state.position;
|
||||
bool get paused => state.pause;
|
||||
|
||||
set position(Duration value) {
|
||||
if (state.position == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(position: value, pause: state.pause);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
if (state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(position: state.position, pause: true);
|
||||
}
|
||||
|
||||
void play() {
|
||||
if (!state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(position: state.position, pause: false);
|
||||
}
|
||||
|
||||
void togglePlay() {
|
||||
state = VideoPlaybackControls(position: state.position, pause: !state.pause);
|
||||
}
|
||||
|
||||
void restart() {
|
||||
state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = ref
|
||||
.read(videoPlaybackValueProvider.notifier)
|
||||
.value
|
||||
.copyWith(state: VideoPlaybackState.playing, position: Duration.zero);
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
enum VideoPlaybackStatus { paused, playing, buffering, completed }
|
||||
|
||||
class VideoPlayerState {
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final VideoPlaybackStatus status;
|
||||
|
||||
const VideoPlayerState({required this.position, required this.duration, required this.status});
|
||||
|
||||
VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) {
|
||||
return VideoPlayerState(
|
||||
position: position ?? this.position,
|
||||
duration: duration ?? this.duration,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _defaultState = VideoPlayerState(
|
||||
position: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
status: VideoPlaybackStatus.paused,
|
||||
);
|
||||
|
||||
final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayerNotifier, VideoPlayerState, String>((
|
||||
ref,
|
||||
name,
|
||||
) {
|
||||
return VideoPlayerNotifier();
|
||||
});
|
||||
|
||||
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
static final _log = Logger('VideoPlayerNotifier');
|
||||
|
||||
VideoPlayerNotifier() : super(_defaultState);
|
||||
|
||||
NativeVideoPlayerController? _controller;
|
||||
Timer? _bufferingTimer;
|
||||
Timer? _seekTimer;
|
||||
|
||||
void attachController(NativeVideoPlayerController controller) {
|
||||
_controller = controller;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bufferingTimer?.cancel();
|
||||
_seekTimer?.cancel();
|
||||
WakelockPlus.disable();
|
||||
_controller = null;
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
if (_controller == null) return;
|
||||
|
||||
_bufferingTimer?.cancel();
|
||||
|
||||
try {
|
||||
await _controller!.pause();
|
||||
await _flushSeek();
|
||||
} catch (e) {
|
||||
_log.severe('Error pausing video: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> play() async {
|
||||
if (_controller == null) return;
|
||||
|
||||
try {
|
||||
await _flushSeek();
|
||||
await _controller!.play();
|
||||
} catch (e) {
|
||||
_log.severe('Error playing video: $e');
|
||||
}
|
||||
|
||||
_startBufferingTimer();
|
||||
}
|
||||
|
||||
Future<void> _flushSeek() async {
|
||||
final timer = _seekTimer;
|
||||
if (timer == null || !timer.isActive) return;
|
||||
|
||||
timer.cancel();
|
||||
await _controller?.seekTo(state.position.inMilliseconds);
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
if (_controller == null) return;
|
||||
|
||||
state = state.copyWith(position: position);
|
||||
|
||||
_seekTimer?.cancel();
|
||||
_seekTimer = Timer(const Duration(milliseconds: 100), () {
|
||||
_controller?.seekTo(position.inMilliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> restart() async {
|
||||
seekTo(Duration.zero);
|
||||
await play();
|
||||
}
|
||||
|
||||
Future<void> setVolume(double volume) async {
|
||||
try {
|
||||
await _controller?.setVolume(volume);
|
||||
} catch (e) {
|
||||
_log.severe('Error setting volume: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setLoop(bool loop) async {
|
||||
try {
|
||||
await _controller?.setLoop(loop);
|
||||
} catch (e) {
|
||||
_log.severe('Error setting loop: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void onNativePlaybackReady() {
|
||||
if (!mounted) return;
|
||||
|
||||
final playbackInfo = _controller?.playbackInfo;
|
||||
final videoInfo = _controller?.videoInfo;
|
||||
|
||||
if (playbackInfo == null || videoInfo == null) return;
|
||||
|
||||
state = state.copyWith(
|
||||
position: Duration(milliseconds: playbackInfo.position),
|
||||
duration: Duration(milliseconds: videoInfo.duration),
|
||||
status: _mapStatus(playbackInfo.status),
|
||||
);
|
||||
}
|
||||
|
||||
void onNativePositionChanged() {
|
||||
if (!mounted || (_seekTimer?.isActive ?? false)) return;
|
||||
|
||||
final playbackInfo = _controller?.playbackInfo;
|
||||
if (playbackInfo == null) return;
|
||||
|
||||
final position = Duration(milliseconds: playbackInfo.position);
|
||||
if (state.position == position) return;
|
||||
|
||||
if (state.status == VideoPlaybackStatus.buffering) {
|
||||
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
|
||||
} else {
|
||||
state = state.copyWith(position: position);
|
||||
}
|
||||
|
||||
_startBufferingTimer();
|
||||
}
|
||||
|
||||
void onNativeStatusChanged() {
|
||||
if (!mounted) return;
|
||||
|
||||
final playbackInfo = _controller?.playbackInfo;
|
||||
if (playbackInfo == null) return;
|
||||
|
||||
final newStatus = _mapStatus(playbackInfo.status);
|
||||
switch (newStatus) {
|
||||
case VideoPlaybackStatus.playing:
|
||||
WakelockPlus.enable();
|
||||
_startBufferingTimer();
|
||||
default:
|
||||
onNativePlaybackEnded();
|
||||
}
|
||||
|
||||
if (state.status != newStatus) {
|
||||
state = state.copyWith(status: newStatus);
|
||||
}
|
||||
}
|
||||
|
||||
void onNativePlaybackEnded() {
|
||||
WakelockPlus.disable();
|
||||
_bufferingTimer?.cancel();
|
||||
}
|
||||
|
||||
void _startBufferingTimer() {
|
||||
_bufferingTimer?.cancel();
|
||||
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted && state.status == VideoPlaybackStatus.playing) {
|
||||
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) {
|
||||
PlaybackStatus.playing => VideoPlaybackStatus.playing,
|
||||
PlaybackStatus.paused => VideoPlaybackStatus.paused,
|
||||
PlaybackStatus.stopped => VideoPlaybackStatus.completed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
|
||||
enum VideoPlaybackState { initializing, paused, playing, buffering, completed }
|
||||
|
||||
class VideoPlaybackValue {
|
||||
/// The current position of the video
|
||||
final Duration position;
|
||||
|
||||
/// The total duration of the video
|
||||
final Duration duration;
|
||||
|
||||
/// The current state of the video playback
|
||||
final VideoPlaybackState state;
|
||||
|
||||
/// The volume of the video
|
||||
final double volume;
|
||||
|
||||
const VideoPlaybackValue({required this.position, required this.duration, required this.state, required this.volume});
|
||||
|
||||
factory VideoPlaybackValue.fromNativeController(NativeVideoPlayerController controller) {
|
||||
final playbackInfo = controller.playbackInfo;
|
||||
final videoInfo = controller.videoInfo;
|
||||
|
||||
if (playbackInfo == null || videoInfo == null) {
|
||||
return videoPlaybackValueDefault;
|
||||
}
|
||||
|
||||
final VideoPlaybackState status = switch (playbackInfo.status) {
|
||||
PlaybackStatus.playing => VideoPlaybackState.playing,
|
||||
PlaybackStatus.paused => VideoPlaybackState.paused,
|
||||
PlaybackStatus.stopped => VideoPlaybackState.completed,
|
||||
};
|
||||
|
||||
return VideoPlaybackValue(
|
||||
position: Duration(milliseconds: playbackInfo.position),
|
||||
duration: Duration(milliseconds: videoInfo.duration),
|
||||
state: status,
|
||||
volume: playbackInfo.volume,
|
||||
);
|
||||
}
|
||||
|
||||
VideoPlaybackValue copyWith({Duration? position, Duration? duration, VideoPlaybackState? state, double? volume}) {
|
||||
return VideoPlaybackValue(
|
||||
position: position ?? this.position,
|
||||
duration: duration ?? this.duration,
|
||||
state: state ?? this.state,
|
||||
volume: volume ?? this.volume,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
|
||||
position: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
state: VideoPlaybackState.initializing,
|
||||
volume: 0.0,
|
||||
);
|
||||
|
||||
final videoPlaybackValueProvider = StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
||||
return VideoPlaybackValueState(ref);
|
||||
});
|
||||
|
||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
VideoPlaybackValue get value => state;
|
||||
|
||||
set value(VideoPlaybackValue value) {
|
||||
state = value;
|
||||
}
|
||||
|
||||
set position(Duration value) {
|
||||
if (state.position == value) return;
|
||||
state = VideoPlaybackValue(position: value, duration: state.duration, state: state.state, volume: state.volume);
|
||||
}
|
||||
|
||||
set status(VideoPlaybackState value) {
|
||||
if (state.state == value) return;
|
||||
state = VideoPlaybackValue(position: state.position, duration: state.duration, state: value, volume: state.volume);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = videoPlaybackValueDefault;
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -123,7 +123,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
Set<BaseAsset> _getAssets(ActionSource source) {
|
||||
return switch (source) {
|
||||
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
||||
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
|
||||
ActionSource.viewer => switch (ref.read(currentAssetNotifier)) {
|
||||
BaseAsset asset => {asset},
|
||||
null => const {},
|
||||
},
|
||||
@@ -307,10 +307,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
// does not update the currentAsset which means
|
||||
// the exif provider will not be refreshed automatically
|
||||
if (source == ActionSource.viewer) {
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset != null) {
|
||||
ref.invalidate(assetExifProvider(currentAsset));
|
||||
}
|
||||
ref.invalidate(currentAssetExifProvider);
|
||||
}
|
||||
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
@@ -412,6 +409,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
if (source == ActionSource.viewer) {
|
||||
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
|
||||
if (updatedParent != null) {
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
|
||||
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,52 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
|
||||
final assetExifProvider = FutureProvider.autoDispose.family<ExifInfo?, BaseAsset>((ref, asset) {
|
||||
return ref.watch(assetServiceProvider).getExif(asset);
|
||||
final currentAssetNotifier = AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset?>(CurrentAssetNotifier.new);
|
||||
|
||||
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
|
||||
KeepAliveLink? _keepAliveLink;
|
||||
StreamSubscription<BaseAsset?>? _assetSubscription;
|
||||
|
||||
@override
|
||||
BaseAsset? build() => null;
|
||||
|
||||
void setAsset(BaseAsset asset) {
|
||||
_keepAliveLink?.close();
|
||||
_assetSubscription?.cancel();
|
||||
state = asset;
|
||||
_assetSubscription = ref.watch(assetServiceProvider).watchAsset(asset).listen((updatedAsset) {
|
||||
if (updatedAsset != null) {
|
||||
state = updatedAsset;
|
||||
}
|
||||
});
|
||||
_keepAliveLink = ref.keepAlive();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_keepAliveLink?.close();
|
||||
_assetSubscription?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class ScopedAssetNotifier extends CurrentAssetNotifier {
|
||||
final BaseAsset _asset;
|
||||
|
||||
ScopedAssetNotifier(this._asset);
|
||||
|
||||
@override
|
||||
BaseAsset? build() {
|
||||
setAsset(_asset);
|
||||
return _asset;
|
||||
}
|
||||
}
|
||||
|
||||
final currentAssetExifProvider = FutureProvider.autoDispose((ref) {
|
||||
final currentAsset = ref.watch(currentAssetNotifier);
|
||||
if (currentAsset == null) {
|
||||
return null;
|
||||
}
|
||||
return ref.watch(assetServiceProvider).getExif(currentAsset);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
|
||||
void useInterval(Duration delay, VoidCallback callback) {
|
||||
final savedCallback = useRef(callback);
|
||||
savedCallback.value = callback;
|
||||
|
||||
useEffect(() {
|
||||
final timer = Timer.periodic(delay, (_) => savedCallback.value());
|
||||
return timer.cancel;
|
||||
}, [delay]);
|
||||
}
|
||||
@@ -333,7 +333,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 40.0),
|
||||
child: Column(
|
||||
children: [
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()),
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
BottomNavigationBar(
|
||||
elevation: 0.0,
|
||||
backgroundColor: Colors.transparent,
|
||||
|
||||
@@ -3,27 +3,23 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/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 CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
final String videoId;
|
||||
final Duration hideTimerDuration;
|
||||
|
||||
const CustomVideoPlayerControls({
|
||||
super.key,
|
||||
required this.videoId,
|
||||
this.hideTimerDuration = const Duration(seconds: 5),
|
||||
});
|
||||
const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
|
||||
final showControls = ref.watch(showControlsProvider);
|
||||
final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status));
|
||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||
|
||||
final cast = ref.watch(castProvider);
|
||||
|
||||
@@ -32,14 +28,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final s = ref.read(videoPlayerProvider(videoId)).status;
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
|
||||
// Do not hide on paused
|
||||
if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) {
|
||||
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
}
|
||||
});
|
||||
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
@@ -47,11 +43,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
ref.read(showControlsProvider.notifier).show = true;
|
||||
}
|
||||
|
||||
// When playback starts, reset the hide timer
|
||||
ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) {
|
||||
if (next == VideoPlaybackStatus.playing) {
|
||||
hideTimer.reset();
|
||||
}
|
||||
// When we change position, show or hide timer
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
@@ -74,13 +68,12 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||
if (status == VideoPlaybackStatus.playing) {
|
||||
notifier.pause();
|
||||
} else if (status == VideoPlaybackStatus.completed) {
|
||||
notifier.restart();
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else if (state == VideoPlaybackState.completed) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).restart();
|
||||
} else {
|
||||
notifier.play();
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +92,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: status == VideoPlaybackStatus.completed,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isPlaying:
|
||||
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
show: assetIsVideo && showControls,
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
|
||||
@@ -3,20 +3,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
||||
|
||||
/// The video controls for the [videoPlayerProvider]
|
||||
/// The video controls for the [videoPlayerControlsProvider]
|
||||
class VideoControls extends ConsumerWidget {
|
||||
final String videoPlayerName;
|
||||
|
||||
const VideoControls({super.key, required this.videoPlayerName});
|
||||
const VideoControls({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isPortrait = context.orientation == Orientation.portrait;
|
||||
return isPortrait
|
||||
? VideoPosition(videoPlayerName: videoPlayerName)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 60.0),
|
||||
child: VideoPosition(videoPlayerName: videoPlayerName),
|
||||
);
|
||||
? const VideoPosition()
|
||||
: const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ 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/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_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});
|
||||
const VideoPosition({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -19,7 +18,7 @@ class VideoPosition extends HookConsumerWidget {
|
||||
|
||||
final (position, duration) = isCasting
|
||||
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
||||
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
|
||||
: ref.watch(videoPlaybackValueProvider.select((v) => (v.position, v.duration)));
|
||||
|
||||
final wasPlaying = useRef<bool>(true);
|
||||
return duration == Duration.zero
|
||||
@@ -45,13 +44,13 @@ class VideoPosition extends HookConsumerWidget {
|
||||
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();
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
wasPlaying.value = state != VideoPlaybackState.paused;
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
if (wasPlaying.value) {
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
}
|
||||
},
|
||||
onChanged: (value) {
|
||||
@@ -62,7 +61,10 @@ class VideoPosition extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
|
||||
ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration;
|
||||
|
||||
// This immediately updates the slider position without waiting for the video to update
|
||||
ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration;
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/memory.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
@@ -33,6 +34,9 @@ class MemoryLane extends HookConsumerWidget {
|
||||
if (memories[memoryIndex].assets.isNotEmpty) {
|
||||
final asset = memories[memoryIndex].assets[0];
|
||||
ref.read(currentAssetProvider.notifier).set(asset);
|
||||
if (asset.isVideo || asset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
|
||||
},
|
||||
|
||||
@@ -420,11 +420,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
|
||||
Widget _buildChild() {
|
||||
return widget.hasCustomChild
|
||||
? SizedBox(
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
height: scaleBoundaries.childSize.height * scale,
|
||||
child: widget.customChild!,
|
||||
)
|
||||
? widget.customChild!
|
||||
: Image(
|
||||
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
|
||||
image: widget.imageProvider!,
|
||||
@@ -432,7 +428,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||
filterQuality: widget.filterQuality,
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
fit: BoxFit.contain,
|
||||
fit: BoxFit.cover,
|
||||
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
||||
);
|
||||
}
|
||||
|
||||
+4
-4
@@ -1217,10 +1217,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1910,10 +1910,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
version: "0.7.7"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/node": "^24.10.13",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"version": "2.5.6",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
Generated
+1012
-1153
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
|
||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
cd plugins && mise run build
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
|
||||
FROM ghcr.io/immich-app/base-server-prod:202601131104@sha256:c649c5838b6348836d27db6d49cadbbc6157feae7a1a237180c3dec03577ba8f
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user