mirror of
https://github.com/immich-app/immich.git
synced 2026-03-14 14:26:53 -07:00
Compare commits
17 Commits
csp-policy
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e04007f8b | ||
|
|
dd03c9c0a9 | ||
|
|
16e4a2b92a | ||
|
|
5caa7e1902 | ||
|
|
8279e1078a | ||
|
|
011ecbb43d | ||
|
|
2725c96cb1 | ||
|
|
3c476b1987 | ||
|
|
5989c9b4aa | ||
|
|
13c4260a1f | ||
|
|
54bc9ddd69 | ||
|
|
f94e0fbc39 | ||
|
|
5532f669eb | ||
|
|
e4c24bdec8 | ||
|
|
56f14162f6 | ||
|
|
8abbbc49cf | ||
|
|
4eb08eee18 |
@@ -16,7 +16,7 @@ services:
|
||||
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- ../plugins:/build/corePlugin
|
||||
- ../packages/plugin-core:/build/plugins/immich-plugin-core
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
immich-machine-learning:
|
||||
|
||||
4
.github/package.json
vendored
4
.github/package.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write ."
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.7.4"
|
||||
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -90,6 +90,8 @@ jobs:
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Build app
|
||||
run: pnpm run --filter immich --filter @immich/plugin-sdk build
|
||||
- name: Run tsc
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -394,6 +396,8 @@ jobs:
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run pnpm install
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
- name: Build plugin
|
||||
run: pnpm run --filter @immich/plugin-sdk --filter @immich/plugin-core build
|
||||
- name: Run medium tests
|
||||
run: pnpm test:medium
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -722,7 +726,7 @@ jobs:
|
||||
- name: Install server dependencies
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
|
||||
- name: Build the app
|
||||
run: pnpm --filter immich build
|
||||
run: pnpm --filter immich --filter @immich/plugin-sdk build
|
||||
- name: Run API generation
|
||||
run: ./bin/generate-open-api.sh
|
||||
working-directory: open-api
|
||||
@@ -784,7 +788,7 @@ jobs:
|
||||
- name: Install server dependencies
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
- name: Build the app
|
||||
run: pnpm build
|
||||
run: pnpm run --filter immich --filter @immich/plugin-sdk build
|
||||
- name: Run existing migrations
|
||||
run: pnpm migrations:run
|
||||
- name: Test npm run schema:reset command works
|
||||
|
||||
9
.vscode/extensions.json
vendored
9
.vscode/extensions.json
vendored
@@ -5,6 +5,13 @@
|
||||
"dbaeumer.vscode-eslint",
|
||||
"dart-code.flutter",
|
||||
"dart-code.dart-code",
|
||||
"dcmdev.dcm-vscode-extension"
|
||||
"dcmdev.dcm-vscode-extension",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"ms-playwright.playwright",
|
||||
"vitest.explorer",
|
||||
"editorconfig.editorconfig",
|
||||
"foxundermoon.shell-format",
|
||||
"timonwong.shellcheck",
|
||||
"bluebrown.yamlfmt"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ 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.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.14",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"@vitest/coverage-v8": "^4.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": "^3.0.0",
|
||||
"vitest": "^4.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 --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import 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$`), () => {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||
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$`), () => {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||
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$`), () => {
|
||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
||||
throw new Error('Network error');
|
||||
});
|
||||
|
||||
@@ -236,16 +236,19 @@ 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.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: [
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: [
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out unsupported files', async () => {
|
||||
@@ -257,16 +260,19 @@ describe('startWatch', () => {
|
||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
|
||||
|
||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
}),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
@@ -291,16 +297,19 @@ describe('startWatch', () => {
|
||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
|
||||
|
||||
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
assets: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: testFilePath,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
}),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||
assetBulkUploadCheckDto: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, UserConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -17,4 +17,8 @@ export default defineConfig({
|
||||
noExternal: /^(?!node:).*$/,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
test: {
|
||||
name: 'cli:unit',
|
||||
globals: true,
|
||||
},
|
||||
} as UserConfig);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "0.99.4"
|
||||
opentofu = "1.11.4"
|
||||
opentofu = "1.11.5"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -73,7 +73,7 @@ services:
|
||||
- ${UPLOAD_LOCATION}/photos:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm_store_server:/buildcache/pnpm-store
|
||||
- ../plugins:/build/corePlugin
|
||||
- ../packages/plugin-core:/build/plugins/immich-plugin-core
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"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",
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"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 --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "pnpm run lint --fix",
|
||||
"check": "tsc --noEmit"
|
||||
@@ -27,7 +27,7 @@
|
||||
"@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",
|
||||
@@ -54,7 +54,8 @@
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"utimes": "^5.2.1",
|
||||
"vitest": "^3.0.0"
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "vitest*.config.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
@@ -14,15 +15,14 @@ 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',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||
@@ -14,15 +15,14 @@ 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',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
|
||||
11
i18n/en.json
11
i18n/en.json
@@ -22,13 +22,12 @@
|
||||
"add_birthday": "Add a birthday",
|
||||
"add_endpoint": "Add endpoint",
|
||||
"add_exclusion_pattern": "Add exclusion pattern",
|
||||
"add_filter": "Add filter",
|
||||
"add_filter_description": "Click to add a filter condition",
|
||||
"add_location": "Add location",
|
||||
"add_more_users": "Add more users",
|
||||
"add_partner": "Add partner",
|
||||
"add_path": "Add path",
|
||||
"add_photos": "Add photos",
|
||||
"add_step": "Add step",
|
||||
"add_tag": "Add tag",
|
||||
"add_to": "Add to…",
|
||||
"add_to_album": "Add to album",
|
||||
@@ -42,7 +41,6 @@
|
||||
"add_to_shared_album": "Add to shared album",
|
||||
"add_upload_to_stack": "Add upload to stack",
|
||||
"add_url": "Add URL",
|
||||
"add_workflow_step": "Add workflow step",
|
||||
"added_to_archive": "Added to archive",
|
||||
"added_to_favorites": "Added to favorites",
|
||||
"added_to_favorites_count": "Added {count, number} to favorites",
|
||||
@@ -805,6 +803,7 @@
|
||||
"comments_are_disabled": "Comments are disabled",
|
||||
"common_create_new_album": "Create new album",
|
||||
"completed": "Completed",
|
||||
"configuration": "Configuration",
|
||||
"confirm": "Confirm",
|
||||
"confirm_admin_password": "Confirm Admin Password",
|
||||
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
|
||||
@@ -1583,7 +1582,6 @@
|
||||
"next": "Next",
|
||||
"next_memory": "Next memory",
|
||||
"no": "No",
|
||||
"no_actions_added": "No actions added yet",
|
||||
"no_albums_found": "No albums found",
|
||||
"no_albums_message": "Create an album to organize your photos and videos",
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
@@ -1600,7 +1598,6 @@
|
||||
"no_exif_info_available": "No exif info available",
|
||||
"no_explore_results_message": "Upload more photos to explore your collection.",
|
||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||
"no_filters_added": "No filters added yet",
|
||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||
"no_local_assets_found": "No local assets found with this checksum",
|
||||
"no_location_set": "No location set",
|
||||
@@ -1613,6 +1610,7 @@
|
||||
"no_results": "No results",
|
||||
"no_results_description": "Try a synonym or more general keyword",
|
||||
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
||||
"no_steps": "No steps added yet",
|
||||
"no_uploads_in_progress": "No uploads in progress",
|
||||
"none": "None",
|
||||
"not_allowed": "Not allowed",
|
||||
@@ -2181,6 +2179,7 @@
|
||||
"start_date_before_end_date": "Start date must be before end date",
|
||||
"state": "State",
|
||||
"status": "Status",
|
||||
"steps": "Steps",
|
||||
"stop_casting": "Stop casting",
|
||||
"stop_motion_photo": "Stop Motion Photo",
|
||||
"stop_photo_sharing": "Stop sharing your photos?",
|
||||
@@ -2311,7 +2310,6 @@
|
||||
"unsupported_field_type": "Unsupported field type",
|
||||
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
|
||||
"untagged": "Untagged",
|
||||
"untitled_workflow": "Untitled workflow",
|
||||
"up_next": "Up next",
|
||||
"update_location_action_prompt": "Update the location of {count} selected assets with:",
|
||||
"updated_at": "Updated",
|
||||
@@ -2402,6 +2400,7 @@
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"width": "Width",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
"workflow": "Workflow",
|
||||
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
|
||||
"workflow_deleted": "Workflow deleted",
|
||||
"workflow_description": "Workflow description",
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"version": "2.5.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write ."
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.7.4",
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hashes for inline <script> elements in app.html
|
||||
* and updates the script-src CSP directive in svelte.config.js.
|
||||
*
|
||||
* SvelteKit's CSP hash mode only hashes inline content it generates itself,
|
||||
* not the template content from app.html. This script fills that gap.
|
||||
*
|
||||
* Run this script whenever the inline scripts in app.html change.
|
||||
*
|
||||
* Usage: node misc/update-csp-hashes.mjs
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = join(scriptDirectory, '..');
|
||||
const appHtmlPath = join(repoRoot, 'web', 'src', 'app.html');
|
||||
const configPath = join(repoRoot, 'web', 'svelte.config.js');
|
||||
|
||||
const appHtml = readFileSync(appHtmlPath, 'utf-8');
|
||||
const scriptRegex = /<script[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
|
||||
const hashes = [];
|
||||
let match;
|
||||
while ((match = scriptRegex.exec(appHtml)) !== null) {
|
||||
const content = match[1];
|
||||
const hash = createHash('sha256').update(content).digest('base64');
|
||||
hashes.push(`sha256-${hash}`);
|
||||
const preview = content.trim().slice(0, 60).replaceAll('\n', ' ');
|
||||
console.log(`Found: ${preview}...`);
|
||||
console.log(` Hash: sha256-${hash}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (hashes.length === 0) {
|
||||
console.log('No inline <script> elements found in app.html');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let config = readFileSync(configPath, 'utf-8');
|
||||
|
||||
const scriptSrcRegex = /'script-src':\s*\[[\s\S]*?\]/;
|
||||
const scriptSrcMatch = config.match(scriptSrcRegex);
|
||||
if (!scriptSrcMatch) {
|
||||
console.error("Could not find 'script-src' directive in svelte.config.js");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existingEntries = [];
|
||||
const entryRegex = /'([^']+)'/g;
|
||||
let entryMatch;
|
||||
while ((entryMatch = entryRegex.exec(scriptSrcMatch[0])) !== null) {
|
||||
const value = entryMatch[1];
|
||||
if (value === 'script-src' || value.startsWith('sha256-')) {
|
||||
continue;
|
||||
}
|
||||
existingEntries.push(value);
|
||||
}
|
||||
|
||||
const allEntries = [...existingEntries, ...hashes];
|
||||
const formatted = allEntries.map((entry) => ` '${entry}'`).join(',\n');
|
||||
const newScriptSrc = `'script-src': [\n${formatted},\n ]`;
|
||||
|
||||
config = config.replace(scriptSrcRegex, newScriptSrc);
|
||||
writeFileSync(configPath, config);
|
||||
|
||||
console.log(`Updated svelte.config.js with ${hashes.length} script hash(es)`);
|
||||
@@ -2,7 +2,7 @@ experimental_monorepo_root = true
|
||||
|
||||
[monorepo]
|
||||
config_roots = [
|
||||
"plugins",
|
||||
"packages/plugin-core",
|
||||
"server",
|
||||
"cli",
|
||||
"deployment",
|
||||
@@ -18,7 +18,7 @@ node = "24.13.1"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.30.3"
|
||||
terragrunt = "0.99.4"
|
||||
opentofu = "1.11.4"
|
||||
opentofu = "1.11.5"
|
||||
java = "21.0.2"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
|
||||
@@ -20,7 +20,6 @@ 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';
|
||||
@@ -367,9 +366,6 @@ 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,18 +11,14 @@ 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_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/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 {
|
||||
@@ -42,18 +38,10 @@ 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;
|
||||
|
||||
@@ -117,127 +105,45 @@ 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 videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||
notifier.onNativePlaybackReady();
|
||||
|
||||
isVideoReady.value = true;
|
||||
|
||||
try {
|
||||
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
|
||||
if (autoPlayVideo) {
|
||||
await videoController.play();
|
||||
await notifier.play();
|
||||
}
|
||||
await videoController.setVolume(0.9);
|
||||
await notifier.setVolume(1);
|
||||
} 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;
|
||||
if (!context.mounted) return;
|
||||
ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged();
|
||||
}
|
||||
|
||||
void onPlaybackEnded() {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (videoController.playbackInfo?.status == PlaybackStatus.stopped &&
|
||||
ref.read(videoPlayerProvider(videoId).notifier).onNativePlaybackEnded();
|
||||
|
||||
final videoController = controller.value;
|
||||
if (videoController?.playbackInfo?.status == PlaybackStatus.stopped &&
|
||||
!ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo)) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
}
|
||||
@@ -254,14 +160,15 @@ 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);
|
||||
@@ -273,10 +180,9 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
}),
|
||||
);
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
unawaited(nc.setLoop(loopVideo));
|
||||
await notifier.setLoop(loopVideo);
|
||||
|
||||
controller.value = nc;
|
||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||
}
|
||||
|
||||
ref.listen(currentAssetProvider, (_, value) {
|
||||
@@ -300,10 +206,6 @@ 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)
|
||||
@@ -337,19 +239,18 @@ 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 controller.value?.play();
|
||||
await notifier.play();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
final videoPlaying = await controller.value?.isPlaying();
|
||||
if (videoPlaying ?? true) {
|
||||
shouldPlayOnForeground.value = true;
|
||||
await controller.value?.pause();
|
||||
await notifier.pause();
|
||||
} else {
|
||||
shouldPlayOnForeground.value = false;
|
||||
}
|
||||
@@ -374,39 +275,8 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showControls) const Center(child: CustomVideoPlayerControls()),
|
||||
if (showControls) Center(child: CustomVideoPlayerControls(videoId: videoId)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -166,9 +165,6 @@ 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,16 +7,15 @@ 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 [currentAssetNotifier] to be set before navigating to this page
|
||||
/// Expects the current asset to be set via [assetViewerProvider] before navigating to this page
|
||||
@RoutePage()
|
||||
class DriftMemoryPage extends HookConsumerWidget {
|
||||
final List<DriftMemory> memories;
|
||||
@@ -26,11 +25,7 @@ class DriftMemoryPage extends HookConsumerWidget {
|
||||
|
||||
static void setMemory(WidgetRef ref, DriftMemory memory) {
|
||||
if (memory.assets.isNotEmpty) {
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first);
|
||||
|
||||
if (memory.assets.first.isVideo) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
ref.read(assetViewerProvider.notifier).setAsset(memory.assets.first);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,11 +167,7 @@ class DriftMemoryPage extends HookConsumerWidget {
|
||||
|
||||
final asset = currentMemory.value.assets[otherIndex];
|
||||
currentAsset.value = asset;
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
||||
// if (asset.isVideo || asset.isMotionPhoto) {
|
||||
if (asset.isVideo) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
}
|
||||
|
||||
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||
@@ -273,7 +264,12 @@ class DriftMemoryPage extends HookConsumerWidget {
|
||||
children: [
|
||||
Container(
|
||||
color: Colors.black,
|
||||
child: DriftMemoryCard(asset: asset, title: title, showTitle: index == 0),
|
||||
child: DriftMemoryCard(
|
||||
asset: asset,
|
||||
title: title,
|
||||
showTitle: index == 0,
|
||||
isCurrent: mIndex == currentMemoryIndex.value && index == currentAssetPage.value,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Row(
|
||||
|
||||
@@ -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/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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(currentAssetNotifier);
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (asset == null) return [];
|
||||
|
||||
final user = ref.read(currentUserProvider);
|
||||
@@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
}
|
||||
|
||||
void _openAlbumSelector() {
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
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(currentAssetNotifier);
|
||||
final latest = ref.read(assetViewerProvider).currentAsset;
|
||||
|
||||
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(currentAssetNotifier);
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
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/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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(currentAssetNotifier);
|
||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
|
||||
onPress() {
|
||||
if (currentAsset == null) {
|
||||
|
||||
@@ -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/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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(currentAssetNotifier) as RemoteAsset?;
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||
|
||||
@@ -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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.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/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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(currentAssetNotifier);
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
|
||||
if (asset == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -11,34 +12,36 @@ 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({required this.minHeight, super.key});
|
||||
const AssetDetails({super.key, required this.asset, required this.minHeight});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
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),
|
||||
],
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,27 +8,25 @@ 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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.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 {
|
||||
const AppearsInDetails({super.key});
|
||||
final BaseAsset asset;
|
||||
|
||||
const AppearsInDetails({super.key, required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
|
||||
if (!asset.hasRemote) return const SizedBox.shrink();
|
||||
|
||||
String? remoteAssetId;
|
||||
if (asset is RemoteAsset) {
|
||||
remoteAssetId = asset.id;
|
||||
} else if (asset is LocalAsset) {
|
||||
remoteAssetId = asset.remoteAssetId;
|
||||
}
|
||||
final remoteAssetId = switch (asset) {
|
||||
RemoteAsset(:final id) => id,
|
||||
LocalAsset(:final remoteAssetId) => remoteAssetId,
|
||||
};
|
||||
|
||||
if (remoteAssetId == null) return const SizedBox.shrink();
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -18,14 +17,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class DateTimeDetails extends ConsumerWidget {
|
||||
const DateTimeDetails({super.key});
|
||||
final BaseAsset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const DateTimeDetails({super.key, required this.asset, this.exifInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final asset = this.asset;
|
||||
final exifInfo = this.exifInfo;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
|
||||
return Column(
|
||||
@@ -106,9 +106,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
final currentDescription = currentExifInfo?.description ?? '';
|
||||
final currentDescription = widget.exif.description ?? '';
|
||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
||||
context: context,
|
||||
);
|
||||
@@ -134,7 +132,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
),
|
||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
||||
onTapOutside: (_) => saveDescription(widget.exif.description),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,12 +8,14 @@ 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 {
|
||||
const LocationDetails({super.key});
|
||||
final BaseAsset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const LocationDetails({super.key, required this.asset, this.exifInfo});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _LocationDetailsState();
|
||||
@@ -40,17 +42,15 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
_mapController = controller;
|
||||
}
|
||||
|
||||
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 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 editLocation() async {
|
||||
@@ -59,8 +59,8 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final asset = widget.asset;
|
||||
final exifInfo = widget.exifInfo;
|
||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||
|
||||
// Guard local assets
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/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';
|
||||
@@ -15,17 +14,14 @@ 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 ConsumerStatefulWidget {
|
||||
const PeopleDetails({super.key});
|
||||
class PeopleDetails extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const PeopleDetails({super.key, required this.asset});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _PeopleDetailsState();
|
||||
}
|
||||
|
||||
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = this.asset;
|
||||
if (asset is! RemoteAsset) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
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 {
|
||||
const RatingDetails({super.key});
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const RatingDetails({super.key, this.exifInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -20,8 +22,6 @@ 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,21 +6,20 @@ 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 {
|
||||
const TechnicalDetails({super.key});
|
||||
final BaseAsset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const TechnicalDetails({super.key, required this.asset, this.exifInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final exifInfo = this.exifInfo;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
|
||||
|
||||
@@ -12,16 +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_viewer.state.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/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/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/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.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';
|
||||
|
||||
@@ -52,7 +52,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
||||
|
||||
double _snapOffset = 0.0;
|
||||
|
||||
DragStartDetails? _dragStart;
|
||||
@@ -246,14 +245,16 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
|
||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||
_isZoomed = switch (scaleState) {
|
||||
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
|
||||
_ => false,
|
||||
};
|
||||
_isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == 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();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -288,22 +289,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_listenForScaleBoundaries(controller);
|
||||
}
|
||||
|
||||
Widget _buildPhotoView(
|
||||
BaseAsset displayAsset,
|
||||
BaseAsset asset, {
|
||||
required bool isCurrentPage,
|
||||
required bool showingDetails,
|
||||
Widget _buildPhotoView({
|
||||
required BaseAsset asset,
|
||||
required PhotoViewHeroAttributes? heroAttributes,
|
||||
required bool isCurrent,
|
||||
required bool isPlayingMotionVideo,
|
||||
required BoxDecoration backgroundDecoration,
|
||||
}) {
|
||||
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
|
||||
final size = context.sizeData;
|
||||
|
||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||
final size = context.sizeData;
|
||||
if (asset.isImage && !isPlayingMotionVideo) {
|
||||
return PhotoView(
|
||||
key: Key(displayAsset.heroTag),
|
||||
key: Key(asset.heroTag),
|
||||
index: widget.index,
|
||||
imageProvider: getFullImageProvider(displayAsset, size: size),
|
||||
imageProvider: getFullImageProvider(asset, size: size),
|
||||
heroAttributes: heroAttributes,
|
||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
@@ -311,7 +310,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
enablePanAlways: true,
|
||||
disableScaleGestures: showingDetails,
|
||||
disableScaleGestures: _showingDetails,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
onDragStart: _onDragStart,
|
||||
@@ -319,45 +318,42 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
onTapUp: _onTapUp,
|
||||
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null,
|
||||
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
|
||||
errorBuilder: (_, __, ___) => SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain),
|
||||
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Size childSize;
|
||||
if (displayAsset.width != null && displayAsset.height != null) {
|
||||
final r = displayAsset.width! / displayAsset.height!;
|
||||
final w = math.min(context.width, context.height * r);
|
||||
childSize = Size(w, w / r);
|
||||
} else {
|
||||
childSize = Size(context.height, context.height);
|
||||
}
|
||||
|
||||
return PhotoView.customChild(
|
||||
key: Key(displayAsset.heroTag),
|
||||
childSize: childSize,
|
||||
filterQuality: FilterQuality.low,
|
||||
key: Key(asset.heroTag),
|
||||
childSize: asset.width != null && asset.height != null
|
||||
? Size(asset.width!.toDouble(), asset.height!.toDouble())
|
||||
: null,
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
onTapUp: _onTapUp,
|
||||
heroAttributes: heroAttributes,
|
||||
basePosition: Alignment.center,
|
||||
disableScaleGestures: showingDetails,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
heroAttributes: heroAttributes,
|
||||
filterQuality: FilterQuality.high,
|
||||
basePosition: Alignment.center,
|
||||
disableScaleGestures: _showingDetails,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
tightMode: true,
|
||||
onPageBuild: _onPageBuild,
|
||||
enablePanAlways: true,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
child: NativeVideoViewer(
|
||||
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
||||
asset: displayAsset,
|
||||
key: _NativeVideoViewerKey(asset.heroTag),
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(
|
||||
image: getFullImageProvider(displayAsset, size: childSize),
|
||||
image: getFullImageProvider(asset, size: size),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
@@ -383,6 +379,8 @@ 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);
|
||||
@@ -396,65 +394,63 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
||||
}
|
||||
|
||||
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(),
|
||||
),
|
||||
return 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),
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
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(minHeight: viewportHeight - snapTarget),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,42 @@
|
||||
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/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.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 {
|
||||
const AssetStackRow({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
|
||||
if (stackChildren == null || stackChildren.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
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});
|
||||
const AssetStackRow({super.key, 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);
|
||||
}),
|
||||
if (stack.isEmpty) {
|
||||
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);
|
||||
|
||||
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);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -67,8 +56,9 @@ class _StackItem extends ConsumerStatefulWidget {
|
||||
|
||||
class _StackItemState extends ConsumerState<_StackItem> {
|
||||
void _onTap() {
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
|
||||
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
|
||||
final notifier = ref.read(assetViewerProvider.notifier);
|
||||
notifier.setAsset(widget.asset);
|
||||
notifier.setStackIndex(widget.index);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -17,13 +17,10 @@ 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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_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/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.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';
|
||||
@@ -72,15 +69,7 @@ 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);
|
||||
}
|
||||
@@ -91,6 +80,8 @@ 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;
|
||||
|
||||
@@ -102,7 +93,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +103,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
|
||||
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
|
||||
@@ -134,6 +127,26 @@ 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();
|
||||
@@ -153,7 +166,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
void _handleCasting() {
|
||||
if (!ref.read(castProvider).isCasting) return;
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (asset == null) return;
|
||||
|
||||
if (asset is RemoteAsset) {
|
||||
@@ -195,17 +208,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
var index = _pageController.page?.round() ?? 0;
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -221,7 +236,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final newAsset = await timelineService.getAssetAsync(index);
|
||||
if (newAsset == null) return;
|
||||
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
|
||||
// Do not reload if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
||||
@@ -258,25 +273,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
_setSystemUIMode(controls, details);
|
||||
});
|
||||
|
||||
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(),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGestureDetectorScope(
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollEndNotification>(
|
||||
onNotification: _onScrollEnd,
|
||||
child: PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
@@ -286,21 +302,20 @@ 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ 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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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';
|
||||
@@ -21,7 +20,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -65,9 +64,9 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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';
|
||||
@@ -11,420 +9,225 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.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/video_viewer_controls.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/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:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
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');
|
||||
class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||
final BaseAsset asset;
|
||||
final int playbackDelayFactor;
|
||||
final bool isCurrent;
|
||||
final bool showControls;
|
||||
final Widget image;
|
||||
|
||||
const NativeVideoViewer({super.key, required this.asset, required this.image, this.playbackDelayFactor = 1});
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.image,
|
||||
this.isCurrent = false,
|
||||
this.showControls = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useState<NativeVideoPlayerController?>(null);
|
||||
final lastVideoPosition = useRef(-1);
|
||||
final isBuffering = useRef(false);
|
||||
ConsumerState<NativeVideoViewer> createState() => _NativeVideoViewerState();
|
||||
}
|
||||
|
||||
// Used to track whether the video should play when the app
|
||||
// is brought back to the foreground
|
||||
final shouldPlayOnForeground = useRef(true);
|
||||
class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
|
||||
static final _log = Logger('NativeVideoViewer');
|
||||
|
||||
// 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);
|
||||
NativeVideoPlayerController? _controller;
|
||||
late final Future<VideoSource?> _videoSource;
|
||||
Timer? _loadTimer;
|
||||
bool _isVideoReady = false;
|
||||
bool _shouldPlayOnForeground = true;
|
||||
|
||||
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
|
||||
final isVisible = useState(Platform.isIOS && asset.hasLocal);
|
||||
VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
|
||||
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 Stack(
|
||||
children: [
|
||||
// This remains under the video to avoid flickering
|
||||
// For motion videos, this is the image portion of the asset
|
||||
Center(child: image),
|
||||
if (aspectRatio.value != null && !isCasting)
|
||||
Visibility.maintain(
|
||||
visible: isVisible.value,
|
||||
child: NativeVideoPlayerView(onViewReady: initController),
|
||||
),
|
||||
const Center(child: VideoViewerControls()),
|
||||
],
|
||||
);
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_videoSource = _createSource();
|
||||
}
|
||||
|
||||
Future<void> _onPauseChange(
|
||||
BuildContext context,
|
||||
NativeVideoPlayerController controller,
|
||||
Debouncer seekDebouncer,
|
||||
bool isPaused,
|
||||
) async {
|
||||
if (!context.mounted) {
|
||||
@override
|
||||
void didUpdateWidget(NativeVideoViewer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return;
|
||||
|
||||
if (!widget.isCurrent) {
|
||||
_loadTimer?.cancel();
|
||||
_notifier.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the last seek is complete before pausing or playing
|
||||
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
||||
if (seekDebouncer.isActive) {
|
||||
await seekDebouncer.drain();
|
||||
}
|
||||
// Prevent unnecessary loading when swiping between assets.
|
||||
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
|
||||
}
|
||||
|
||||
try {
|
||||
if (isPaused) {
|
||||
await controller.pause();
|
||||
} else {
|
||||
await controller.play();
|
||||
}
|
||||
} catch (error) {
|
||||
log.severe('Error pausing or playing video: $error');
|
||||
@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), (_, __) {});
|
||||
|
||||
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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
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/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/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/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, this.hideTimerDuration = const Duration(seconds: 5)});
|
||||
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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 videoPlayerName = asset.heroTag;
|
||||
final assetIsVideo = asset.isVideo;
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
|
||||
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
|
||||
|
||||
final cast = ref.watch(castProvider);
|
||||
|
||||
@@ -32,14 +29,14 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||
|
||||
// Do not hide on paused
|
||||
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
|
||||
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
});
|
||||
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
|
||||
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
@@ -47,9 +44,11 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||
}
|
||||
|
||||
// When we change position, show or hide timer
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
// When playback starts, reset the hide timer
|
||||
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
|
||||
if (next == VideoPlaybackStatus.playing) {
|
||||
hideTimer.reset();
|
||||
}
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
@@ -57,34 +56,30 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
showControlsAndStartHideTimer();
|
||||
|
||||
if (cast.isCasting) {
|
||||
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);
|
||||
switch (cast.castState) {
|
||||
case CastState.playing:
|
||||
ref.read(castProvider.notifier).pause();
|
||||
case CastState.paused:
|
||||
ref.read(castProvider.notifier).play();
|
||||
default:
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
|
||||
switch (status) {
|
||||
case VideoPlaybackStatus.playing:
|
||||
notifier.pause();
|
||||
case VideoPlaybackStatus.completed:
|
||||
notifier.restart();
|
||||
default:
|
||||
notifier.play();
|
||||
}
|
||||
}
|
||||
|
||||
void toggleControlsVisibility() {
|
||||
if (showBuffering) {
|
||||
return;
|
||||
}
|
||||
if (showBuffering) return;
|
||||
|
||||
if (showControls) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
} else {
|
||||
@@ -105,9 +100,9 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isFinished: status == VideoPlaybackStatus.completed,
|
||||
isPlaying:
|
||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
show: assetIsVideo && showControls,
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||
|
||||
class ViewerBottomAppBar extends ConsumerWidget {
|
||||
@@ -9,24 +8,12 @@ class ViewerBottomAppBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0.0;
|
||||
}
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 1.0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: Durations.short2,
|
||||
child: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [AssetStackRow(), ViewerBottomBar()],
|
||||
),
|
||||
),
|
||||
child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const 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/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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(currentAssetNotifier);
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -8,10 +8,9 @@ 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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.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';
|
||||
@@ -22,7 +21,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -35,16 +34,13 @@ 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));
|
||||
}
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0.0;
|
||||
}
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.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,12 +13,14 @@ 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,
|
||||
});
|
||||
@@ -37,32 +39,35 @@ 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 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) {
|
||||
if (phoneAspectRatio * .75 < r && phoneAspectRatio * 1.25 > r) {
|
||||
// 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));
|
||||
} else {
|
||||
return SizedBox(
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
|
||||
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: r,
|
||||
child: NativeVideoViewer(
|
||||
key: ValueKey(asset.id),
|
||||
asset: asset,
|
||||
playbackDelayFactor: 2,
|
||||
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
|
||||
isCurrent: isCurrent,
|
||||
showControls: false,
|
||||
image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (showTitle)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
class AssetViewerState {
|
||||
@@ -68,6 +70,12 @@ 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();
|
||||
}
|
||||
|
||||
@@ -75,10 +83,8 @@ 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);
|
||||
}
|
||||
|
||||
@@ -95,7 +101,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
}
|
||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||
if (showing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
final heroTag = state.currentAsset?.heroTag;
|
||||
if (heroTag != null) {
|
||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,3 +135,10 @@ 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);
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
200
mobile/lib/providers/asset_viewer/video_player_provider.dart
Normal file
200
mobile/lib/providers/asset_viewer/video_player_provider.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||
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(currentAssetNotifier)) {
|
||||
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
|
||||
BaseAsset asset => {asset},
|
||||
null => const {},
|
||||
},
|
||||
@@ -307,7 +307,10 @@ class ActionNotifier extends Notifier<void> {
|
||||
// does not update the currentAsset which means
|
||||
// the exif provider will not be refreshed automatically
|
||||
if (source == ActionSource.viewer) {
|
||||
ref.invalidate(currentAssetExifProvider);
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset != null) {
|
||||
ref.invalidate(assetExifProvider(currentAsset));
|
||||
}
|
||||
}
|
||||
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
@@ -409,7 +412,6 @@ 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,52 +1,8 @@
|
||||
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 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);
|
||||
final assetExifProvider = FutureProvider.autoDispose.family<ExifInfo?, BaseAsset>((ref, asset) {
|
||||
return ref.watch(assetServiceProvider).getExif(asset);
|
||||
});
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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) const VideoControls(),
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()),
|
||||
BottomNavigationBar(
|
||||
elevation: 0.0,
|
||||
backgroundColor: Colors.transparent,
|
||||
|
||||
@@ -3,23 +3,27 @@ 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_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
|
||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
final String videoId;
|
||||
final Duration hideTimerDuration;
|
||||
|
||||
const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
|
||||
const CustomVideoPlayerControls({
|
||||
super.key,
|
||||
required this.videoId,
|
||||
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 VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||
final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status));
|
||||
|
||||
final cast = ref.watch(castProvider);
|
||||
|
||||
@@ -28,14 +32,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
final s = ref.read(videoPlayerProvider(videoId)).status;
|
||||
|
||||
// Do not hide on paused
|
||||
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
|
||||
if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
}
|
||||
});
|
||||
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
|
||||
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
@@ -43,9 +47,11 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
ref.read(showControlsProvider.notifier).show = true;
|
||||
}
|
||||
|
||||
// When we change position, show or hide timer
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
// When playback starts, reset the hide timer
|
||||
ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) {
|
||||
if (next == VideoPlaybackStatus.playing) {
|
||||
hideTimer.reset();
|
||||
}
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
@@ -68,12 +74,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else if (state == VideoPlaybackState.completed) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).restart();
|
||||
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||
if (status == VideoPlaybackStatus.playing) {
|
||||
notifier.pause();
|
||||
} else if (status == VideoPlaybackStatus.completed) {
|
||||
notifier.restart();
|
||||
} else {
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
notifier.play();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +99,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isFinished: status == VideoPlaybackStatus.completed,
|
||||
isPlaying:
|
||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
show: assetIsVideo && showControls,
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
|
||||
@@ -3,15 +3,20 @@ 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 [videoPlayerControlsProvider]
|
||||
/// The video controls for the [videoPlayerProvider]
|
||||
class VideoControls extends ConsumerWidget {
|
||||
const VideoControls({super.key});
|
||||
final String videoPlayerName;
|
||||
|
||||
const VideoControls({super.key, required this.videoPlayerName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isPortrait = context.orientation == Orientation.portrait;
|
||||
return isPortrait
|
||||
? const VideoPosition()
|
||||
: const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition());
|
||||
? VideoPosition(videoPlayerName: videoPlayerName)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 60.0),
|
||||
child: VideoPosition(videoPlayerName: videoPlayerName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ 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_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
||||
|
||||
class VideoPosition extends HookConsumerWidget {
|
||||
const VideoPosition({super.key});
|
||||
final String videoPlayerName;
|
||||
|
||||
const VideoPosition({super.key, required this.videoPlayerName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -18,7 +19,7 @@ class VideoPosition extends HookConsumerWidget {
|
||||
|
||||
final (position, duration) = isCasting
|
||||
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
||||
: ref.watch(videoPlaybackValueProvider.select((v) => (v.position, v.duration)));
|
||||
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
|
||||
|
||||
final wasPlaying = useRef<bool>(true);
|
||||
return duration == Duration.zero
|
||||
@@ -44,13 +45,13 @@ class VideoPosition extends HookConsumerWidget {
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: whiteOpacity75,
|
||||
onChangeStart: (value) {
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
wasPlaying.value = state != VideoPlaybackState.paused;
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||
wasPlaying.value = status != VideoPlaybackStatus.paused;
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
if (wasPlaying.value) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
|
||||
}
|
||||
},
|
||||
onChanged: (value) {
|
||||
@@ -61,10 +62,7 @@ class VideoPosition extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -34,9 +33,6 @@ 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));
|
||||
},
|
||||
|
||||
22
mobile/openapi/README.md
generated
22
mobile/openapi/README.md
generated
@@ -211,8 +211,8 @@ Class | Method | HTTP request | Description
|
||||
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
|
||||
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
||||
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
||||
*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers
|
||||
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
|
||||
*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods
|
||||
*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **GET** /plugins | List all plugins
|
||||
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
|
||||
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
|
||||
*QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs
|
||||
@@ -323,7 +323,8 @@ Class | Method | HTTP request | Description
|
||||
*WorkflowsApi* | [**createWorkflow**](doc//WorkflowsApi.md#createworkflow) | **POST** /workflows | Create a workflow
|
||||
*WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow
|
||||
*WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow
|
||||
*WorkflowsApi* | [**getWorkflows**](doc//WorkflowsApi.md#getworkflows) | **GET** /workflows | List all workflows
|
||||
*WorkflowsApi* | [**getWorkflowTriggers**](doc//WorkflowsApi.md#getworkflowtriggers) | **GET** /workflows/triggers | List all workflow triggers
|
||||
*WorkflowsApi* | [**searchWorkflows**](doc//WorkflowsApi.md#searchworkflows) | **GET** /workflows | List all workflows
|
||||
*WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow
|
||||
|
||||
|
||||
@@ -498,12 +499,8 @@ Class | Method | HTTP request | Description
|
||||
- [PinCodeResetDto](doc//PinCodeResetDto.md)
|
||||
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
|
||||
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
||||
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
|
||||
- [PluginContextType](doc//PluginContextType.md)
|
||||
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
|
||||
- [PluginMethodResponseDto](doc//PluginMethodResponseDto.md)
|
||||
- [PluginResponseDto](doc//PluginResponseDto.md)
|
||||
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
|
||||
- [PluginTriggerType](doc//PluginTriggerType.md)
|
||||
- [PurchaseResponse](doc//PurchaseResponse.md)
|
||||
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
||||
- [QueueCommand](doc//QueueCommand.md)
|
||||
@@ -675,12 +672,13 @@ Class | Method | HTTP request | Description
|
||||
- [VersionCheckStateResponseDto](doc//VersionCheckStateResponseDto.md)
|
||||
- [VideoCodec](doc//VideoCodec.md)
|
||||
- [VideoContainer](doc//VideoContainer.md)
|
||||
- [WorkflowActionItemDto](doc//WorkflowActionItemDto.md)
|
||||
- [WorkflowActionResponseDto](doc//WorkflowActionResponseDto.md)
|
||||
- [WorkflowCreateDto](doc//WorkflowCreateDto.md)
|
||||
- [WorkflowFilterItemDto](doc//WorkflowFilterItemDto.md)
|
||||
- [WorkflowFilterResponseDto](doc//WorkflowFilterResponseDto.md)
|
||||
- [WorkflowResponseDto](doc//WorkflowResponseDto.md)
|
||||
- [WorkflowStepDto](doc//WorkflowStepDto.md)
|
||||
- [WorkflowStepResponseDto](doc//WorkflowStepResponseDto.md)
|
||||
- [WorkflowTrigger](doc//WorkflowTrigger.md)
|
||||
- [WorkflowTriggerResponseDto](doc//WorkflowTriggerResponseDto.md)
|
||||
- [WorkflowType](doc//WorkflowType.md)
|
||||
- [WorkflowUpdateDto](doc//WorkflowUpdateDto.md)
|
||||
|
||||
|
||||
|
||||
15
mobile/openapi/lib/api.dart
generated
15
mobile/openapi/lib/api.dart
generated
@@ -237,12 +237,8 @@ part 'model/pin_code_change_dto.dart';
|
||||
part 'model/pin_code_reset_dto.dart';
|
||||
part 'model/pin_code_setup_dto.dart';
|
||||
part 'model/places_response_dto.dart';
|
||||
part 'model/plugin_action_response_dto.dart';
|
||||
part 'model/plugin_context_type.dart';
|
||||
part 'model/plugin_filter_response_dto.dart';
|
||||
part 'model/plugin_method_response_dto.dart';
|
||||
part 'model/plugin_response_dto.dart';
|
||||
part 'model/plugin_trigger_response_dto.dart';
|
||||
part 'model/plugin_trigger_type.dart';
|
||||
part 'model/purchase_response.dart';
|
||||
part 'model/purchase_update.dart';
|
||||
part 'model/queue_command.dart';
|
||||
@@ -414,12 +410,13 @@ part 'model/validate_library_response_dto.dart';
|
||||
part 'model/version_check_state_response_dto.dart';
|
||||
part 'model/video_codec.dart';
|
||||
part 'model/video_container.dart';
|
||||
part 'model/workflow_action_item_dto.dart';
|
||||
part 'model/workflow_action_response_dto.dart';
|
||||
part 'model/workflow_create_dto.dart';
|
||||
part 'model/workflow_filter_item_dto.dart';
|
||||
part 'model/workflow_filter_response_dto.dart';
|
||||
part 'model/workflow_response_dto.dart';
|
||||
part 'model/workflow_step_dto.dart';
|
||||
part 'model/workflow_step_response_dto.dart';
|
||||
part 'model/workflow_trigger.dart';
|
||||
part 'model/workflow_trigger_response_dto.dart';
|
||||
part 'model/workflow_type.dart';
|
||||
part 'model/workflow_update_dto.dart';
|
||||
|
||||
|
||||
|
||||
149
mobile/openapi/lib/api/plugins_api.dart
generated
149
mobile/openapi/lib/api/plugins_api.dart
generated
@@ -73,14 +73,36 @@ class PluginsApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all plugin triggers
|
||||
/// Retrieve plugin methods
|
||||
///
|
||||
/// Retrieve a list of all available plugin triggers.
|
||||
/// Retrieve a list of plugin methods
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getPluginTriggersWithHttpInfo() async {
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] description:
|
||||
///
|
||||
/// * [bool] enabled:
|
||||
/// Whether the plugin method is enabled
|
||||
///
|
||||
/// * [String] id:
|
||||
/// Plugin method ID
|
||||
///
|
||||
/// * [String] name:
|
||||
///
|
||||
/// * [String] pluginName:
|
||||
///
|
||||
/// * [String] pluginVersion:
|
||||
///
|
||||
/// * [String] title:
|
||||
///
|
||||
/// * [WorkflowTrigger] trigger:
|
||||
///
|
||||
/// * [WorkflowType] type:
|
||||
Future<Response> searchPluginMethodsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, String? pluginName, String? pluginVersion, String? title, WorkflowTrigger? trigger, WorkflowType? type, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/plugins/triggers';
|
||||
final apiPath = r'/plugins/methods';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
@@ -89,6 +111,34 @@ class PluginsApi {
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (description != null) {
|
||||
queryParams.addAll(_queryParams('', 'description', description));
|
||||
}
|
||||
if (enabled != null) {
|
||||
queryParams.addAll(_queryParams('', 'enabled', enabled));
|
||||
}
|
||||
if (id != null) {
|
||||
queryParams.addAll(_queryParams('', 'id', id));
|
||||
}
|
||||
if (name != null) {
|
||||
queryParams.addAll(_queryParams('', 'name', name));
|
||||
}
|
||||
if (pluginName != null) {
|
||||
queryParams.addAll(_queryParams('', 'pluginName', pluginName));
|
||||
}
|
||||
if (pluginVersion != null) {
|
||||
queryParams.addAll(_queryParams('', 'pluginVersion', pluginVersion));
|
||||
}
|
||||
if (title != null) {
|
||||
queryParams.addAll(_queryParams('', 'title', title));
|
||||
}
|
||||
if (trigger != null) {
|
||||
queryParams.addAll(_queryParams('', 'trigger', trigger));
|
||||
}
|
||||
if (type != null) {
|
||||
queryParams.addAll(_queryParams('', 'type', type));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
@@ -103,11 +153,33 @@ class PluginsApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// List all plugin triggers
|
||||
/// Retrieve plugin methods
|
||||
///
|
||||
/// Retrieve a list of all available plugin triggers.
|
||||
Future<List<PluginTriggerResponseDto>?> getPluginTriggers() async {
|
||||
final response = await getPluginTriggersWithHttpInfo();
|
||||
/// Retrieve a list of plugin methods
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] description:
|
||||
///
|
||||
/// * [bool] enabled:
|
||||
/// Whether the plugin method is enabled
|
||||
///
|
||||
/// * [String] id:
|
||||
/// Plugin method ID
|
||||
///
|
||||
/// * [String] name:
|
||||
///
|
||||
/// * [String] pluginName:
|
||||
///
|
||||
/// * [String] pluginVersion:
|
||||
///
|
||||
/// * [String] title:
|
||||
///
|
||||
/// * [WorkflowTrigger] trigger:
|
||||
///
|
||||
/// * [WorkflowType] type:
|
||||
Future<List<PluginMethodResponseDto>?> searchPluginMethods({ String? description, bool? enabled, String? id, String? name, String? pluginName, String? pluginVersion, String? title, WorkflowTrigger? trigger, WorkflowType? type, }) async {
|
||||
final response = await searchPluginMethodsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, pluginName: pluginName, pluginVersion: pluginVersion, title: title, trigger: trigger, type: type, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -116,8 +188,8 @@ class PluginsApi {
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTriggerResponseDto>') as List)
|
||||
.cast<PluginTriggerResponseDto>()
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<PluginMethodResponseDto>') as List)
|
||||
.cast<PluginMethodResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
@@ -129,7 +201,23 @@ class PluginsApi {
|
||||
/// Retrieve a list of plugins available to the authenticated user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getPluginsWithHttpInfo() async {
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] description:
|
||||
///
|
||||
/// * [bool] enabled:
|
||||
/// Whether the plugin is enabled
|
||||
///
|
||||
/// * [String] id:
|
||||
/// Plugin ID
|
||||
///
|
||||
/// * [String] name:
|
||||
///
|
||||
/// * [String] title:
|
||||
///
|
||||
/// * [String] version:
|
||||
Future<Response> searchPluginsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, String? title, String? version, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/plugins';
|
||||
|
||||
@@ -140,6 +228,25 @@ class PluginsApi {
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (description != null) {
|
||||
queryParams.addAll(_queryParams('', 'description', description));
|
||||
}
|
||||
if (enabled != null) {
|
||||
queryParams.addAll(_queryParams('', 'enabled', enabled));
|
||||
}
|
||||
if (id != null) {
|
||||
queryParams.addAll(_queryParams('', 'id', id));
|
||||
}
|
||||
if (name != null) {
|
||||
queryParams.addAll(_queryParams('', 'name', name));
|
||||
}
|
||||
if (title != null) {
|
||||
queryParams.addAll(_queryParams('', 'title', title));
|
||||
}
|
||||
if (version != null) {
|
||||
queryParams.addAll(_queryParams('', 'version', version));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
@@ -157,8 +264,24 @@ class PluginsApi {
|
||||
/// List all plugins
|
||||
///
|
||||
/// Retrieve a list of plugins available to the authenticated user.
|
||||
Future<List<PluginResponseDto>?> getPlugins() async {
|
||||
final response = await getPluginsWithHttpInfo();
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] description:
|
||||
///
|
||||
/// * [bool] enabled:
|
||||
/// Whether the plugin is enabled
|
||||
///
|
||||
/// * [String] id:
|
||||
/// Plugin ID
|
||||
///
|
||||
/// * [String] name:
|
||||
///
|
||||
/// * [String] title:
|
||||
///
|
||||
/// * [String] version:
|
||||
Future<List<PluginResponseDto>?> searchPlugins({ String? description, bool? enabled, String? id, String? name, String? title, String? version, }) async {
|
||||
final response = await searchPluginsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, title: title, version: version, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
113
mobile/openapi/lib/api/workflows_api.dart
generated
113
mobile/openapi/lib/api/workflows_api.dart
generated
@@ -178,14 +178,14 @@ class WorkflowsApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all workflows
|
||||
/// List all workflow triggers
|
||||
///
|
||||
/// Retrieve a list of workflows available to the authenticated user.
|
||||
/// Retrieve a list of all available workflow triggers.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getWorkflowsWithHttpInfo() async {
|
||||
Future<Response> getWorkflowTriggersWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows';
|
||||
final apiPath = r'/workflows/triggers';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
@@ -208,11 +208,112 @@ class WorkflowsApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// List all workflow triggers
|
||||
///
|
||||
/// Retrieve a list of all available workflow triggers.
|
||||
Future<List<WorkflowTriggerResponseDto>?> getWorkflowTriggers() async {
|
||||
final response = await getWorkflowTriggersWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<WorkflowTriggerResponseDto>') as List)
|
||||
.cast<WorkflowTriggerResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all workflows
|
||||
///
|
||||
/// Retrieve a list of workflows available to the authenticated user.
|
||||
Future<List<WorkflowResponseDto>?> getWorkflows() async {
|
||||
final response = await getWorkflowsWithHttpInfo();
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] description:
|
||||
/// Workflow description
|
||||
///
|
||||
/// * [bool] enabled:
|
||||
/// Workflow enabled
|
||||
///
|
||||
/// * [String] id:
|
||||
/// Workflow ID
|
||||
///
|
||||
/// * [String] name:
|
||||
/// Workflow name
|
||||
///
|
||||
/// * [WorkflowTrigger] trigger:
|
||||
/// Workflow trigger type
|
||||
Future<Response> searchWorkflowsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (description != null) {
|
||||
queryParams.addAll(_queryParams('', 'description', description));
|
||||
}
|
||||
if (enabled != null) {
|
||||
queryParams.addAll(_queryParams('', 'enabled', enabled));
|
||||
}
|
||||
if (id != null) {
|
||||
queryParams.addAll(_queryParams('', 'id', id));
|
||||
}
|
||||
if (name != null) {
|
||||
queryParams.addAll(_queryParams('', 'name', name));
|
||||
}
|
||||
if (trigger != null) {
|
||||
queryParams.addAll(_queryParams('', 'trigger', trigger));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// List all workflows
|
||||
///
|
||||
/// Retrieve a list of workflows available to the authenticated user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] description:
|
||||
/// Workflow description
|
||||
///
|
||||
/// * [bool] enabled:
|
||||
/// Workflow enabled
|
||||
///
|
||||
/// * [String] id:
|
||||
/// Workflow ID
|
||||
///
|
||||
/// * [String] name:
|
||||
/// Workflow name
|
||||
///
|
||||
/// * [WorkflowTrigger] trigger:
|
||||
/// Workflow trigger type
|
||||
Future<List<WorkflowResponseDto>?> searchWorkflows({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, }) async {
|
||||
final response = await searchWorkflowsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, trigger: trigger, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
30
mobile/openapi/lib/api_client.dart
generated
30
mobile/openapi/lib/api_client.dart
generated
@@ -520,18 +520,10 @@ class ApiClient {
|
||||
return PinCodeSetupDto.fromJson(value);
|
||||
case 'PlacesResponseDto':
|
||||
return PlacesResponseDto.fromJson(value);
|
||||
case 'PluginActionResponseDto':
|
||||
return PluginActionResponseDto.fromJson(value);
|
||||
case 'PluginContextType':
|
||||
return PluginContextTypeTypeTransformer().decode(value);
|
||||
case 'PluginFilterResponseDto':
|
||||
return PluginFilterResponseDto.fromJson(value);
|
||||
case 'PluginMethodResponseDto':
|
||||
return PluginMethodResponseDto.fromJson(value);
|
||||
case 'PluginResponseDto':
|
||||
return PluginResponseDto.fromJson(value);
|
||||
case 'PluginTriggerResponseDto':
|
||||
return PluginTriggerResponseDto.fromJson(value);
|
||||
case 'PluginTriggerType':
|
||||
return PluginTriggerTypeTypeTransformer().decode(value);
|
||||
case 'PurchaseResponse':
|
||||
return PurchaseResponse.fromJson(value);
|
||||
case 'PurchaseUpdate':
|
||||
@@ -874,18 +866,20 @@ class ApiClient {
|
||||
return VideoCodecTypeTransformer().decode(value);
|
||||
case 'VideoContainer':
|
||||
return VideoContainerTypeTransformer().decode(value);
|
||||
case 'WorkflowActionItemDto':
|
||||
return WorkflowActionItemDto.fromJson(value);
|
||||
case 'WorkflowActionResponseDto':
|
||||
return WorkflowActionResponseDto.fromJson(value);
|
||||
case 'WorkflowCreateDto':
|
||||
return WorkflowCreateDto.fromJson(value);
|
||||
case 'WorkflowFilterItemDto':
|
||||
return WorkflowFilterItemDto.fromJson(value);
|
||||
case 'WorkflowFilterResponseDto':
|
||||
return WorkflowFilterResponseDto.fromJson(value);
|
||||
case 'WorkflowResponseDto':
|
||||
return WorkflowResponseDto.fromJson(value);
|
||||
case 'WorkflowStepDto':
|
||||
return WorkflowStepDto.fromJson(value);
|
||||
case 'WorkflowStepResponseDto':
|
||||
return WorkflowStepResponseDto.fromJson(value);
|
||||
case 'WorkflowTrigger':
|
||||
return WorkflowTriggerTypeTransformer().decode(value);
|
||||
case 'WorkflowTriggerResponseDto':
|
||||
return WorkflowTriggerResponseDto.fromJson(value);
|
||||
case 'WorkflowType':
|
||||
return WorkflowTypeTypeTransformer().decode(value);
|
||||
case 'WorkflowUpdateDto':
|
||||
return WorkflowUpdateDto.fromJson(value);
|
||||
default:
|
||||
|
||||
12
mobile/openapi/lib/api_helper.dart
generated
12
mobile/openapi/lib/api_helper.dart
generated
@@ -130,12 +130,6 @@ String parameterToString(dynamic value) {
|
||||
if (value is Permission) {
|
||||
return PermissionTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is PluginContextType) {
|
||||
return PluginContextTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is PluginTriggerType) {
|
||||
return PluginTriggerTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is QueueCommand) {
|
||||
return QueueCommandTypeTransformer().encode(value).toString();
|
||||
}
|
||||
@@ -193,6 +187,12 @@ String parameterToString(dynamic value) {
|
||||
if (value is VideoContainer) {
|
||||
return VideoContainerTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is WorkflowTrigger) {
|
||||
return WorkflowTriggerTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is WorkflowType) {
|
||||
return WorkflowTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
|
||||
6
mobile/openapi/lib/model/job_name.dart
generated
6
mobile/openapi/lib/model/job_name.dart
generated
@@ -78,7 +78,7 @@ class JobName {
|
||||
static const versionCheck = JobName._(r'VersionCheck');
|
||||
static const ocrQueueAll = JobName._(r'OcrQueueAll');
|
||||
static const ocr = JobName._(r'Ocr');
|
||||
static const workflowRun = JobName._(r'WorkflowRun');
|
||||
static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate');
|
||||
|
||||
/// List of all possible values in this [enum][JobName].
|
||||
static const values = <JobName>[
|
||||
@@ -137,7 +137,7 @@ class JobName {
|
||||
versionCheck,
|
||||
ocrQueueAll,
|
||||
ocr,
|
||||
workflowRun,
|
||||
workflowAssetCreate,
|
||||
];
|
||||
|
||||
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
|
||||
@@ -231,7 +231,7 @@ class JobNameTypeTransformer {
|
||||
case r'VersionCheck': return JobName.versionCheck;
|
||||
case r'OcrQueueAll': return JobName.ocrQueueAll;
|
||||
case r'Ocr': return JobName.ocr;
|
||||
case r'WorkflowRun': return JobName.workflowRun;
|
||||
case r'WorkflowAssetCreate': return JobName.workflowAssetCreate;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
||||
158
mobile/openapi/lib/model/plugin_action_response_dto.dart
generated
158
mobile/openapi/lib/model/plugin_action_response_dto.dart
generated
@@ -1,158 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class PluginActionResponseDto {
|
||||
/// Returns a new [PluginActionResponseDto] instance.
|
||||
PluginActionResponseDto({
|
||||
required this.description,
|
||||
required this.id,
|
||||
required this.methodName,
|
||||
required this.pluginId,
|
||||
required this.schema,
|
||||
this.supportedContexts = const [],
|
||||
required this.title,
|
||||
});
|
||||
|
||||
/// Action description
|
||||
String description;
|
||||
|
||||
/// Action ID
|
||||
String id;
|
||||
|
||||
/// Method name
|
||||
String methodName;
|
||||
|
||||
/// Plugin ID
|
||||
String pluginId;
|
||||
|
||||
/// Action schema
|
||||
Object? schema;
|
||||
|
||||
/// Supported contexts
|
||||
List<PluginContextType> supportedContexts;
|
||||
|
||||
/// Action title
|
||||
String title;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginActionResponseDto &&
|
||||
other.description == description &&
|
||||
other.id == id &&
|
||||
other.methodName == methodName &&
|
||||
other.pluginId == pluginId &&
|
||||
other.schema == schema &&
|
||||
_deepEquality.equals(other.supportedContexts, supportedContexts) &&
|
||||
other.title == title;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(description.hashCode) +
|
||||
(id.hashCode) +
|
||||
(methodName.hashCode) +
|
||||
(pluginId.hashCode) +
|
||||
(schema == null ? 0 : schema!.hashCode) +
|
||||
(supportedContexts.hashCode) +
|
||||
(title.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginActionResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'description'] = this.description;
|
||||
json[r'id'] = this.id;
|
||||
json[r'methodName'] = this.methodName;
|
||||
json[r'pluginId'] = this.pluginId;
|
||||
if (this.schema != null) {
|
||||
json[r'schema'] = this.schema;
|
||||
} else {
|
||||
// json[r'schema'] = null;
|
||||
}
|
||||
json[r'supportedContexts'] = this.supportedContexts;
|
||||
json[r'title'] = this.title;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [PluginActionResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static PluginActionResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "PluginActionResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PluginActionResponseDto(
|
||||
description: mapValueOfType<String>(json, r'description')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
methodName: mapValueOfType<String>(json, r'methodName')!,
|
||||
pluginId: mapValueOfType<String>(json, r'pluginId')!,
|
||||
schema: mapValueOfType<Object>(json, r'schema'),
|
||||
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<PluginActionResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginActionResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginActionResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, PluginActionResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PluginActionResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = PluginActionResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of PluginActionResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PluginActionResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PluginActionResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = PluginActionResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'description',
|
||||
'id',
|
||||
'methodName',
|
||||
'pluginId',
|
||||
'schema',
|
||||
'supportedContexts',
|
||||
'title',
|
||||
};
|
||||
}
|
||||
|
||||
88
mobile/openapi/lib/model/plugin_context_type.dart
generated
88
mobile/openapi/lib/model/plugin_context_type.dart
generated
@@ -1,88 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
/// Context type
|
||||
class PluginContextType {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const PluginContextType._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const asset = PluginContextType._(r'asset');
|
||||
static const album = PluginContextType._(r'album');
|
||||
static const person = PluginContextType._(r'person');
|
||||
|
||||
/// List of all possible values in this [enum][PluginContextType].
|
||||
static const values = <PluginContextType>[
|
||||
asset,
|
||||
album,
|
||||
person,
|
||||
];
|
||||
|
||||
static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value);
|
||||
|
||||
static List<PluginContextType> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginContextType>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginContextType.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [PluginContextType] to String,
|
||||
/// and [decode] dynamic data back to [PluginContextType].
|
||||
class PluginContextTypeTypeTransformer {
|
||||
factory PluginContextTypeTypeTransformer() => _instance ??= const PluginContextTypeTypeTransformer._();
|
||||
|
||||
const PluginContextTypeTypeTransformer._();
|
||||
|
||||
String encode(PluginContextType data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a PluginContextType.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
PluginContextType? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'asset': return PluginContextType.asset;
|
||||
case r'album': return PluginContextType.album;
|
||||
case r'person': return PluginContextType.person;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [PluginContextTypeTypeTransformer] instance.
|
||||
static PluginContextTypeTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
@@ -10,105 +10,97 @@
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class PluginFilterResponseDto {
|
||||
/// Returns a new [PluginFilterResponseDto] instance.
|
||||
PluginFilterResponseDto({
|
||||
class PluginMethodResponseDto {
|
||||
/// Returns a new [PluginMethodResponseDto] instance.
|
||||
PluginMethodResponseDto({
|
||||
required this.description,
|
||||
required this.id,
|
||||
required this.methodName,
|
||||
required this.pluginId,
|
||||
required this.key,
|
||||
required this.name,
|
||||
required this.schema,
|
||||
this.supportedContexts = const [],
|
||||
required this.title,
|
||||
this.types = const [],
|
||||
});
|
||||
|
||||
/// Filter description
|
||||
/// Description
|
||||
String description;
|
||||
|
||||
/// Filter ID
|
||||
String id;
|
||||
/// Key
|
||||
String key;
|
||||
|
||||
/// Method name
|
||||
String methodName;
|
||||
/// Name
|
||||
String name;
|
||||
|
||||
/// Plugin ID
|
||||
String pluginId;
|
||||
|
||||
/// Filter schema
|
||||
/// Schema
|
||||
Object? schema;
|
||||
|
||||
/// Supported contexts
|
||||
List<PluginContextType> supportedContexts;
|
||||
|
||||
/// Filter title
|
||||
/// Title
|
||||
String title;
|
||||
|
||||
/// Workflow types
|
||||
List<WorkflowType> types;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginFilterResponseDto &&
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginMethodResponseDto &&
|
||||
other.description == description &&
|
||||
other.id == id &&
|
||||
other.methodName == methodName &&
|
||||
other.pluginId == pluginId &&
|
||||
other.key == key &&
|
||||
other.name == name &&
|
||||
other.schema == schema &&
|
||||
_deepEquality.equals(other.supportedContexts, supportedContexts) &&
|
||||
other.title == title;
|
||||
other.title == title &&
|
||||
_deepEquality.equals(other.types, types);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(description.hashCode) +
|
||||
(id.hashCode) +
|
||||
(methodName.hashCode) +
|
||||
(pluginId.hashCode) +
|
||||
(key.hashCode) +
|
||||
(name.hashCode) +
|
||||
(schema == null ? 0 : schema!.hashCode) +
|
||||
(supportedContexts.hashCode) +
|
||||
(title.hashCode);
|
||||
(title.hashCode) +
|
||||
(types.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginFilterResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]';
|
||||
String toString() => 'PluginMethodResponseDto[description=$description, key=$key, name=$name, schema=$schema, title=$title, types=$types]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'description'] = this.description;
|
||||
json[r'id'] = this.id;
|
||||
json[r'methodName'] = this.methodName;
|
||||
json[r'pluginId'] = this.pluginId;
|
||||
json[r'key'] = this.key;
|
||||
json[r'name'] = this.name;
|
||||
if (this.schema != null) {
|
||||
json[r'schema'] = this.schema;
|
||||
} else {
|
||||
// json[r'schema'] = null;
|
||||
}
|
||||
json[r'supportedContexts'] = this.supportedContexts;
|
||||
json[r'title'] = this.title;
|
||||
json[r'types'] = this.types;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [PluginFilterResponseDto] instance and imports its values from
|
||||
/// Returns a new [PluginMethodResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static PluginFilterResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "PluginFilterResponseDto");
|
||||
static PluginMethodResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "PluginMethodResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PluginFilterResponseDto(
|
||||
return PluginMethodResponseDto(
|
||||
description: mapValueOfType<String>(json, r'description')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
methodName: mapValueOfType<String>(json, r'methodName')!,
|
||||
pluginId: mapValueOfType<String>(json, r'pluginId')!,
|
||||
key: mapValueOfType<String>(json, r'key')!,
|
||||
name: mapValueOfType<String>(json, r'name')!,
|
||||
schema: mapValueOfType<Object>(json, r'schema'),
|
||||
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
types: WorkflowType.listFromJson(json[r'types']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<PluginFilterResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginFilterResponseDto>[];
|
||||
static List<PluginMethodResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginMethodResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginFilterResponseDto.fromJson(row);
|
||||
final value = PluginMethodResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
@@ -117,12 +109,12 @@ class PluginFilterResponseDto {
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, PluginFilterResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PluginFilterResponseDto>{};
|
||||
static Map<String, PluginMethodResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PluginMethodResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = PluginFilterResponseDto.fromJson(entry.value);
|
||||
final value = PluginMethodResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -131,14 +123,14 @@ class PluginFilterResponseDto {
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of PluginFilterResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PluginFilterResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PluginFilterResponseDto>>{};
|
||||
// maps a json object with a list of PluginMethodResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PluginMethodResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PluginMethodResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = PluginFilterResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
map[entry.key] = PluginMethodResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
@@ -147,12 +139,11 @@ class PluginFilterResponseDto {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'description',
|
||||
'id',
|
||||
'methodName',
|
||||
'pluginId',
|
||||
'key',
|
||||
'name',
|
||||
'schema',
|
||||
'supportedContexts',
|
||||
'title',
|
||||
'types',
|
||||
};
|
||||
}
|
||||
|
||||
29
mobile/openapi/lib/model/plugin_response_dto.dart
generated
29
mobile/openapi/lib/model/plugin_response_dto.dart
generated
@@ -13,21 +13,17 @@ part of openapi.api;
|
||||
class PluginResponseDto {
|
||||
/// Returns a new [PluginResponseDto] instance.
|
||||
PluginResponseDto({
|
||||
this.actions = const [],
|
||||
required this.author,
|
||||
required this.createdAt,
|
||||
required this.description,
|
||||
this.filters = const [],
|
||||
required this.id,
|
||||
this.methods = const [],
|
||||
required this.name,
|
||||
required this.title,
|
||||
required this.updatedAt,
|
||||
required this.version,
|
||||
});
|
||||
|
||||
/// Plugin actions
|
||||
List<PluginActionResponseDto> actions;
|
||||
|
||||
/// Plugin author
|
||||
String author;
|
||||
|
||||
@@ -37,12 +33,12 @@ class PluginResponseDto {
|
||||
/// Plugin description
|
||||
String description;
|
||||
|
||||
/// Plugin filters
|
||||
List<PluginFilterResponseDto> filters;
|
||||
|
||||
/// Plugin ID
|
||||
String id;
|
||||
|
||||
/// Plugin methods
|
||||
List<PluginMethodResponseDto> methods;
|
||||
|
||||
/// Plugin name
|
||||
String name;
|
||||
|
||||
@@ -57,12 +53,11 @@ class PluginResponseDto {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginResponseDto &&
|
||||
_deepEquality.equals(other.actions, actions) &&
|
||||
other.author == author &&
|
||||
other.createdAt == createdAt &&
|
||||
other.description == description &&
|
||||
_deepEquality.equals(other.filters, filters) &&
|
||||
other.id == id &&
|
||||
_deepEquality.equals(other.methods, methods) &&
|
||||
other.name == name &&
|
||||
other.title == title &&
|
||||
other.updatedAt == updatedAt &&
|
||||
@@ -71,28 +66,26 @@ class PluginResponseDto {
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(actions.hashCode) +
|
||||
(author.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(description.hashCode) +
|
||||
(filters.hashCode) +
|
||||
(id.hashCode) +
|
||||
(methods.hashCode) +
|
||||
(name.hashCode) +
|
||||
(title.hashCode) +
|
||||
(updatedAt.hashCode) +
|
||||
(version.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginResponseDto[actions=$actions, author=$author, createdAt=$createdAt, description=$description, filters=$filters, id=$id, name=$name, title=$title, updatedAt=$updatedAt, version=$version]';
|
||||
String toString() => 'PluginResponseDto[author=$author, createdAt=$createdAt, description=$description, id=$id, methods=$methods, name=$name, title=$title, updatedAt=$updatedAt, version=$version]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'actions'] = this.actions;
|
||||
json[r'author'] = this.author;
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'description'] = this.description;
|
||||
json[r'filters'] = this.filters;
|
||||
json[r'id'] = this.id;
|
||||
json[r'methods'] = this.methods;
|
||||
json[r'name'] = this.name;
|
||||
json[r'title'] = this.title;
|
||||
json[r'updatedAt'] = this.updatedAt;
|
||||
@@ -109,12 +102,11 @@ class PluginResponseDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PluginResponseDto(
|
||||
actions: PluginActionResponseDto.listFromJson(json[r'actions']),
|
||||
author: mapValueOfType<String>(json, r'author')!,
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
description: mapValueOfType<String>(json, r'description')!,
|
||||
filters: PluginFilterResponseDto.listFromJson(json[r'filters']),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
methods: PluginMethodResponseDto.listFromJson(json[r'methods']),
|
||||
name: mapValueOfType<String>(json, r'name')!,
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
|
||||
@@ -166,12 +158,11 @@ class PluginResponseDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'actions',
|
||||
'author',
|
||||
'createdAt',
|
||||
'description',
|
||||
'filters',
|
||||
'id',
|
||||
'methods',
|
||||
'name',
|
||||
'title',
|
||||
'updatedAt',
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class PluginTriggerResponseDto {
|
||||
/// Returns a new [PluginTriggerResponseDto] instance.
|
||||
PluginTriggerResponseDto({
|
||||
required this.contextType,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
/// Context type
|
||||
PluginContextType contextType;
|
||||
|
||||
/// Trigger type
|
||||
PluginTriggerType type;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
|
||||
other.contextType == contextType &&
|
||||
other.type == type;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(contextType.hashCode) +
|
||||
(type.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginTriggerResponseDto[contextType=$contextType, type=$type]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'contextType'] = this.contextType;
|
||||
json[r'type'] = this.type;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [PluginTriggerResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static PluginTriggerResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "PluginTriggerResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PluginTriggerResponseDto(
|
||||
contextType: PluginContextType.fromJson(json[r'contextType'])!,
|
||||
type: PluginTriggerType.fromJson(json[r'type'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<PluginTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginTriggerResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginTriggerResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, PluginTriggerResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PluginTriggerResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = PluginTriggerResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PluginTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PluginTriggerResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'contextType',
|
||||
'type',
|
||||
};
|
||||
}
|
||||
|
||||
85
mobile/openapi/lib/model/plugin_trigger_type.dart
generated
85
mobile/openapi/lib/model/plugin_trigger_type.dart
generated
@@ -1,85 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
/// Trigger type
|
||||
class PluginTriggerType {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const PluginTriggerType._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const assetCreate = PluginTriggerType._(r'AssetCreate');
|
||||
static const personRecognized = PluginTriggerType._(r'PersonRecognized');
|
||||
|
||||
/// List of all possible values in this [enum][PluginTriggerType].
|
||||
static const values = <PluginTriggerType>[
|
||||
assetCreate,
|
||||
personRecognized,
|
||||
];
|
||||
|
||||
static PluginTriggerType? fromJson(dynamic value) => PluginTriggerTypeTypeTransformer().decode(value);
|
||||
|
||||
static List<PluginTriggerType> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginTriggerType>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginTriggerType.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [PluginTriggerType] to String,
|
||||
/// and [decode] dynamic data back to [PluginTriggerType].
|
||||
class PluginTriggerTypeTypeTransformer {
|
||||
factory PluginTriggerTypeTypeTransformer() => _instance ??= const PluginTriggerTypeTypeTransformer._();
|
||||
|
||||
const PluginTriggerTypeTypeTransformer._();
|
||||
|
||||
String encode(PluginTriggerType data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a PluginTriggerType.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
PluginTriggerType? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'AssetCreate': return PluginTriggerType.assetCreate;
|
||||
case r'PersonRecognized': return PluginTriggerType.personRecognized;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [PluginTriggerTypeTypeTransformer] instance.
|
||||
static PluginTriggerTypeTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
118
mobile/openapi/lib/model/workflow_action_item_dto.dart
generated
118
mobile/openapi/lib/model/workflow_action_item_dto.dart
generated
@@ -1,118 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class WorkflowActionItemDto {
|
||||
/// Returns a new [WorkflowActionItemDto] instance.
|
||||
WorkflowActionItemDto({
|
||||
this.actionConfig,
|
||||
required this.pluginActionId,
|
||||
});
|
||||
|
||||
/// Action configuration
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Object? actionConfig;
|
||||
|
||||
/// Plugin action ID
|
||||
String pluginActionId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowActionItemDto &&
|
||||
other.actionConfig == actionConfig &&
|
||||
other.pluginActionId == pluginActionId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(actionConfig == null ? 0 : actionConfig!.hashCode) +
|
||||
(pluginActionId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, pluginActionId=$pluginActionId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.actionConfig != null) {
|
||||
json[r'actionConfig'] = this.actionConfig;
|
||||
} else {
|
||||
// json[r'actionConfig'] = null;
|
||||
}
|
||||
json[r'pluginActionId'] = this.pluginActionId;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowActionItemDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowActionItemDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowActionItemDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowActionItemDto(
|
||||
actionConfig: mapValueOfType<Object>(json, r'actionConfig'),
|
||||
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowActionItemDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowActionItemDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowActionItemDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowActionItemDto> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowActionItemDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowActionItemDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowActionItemDto-objects as value to a dart map
|
||||
static Map<String, List<WorkflowActionItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowActionItemDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowActionItemDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'pluginActionId',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class WorkflowActionResponseDto {
|
||||
/// Returns a new [WorkflowActionResponseDto] instance.
|
||||
WorkflowActionResponseDto({
|
||||
required this.actionConfig,
|
||||
required this.id,
|
||||
required this.order,
|
||||
required this.pluginActionId,
|
||||
required this.workflowId,
|
||||
});
|
||||
|
||||
/// Action configuration
|
||||
Object? actionConfig;
|
||||
|
||||
/// Action ID
|
||||
String id;
|
||||
|
||||
/// Action order
|
||||
num order;
|
||||
|
||||
/// Plugin action ID
|
||||
String pluginActionId;
|
||||
|
||||
/// Workflow ID
|
||||
String workflowId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowActionResponseDto &&
|
||||
other.actionConfig == actionConfig &&
|
||||
other.id == id &&
|
||||
other.order == order &&
|
||||
other.pluginActionId == pluginActionId &&
|
||||
other.workflowId == workflowId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(actionConfig == null ? 0 : actionConfig!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(order.hashCode) +
|
||||
(pluginActionId.hashCode) +
|
||||
(workflowId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, id=$id, order=$order, pluginActionId=$pluginActionId, workflowId=$workflowId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.actionConfig != null) {
|
||||
json[r'actionConfig'] = this.actionConfig;
|
||||
} else {
|
||||
// json[r'actionConfig'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
json[r'order'] = this.order;
|
||||
json[r'pluginActionId'] = this.pluginActionId;
|
||||
json[r'workflowId'] = this.workflowId;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowActionResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowActionResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowActionResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowActionResponseDto(
|
||||
actionConfig: mapValueOfType<Object>(json, r'actionConfig'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
order: num.parse('${json[r'order']}'),
|
||||
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
|
||||
workflowId: mapValueOfType<String>(json, r'workflowId')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowActionResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowActionResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowActionResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowActionResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowActionResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowActionResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowActionResponseDto-objects as value to a dart map
|
||||
static Map<String, List<WorkflowActionResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowActionResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowActionResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'actionConfig',
|
||||
'id',
|
||||
'order',
|
||||
'pluginActionId',
|
||||
'workflowId',
|
||||
};
|
||||
}
|
||||
|
||||
60
mobile/openapi/lib/model/workflow_create_dto.dart
generated
60
mobile/openapi/lib/model/workflow_create_dto.dart
generated
@@ -13,24 +13,14 @@ part of openapi.api;
|
||||
class WorkflowCreateDto {
|
||||
/// Returns a new [WorkflowCreateDto] instance.
|
||||
WorkflowCreateDto({
|
||||
this.actions = const [],
|
||||
this.description,
|
||||
this.enabled,
|
||||
this.filters = const [],
|
||||
required this.name,
|
||||
required this.triggerType,
|
||||
this.name,
|
||||
this.steps = const [],
|
||||
required this.trigger,
|
||||
});
|
||||
|
||||
/// Workflow actions
|
||||
List<WorkflowActionItemDto> actions;
|
||||
|
||||
/// Workflow description
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? description;
|
||||
|
||||
/// Workflow enabled
|
||||
@@ -42,40 +32,36 @@ class WorkflowCreateDto {
|
||||
///
|
||||
bool? enabled;
|
||||
|
||||
/// Workflow filters
|
||||
List<WorkflowFilterItemDto> filters;
|
||||
|
||||
/// Workflow name
|
||||
String name;
|
||||
String? name;
|
||||
|
||||
List<WorkflowStepDto> steps;
|
||||
|
||||
/// Workflow trigger type
|
||||
PluginTriggerType triggerType;
|
||||
WorkflowTrigger trigger;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowCreateDto &&
|
||||
_deepEquality.equals(other.actions, actions) &&
|
||||
other.description == description &&
|
||||
other.enabled == enabled &&
|
||||
_deepEquality.equals(other.filters, filters) &&
|
||||
other.name == name &&
|
||||
other.triggerType == triggerType;
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.trigger == trigger;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(actions.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(filters.hashCode) +
|
||||
(name.hashCode) +
|
||||
(triggerType.hashCode);
|
||||
(name == null ? 0 : name!.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(trigger.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowCreateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
|
||||
String toString() => 'WorkflowCreateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'actions'] = this.actions;
|
||||
if (this.description != null) {
|
||||
json[r'description'] = this.description;
|
||||
} else {
|
||||
@@ -86,9 +72,13 @@ class WorkflowCreateDto {
|
||||
} else {
|
||||
// json[r'enabled'] = null;
|
||||
}
|
||||
json[r'filters'] = this.filters;
|
||||
if (this.name != null) {
|
||||
json[r'name'] = this.name;
|
||||
json[r'triggerType'] = this.triggerType;
|
||||
} else {
|
||||
// json[r'name'] = null;
|
||||
}
|
||||
json[r'steps'] = this.steps;
|
||||
json[r'trigger'] = this.trigger;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -101,12 +91,11 @@ class WorkflowCreateDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowCreateDto(
|
||||
actions: WorkflowActionItemDto.listFromJson(json[r'actions']),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
|
||||
name: mapValueOfType<String>(json, r'name')!,
|
||||
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
steps: WorkflowStepDto.listFromJson(json[r'steps']),
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -154,10 +143,7 @@ class WorkflowCreateDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'actions',
|
||||
'filters',
|
||||
'name',
|
||||
'triggerType',
|
||||
'trigger',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
118
mobile/openapi/lib/model/workflow_filter_item_dto.dart
generated
118
mobile/openapi/lib/model/workflow_filter_item_dto.dart
generated
@@ -1,118 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class WorkflowFilterItemDto {
|
||||
/// Returns a new [WorkflowFilterItemDto] instance.
|
||||
WorkflowFilterItemDto({
|
||||
this.filterConfig,
|
||||
required this.pluginFilterId,
|
||||
});
|
||||
|
||||
/// Filter configuration
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Object? filterConfig;
|
||||
|
||||
/// Plugin filter ID
|
||||
String pluginFilterId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterItemDto &&
|
||||
other.filterConfig == filterConfig &&
|
||||
other.pluginFilterId == pluginFilterId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(filterConfig == null ? 0 : filterConfig!.hashCode) +
|
||||
(pluginFilterId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, pluginFilterId=$pluginFilterId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.filterConfig != null) {
|
||||
json[r'filterConfig'] = this.filterConfig;
|
||||
} else {
|
||||
// json[r'filterConfig'] = null;
|
||||
}
|
||||
json[r'pluginFilterId'] = this.pluginFilterId;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowFilterItemDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowFilterItemDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowFilterItemDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowFilterItemDto(
|
||||
filterConfig: mapValueOfType<Object>(json, r'filterConfig'),
|
||||
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowFilterItemDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowFilterItemDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowFilterItemDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowFilterItemDto> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowFilterItemDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowFilterItemDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowFilterItemDto-objects as value to a dart map
|
||||
static Map<String, List<WorkflowFilterItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowFilterItemDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowFilterItemDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'pluginFilterId',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class WorkflowFilterResponseDto {
|
||||
/// Returns a new [WorkflowFilterResponseDto] instance.
|
||||
WorkflowFilterResponseDto({
|
||||
required this.filterConfig,
|
||||
required this.id,
|
||||
required this.order,
|
||||
required this.pluginFilterId,
|
||||
required this.workflowId,
|
||||
});
|
||||
|
||||
/// Filter configuration
|
||||
Object? filterConfig;
|
||||
|
||||
/// Filter ID
|
||||
String id;
|
||||
|
||||
/// Filter order
|
||||
num order;
|
||||
|
||||
/// Plugin filter ID
|
||||
String pluginFilterId;
|
||||
|
||||
/// Workflow ID
|
||||
String workflowId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterResponseDto &&
|
||||
other.filterConfig == filterConfig &&
|
||||
other.id == id &&
|
||||
other.order == order &&
|
||||
other.pluginFilterId == pluginFilterId &&
|
||||
other.workflowId == workflowId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(filterConfig == null ? 0 : filterConfig!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(order.hashCode) +
|
||||
(pluginFilterId.hashCode) +
|
||||
(workflowId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, id=$id, order=$order, pluginFilterId=$pluginFilterId, workflowId=$workflowId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.filterConfig != null) {
|
||||
json[r'filterConfig'] = this.filterConfig;
|
||||
} else {
|
||||
// json[r'filterConfig'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
json[r'order'] = this.order;
|
||||
json[r'pluginFilterId'] = this.pluginFilterId;
|
||||
json[r'workflowId'] = this.workflowId;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowFilterResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowFilterResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowFilterResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowFilterResponseDto(
|
||||
filterConfig: mapValueOfType<Object>(json, r'filterConfig'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
order: num.parse('${json[r'order']}'),
|
||||
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
|
||||
workflowId: mapValueOfType<String>(json, r'workflowId')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowFilterResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowFilterResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowFilterResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowFilterResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowFilterResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowFilterResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowFilterResponseDto-objects as value to a dart map
|
||||
static Map<String, List<WorkflowFilterResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowFilterResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowFilterResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'filterConfig',
|
||||
'id',
|
||||
'order',
|
||||
'pluginFilterId',
|
||||
'workflowId',
|
||||
};
|
||||
}
|
||||
|
||||
69
mobile/openapi/lib/model/workflow_response_dto.dart
generated
69
mobile/openapi/lib/model/workflow_response_dto.dart
generated
@@ -13,87 +13,84 @@ part of openapi.api;
|
||||
class WorkflowResponseDto {
|
||||
/// Returns a new [WorkflowResponseDto] instance.
|
||||
WorkflowResponseDto({
|
||||
this.actions = const [],
|
||||
required this.createdAt,
|
||||
required this.description,
|
||||
required this.enabled,
|
||||
this.filters = const [],
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.ownerId,
|
||||
required this.triggerType,
|
||||
this.steps = const [],
|
||||
required this.trigger,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Workflow actions
|
||||
List<WorkflowActionResponseDto> actions;
|
||||
|
||||
/// Creation date
|
||||
String createdAt;
|
||||
|
||||
/// Workflow description
|
||||
String description;
|
||||
String? description;
|
||||
|
||||
/// Workflow enabled
|
||||
bool enabled;
|
||||
|
||||
/// Workflow filters
|
||||
List<WorkflowFilterResponseDto> filters;
|
||||
|
||||
/// Workflow ID
|
||||
String id;
|
||||
|
||||
/// Workflow name
|
||||
String? name;
|
||||
|
||||
/// Owner user ID
|
||||
String ownerId;
|
||||
/// Workflow steps
|
||||
List<WorkflowStepResponseDto> steps;
|
||||
|
||||
/// Workflow trigger type
|
||||
PluginTriggerType triggerType;
|
||||
WorkflowTrigger trigger;
|
||||
|
||||
/// Update date
|
||||
String updatedAt;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
|
||||
_deepEquality.equals(other.actions, actions) &&
|
||||
other.createdAt == createdAt &&
|
||||
other.description == description &&
|
||||
other.enabled == enabled &&
|
||||
_deepEquality.equals(other.filters, filters) &&
|
||||
other.id == id &&
|
||||
other.name == name &&
|
||||
other.ownerId == ownerId &&
|
||||
other.triggerType == triggerType;
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.trigger == trigger &&
|
||||
other.updatedAt == updatedAt;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(actions.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(description.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(enabled.hashCode) +
|
||||
(filters.hashCode) +
|
||||
(id.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(triggerType.hashCode);
|
||||
(steps.hashCode) +
|
||||
(trigger.hashCode) +
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowResponseDto[actions=$actions, createdAt=$createdAt, description=$description, enabled=$enabled, filters=$filters, id=$id, name=$name, ownerId=$ownerId, triggerType=$triggerType]';
|
||||
String toString() => 'WorkflowResponseDto[createdAt=$createdAt, description=$description, enabled=$enabled, id=$id, name=$name, steps=$steps, trigger=$trigger, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'actions'] = this.actions;
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
if (this.description != null) {
|
||||
json[r'description'] = this.description;
|
||||
} else {
|
||||
// json[r'description'] = null;
|
||||
}
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'filters'] = this.filters;
|
||||
json[r'id'] = this.id;
|
||||
if (this.name != null) {
|
||||
json[r'name'] = this.name;
|
||||
} else {
|
||||
// json[r'name'] = null;
|
||||
}
|
||||
json[r'ownerId'] = this.ownerId;
|
||||
json[r'triggerType'] = this.triggerType;
|
||||
json[r'steps'] = this.steps;
|
||||
json[r'trigger'] = this.trigger;
|
||||
json[r'updatedAt'] = this.updatedAt;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -106,15 +103,14 @@ class WorkflowResponseDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowResponseDto(
|
||||
actions: WorkflowActionResponseDto.listFromJson(json[r'actions']),
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
description: mapValueOfType<String>(json, r'description')!,
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
filters: WorkflowFilterResponseDto.listFromJson(json[r'filters']),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
|
||||
steps: WorkflowStepResponseDto.listFromJson(json[r'steps']),
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -162,15 +158,14 @@ class WorkflowResponseDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'actions',
|
||||
'createdAt',
|
||||
'description',
|
||||
'enabled',
|
||||
'filters',
|
||||
'id',
|
||||
'name',
|
||||
'ownerId',
|
||||
'triggerType',
|
||||
'steps',
|
||||
'trigger',
|
||||
'updatedAt',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
130
mobile/openapi/lib/model/workflow_step_dto.dart
generated
Normal file
130
mobile/openapi/lib/model/workflow_step_dto.dart
generated
Normal file
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class WorkflowStepDto {
|
||||
/// Returns a new [WorkflowStepDto] instance.
|
||||
WorkflowStepDto({
|
||||
this.config,
|
||||
this.enabled,
|
||||
required this.method,
|
||||
});
|
||||
|
||||
/// Step configuration
|
||||
Object? config;
|
||||
|
||||
/// Step is enabled
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
bool? enabled;
|
||||
|
||||
/// Step plugin method
|
||||
String method;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowStepDto &&
|
||||
other.config == config &&
|
||||
other.enabled == enabled &&
|
||||
other.method == method;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(config == null ? 0 : config!.hashCode) +
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(method.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowStepDto[config=$config, enabled=$enabled, method=$method]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.config != null) {
|
||||
json[r'config'] = this.config;
|
||||
} else {
|
||||
// json[r'config'] = null;
|
||||
}
|
||||
if (this.enabled != null) {
|
||||
json[r'enabled'] = this.enabled;
|
||||
} else {
|
||||
// json[r'enabled'] = null;
|
||||
}
|
||||
json[r'method'] = this.method;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowStepDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowStepDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowStepDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowStepDto(
|
||||
config: mapValueOfType<Object>(json, r'config'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||
method: mapValueOfType<String>(json, r'method')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowStepDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowStepDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowStepDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowStepDto> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowStepDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowStepDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowStepDto-objects as value to a dart map
|
||||
static Map<String, List<WorkflowStepDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowStepDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowStepDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'method',
|
||||
};
|
||||
}
|
||||
|
||||
122
mobile/openapi/lib/model/workflow_step_response_dto.dart
generated
Normal file
122
mobile/openapi/lib/model/workflow_step_response_dto.dart
generated
Normal file
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class WorkflowStepResponseDto {
|
||||
/// Returns a new [WorkflowStepResponseDto] instance.
|
||||
WorkflowStepResponseDto({
|
||||
required this.config,
|
||||
required this.enabled,
|
||||
required this.method,
|
||||
});
|
||||
|
||||
/// Step configuration
|
||||
Object? config;
|
||||
|
||||
/// Step is enabled
|
||||
bool enabled;
|
||||
|
||||
/// Step plugin method
|
||||
String method;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowStepResponseDto &&
|
||||
other.config == config &&
|
||||
other.enabled == enabled &&
|
||||
other.method == method;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(config == null ? 0 : config!.hashCode) +
|
||||
(enabled.hashCode) +
|
||||
(method.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowStepResponseDto[config=$config, enabled=$enabled, method=$method]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.config != null) {
|
||||
json[r'config'] = this.config;
|
||||
} else {
|
||||
// json[r'config'] = null;
|
||||
}
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'method'] = this.method;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowStepResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowStepResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowStepResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowStepResponseDto(
|
||||
config: mapValueOfType<Object>(json, r'config'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
method: mapValueOfType<String>(json, r'method')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowStepResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowStepResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowStepResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowStepResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowStepResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowStepResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowStepResponseDto-objects as value to a dart map
|
||||
static Map<String, List<WorkflowStepResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowStepResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowStepResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'config',
|
||||
'enabled',
|
||||
'method',
|
||||
};
|
||||
}
|
||||
|
||||
85
mobile/openapi/lib/model/workflow_trigger.dart
generated
Normal file
85
mobile/openapi/lib/model/workflow_trigger.dart
generated
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class WorkflowTrigger {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const WorkflowTrigger._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const assetCreate = WorkflowTrigger._(r'AssetCreate');
|
||||
static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
|
||||
|
||||
/// List of all possible values in this [enum][WorkflowTrigger].
|
||||
static const values = <WorkflowTrigger>[
|
||||
assetCreate,
|
||||
personRecognized,
|
||||
];
|
||||
|
||||
static WorkflowTrigger? fromJson(dynamic value) => WorkflowTriggerTypeTransformer().decode(value);
|
||||
|
||||
static List<WorkflowTrigger> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowTrigger>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowTrigger.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [WorkflowTrigger] to String,
|
||||
/// and [decode] dynamic data back to [WorkflowTrigger].
|
||||
class WorkflowTriggerTypeTransformer {
|
||||
factory WorkflowTriggerTypeTransformer() => _instance ??= const WorkflowTriggerTypeTransformer._();
|
||||
|
||||
const WorkflowTriggerTypeTransformer._();
|
||||
|
||||
String encode(WorkflowTrigger data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a WorkflowTrigger.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
WorkflowTrigger? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'AssetCreate': return WorkflowTrigger.assetCreate;
|
||||
case r'PersonRecognized': return WorkflowTrigger.personRecognized;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [WorkflowTriggerTypeTransformer] instance.
|
||||
static WorkflowTriggerTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
109
mobile/openapi/lib/model/workflow_trigger_response_dto.dart
generated
Normal file
109
mobile/openapi/lib/model/workflow_trigger_response_dto.dart
generated
Normal file
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class WorkflowTriggerResponseDto {
|
||||
/// Returns a new [WorkflowTriggerResponseDto] instance.
|
||||
WorkflowTriggerResponseDto({
|
||||
required this.trigger,
|
||||
this.types = const [],
|
||||
});
|
||||
|
||||
/// Trigger type
|
||||
WorkflowTrigger trigger;
|
||||
|
||||
/// Workflow types
|
||||
List<WorkflowType> types;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowTriggerResponseDto &&
|
||||
other.trigger == trigger &&
|
||||
_deepEquality.equals(other.types, types);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(trigger.hashCode) +
|
||||
(types.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowTriggerResponseDto[trigger=$trigger, types=$types]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'trigger'] = this.trigger;
|
||||
json[r'types'] = this.types;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowTriggerResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowTriggerResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowTriggerResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowTriggerResponseDto(
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||
types: WorkflowType.listFromJson(json[r'types']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowTriggerResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowTriggerResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowTriggerResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowTriggerResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowTriggerResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowTriggerResponseDto-objects as value to a dart map
|
||||
static Map<String, List<WorkflowTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowTriggerResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowTriggerResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'trigger',
|
||||
'types',
|
||||
};
|
||||
}
|
||||
|
||||
85
mobile/openapi/lib/model/workflow_type.dart
generated
Normal file
85
mobile/openapi/lib/model/workflow_type.dart
generated
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
/// Workflow types
|
||||
class WorkflowType {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const WorkflowType._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const assetV1 = WorkflowType._(r'AssetV1');
|
||||
static const assetPersonV1 = WorkflowType._(r'AssetPersonV1');
|
||||
|
||||
/// List of all possible values in this [enum][WorkflowType].
|
||||
static const values = <WorkflowType>[
|
||||
assetV1,
|
||||
assetPersonV1,
|
||||
];
|
||||
|
||||
static WorkflowType? fromJson(dynamic value) => WorkflowTypeTypeTransformer().decode(value);
|
||||
|
||||
static List<WorkflowType> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowType>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowType.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [WorkflowType] to String,
|
||||
/// and [decode] dynamic data back to [WorkflowType].
|
||||
class WorkflowTypeTypeTransformer {
|
||||
factory WorkflowTypeTypeTransformer() => _instance ??= const WorkflowTypeTypeTransformer._();
|
||||
|
||||
const WorkflowTypeTypeTransformer._();
|
||||
|
||||
String encode(WorkflowType data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a WorkflowType.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
WorkflowType? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'AssetV1': return WorkflowType.assetV1;
|
||||
case r'AssetPersonV1': return WorkflowType.assetPersonV1;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [WorkflowTypeTypeTransformer] instance.
|
||||
static WorkflowTypeTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
53
mobile/openapi/lib/model/workflow_update_dto.dart
generated
53
mobile/openapi/lib/model/workflow_update_dto.dart
generated
@@ -13,24 +13,14 @@ part of openapi.api;
|
||||
class WorkflowUpdateDto {
|
||||
/// Returns a new [WorkflowUpdateDto] instance.
|
||||
WorkflowUpdateDto({
|
||||
this.actions = const [],
|
||||
this.description,
|
||||
this.enabled,
|
||||
this.filters = const [],
|
||||
this.name,
|
||||
this.triggerType,
|
||||
this.steps = const [],
|
||||
this.trigger,
|
||||
});
|
||||
|
||||
/// Workflow actions
|
||||
List<WorkflowActionItemDto> actions;
|
||||
|
||||
/// Workflow description
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? description;
|
||||
|
||||
/// Workflow enabled
|
||||
@@ -42,18 +32,11 @@ class WorkflowUpdateDto {
|
||||
///
|
||||
bool? enabled;
|
||||
|
||||
/// Workflow filters
|
||||
List<WorkflowFilterItemDto> filters;
|
||||
|
||||
/// Workflow name
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? name;
|
||||
|
||||
List<WorkflowStepDto> steps;
|
||||
|
||||
/// Workflow trigger type
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
@@ -61,33 +44,30 @@ class WorkflowUpdateDto {
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
PluginTriggerType? triggerType;
|
||||
WorkflowTrigger? trigger;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
|
||||
_deepEquality.equals(other.actions, actions) &&
|
||||
other.description == description &&
|
||||
other.enabled == enabled &&
|
||||
_deepEquality.equals(other.filters, filters) &&
|
||||
other.name == name &&
|
||||
other.triggerType == triggerType;
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.trigger == trigger;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(actions.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(filters.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode) +
|
||||
(triggerType == null ? 0 : triggerType!.hashCode);
|
||||
(steps.hashCode) +
|
||||
(trigger == null ? 0 : trigger!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
|
||||
String toString() => 'WorkflowUpdateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'actions'] = this.actions;
|
||||
if (this.description != null) {
|
||||
json[r'description'] = this.description;
|
||||
} else {
|
||||
@@ -98,16 +78,16 @@ class WorkflowUpdateDto {
|
||||
} else {
|
||||
// json[r'enabled'] = null;
|
||||
}
|
||||
json[r'filters'] = this.filters;
|
||||
if (this.name != null) {
|
||||
json[r'name'] = this.name;
|
||||
} else {
|
||||
// json[r'name'] = null;
|
||||
}
|
||||
if (this.triggerType != null) {
|
||||
json[r'triggerType'] = this.triggerType;
|
||||
json[r'steps'] = this.steps;
|
||||
if (this.trigger != null) {
|
||||
json[r'trigger'] = this.trigger;
|
||||
} else {
|
||||
// json[r'triggerType'] = null;
|
||||
// json[r'trigger'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
@@ -121,12 +101,11 @@ class WorkflowUpdateDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowUpdateDto(
|
||||
actions: WorkflowActionItemDto.listFromJson(json[r'actions']),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
triggerType: PluginTriggerType.fromJson(json[r'triggerType']),
|
||||
steps: WorkflowStepDto.listFromJson(json[r'steps']),
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1217,10 +1217,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1910,10 +1910,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.6"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1561,51 +1561,31 @@ export type PersonStatisticsResponseDto = {
|
||||
/** Number of assets */
|
||||
assets: number;
|
||||
};
|
||||
export type PluginActionResponseDto = {
|
||||
/** Action description */
|
||||
export type PluginMethodResponseDto = {
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Action ID */
|
||||
id: string;
|
||||
/** Method name */
|
||||
methodName: string;
|
||||
/** Plugin ID */
|
||||
pluginId: string;
|
||||
/** Action schema */
|
||||
/** Key */
|
||||
key: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Schema */
|
||||
schema: object | null;
|
||||
/** Supported contexts */
|
||||
supportedContexts: PluginContextType[];
|
||||
/** Action title */
|
||||
title: string;
|
||||
};
|
||||
export type PluginFilterResponseDto = {
|
||||
/** Filter description */
|
||||
description: string;
|
||||
/** Filter ID */
|
||||
id: string;
|
||||
/** Method name */
|
||||
methodName: string;
|
||||
/** Plugin ID */
|
||||
pluginId: string;
|
||||
/** Filter schema */
|
||||
schema: object | null;
|
||||
/** Supported contexts */
|
||||
supportedContexts: PluginContextType[];
|
||||
/** Filter title */
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Workflow types */
|
||||
types: WorkflowType[];
|
||||
};
|
||||
export type PluginResponseDto = {
|
||||
/** Plugin actions */
|
||||
actions: PluginActionResponseDto[];
|
||||
/** Plugin author */
|
||||
author: string;
|
||||
/** Creation date */
|
||||
createdAt: string;
|
||||
/** Plugin description */
|
||||
description: string;
|
||||
/** Plugin filters */
|
||||
filters: PluginFilterResponseDto[];
|
||||
/** Plugin ID */
|
||||
id: string;
|
||||
/** Plugin methods */
|
||||
methods: PluginMethodResponseDto[];
|
||||
/** Plugin name */
|
||||
name: string;
|
||||
/** Plugin title */
|
||||
@@ -1615,12 +1595,6 @@ export type PluginResponseDto = {
|
||||
/** Plugin version */
|
||||
version: string;
|
||||
};
|
||||
export type PluginTriggerResponseDto = {
|
||||
/** Context type */
|
||||
contextType: PluginContextType;
|
||||
/** Trigger type */
|
||||
"type": PluginTriggerType;
|
||||
};
|
||||
export type QueueResponseDto = {
|
||||
/** Whether the queue is paused */
|
||||
isPaused: boolean;
|
||||
@@ -2829,89 +2803,67 @@ export type CreateProfileImageResponseDto = {
|
||||
/** User ID */
|
||||
userId: string;
|
||||
};
|
||||
export type WorkflowActionResponseDto = {
|
||||
/** Action configuration */
|
||||
actionConfig: object | null;
|
||||
/** Action ID */
|
||||
id: string;
|
||||
/** Action order */
|
||||
order: number;
|
||||
/** Plugin action ID */
|
||||
pluginActionId: string;
|
||||
/** Workflow ID */
|
||||
workflowId: string;
|
||||
};
|
||||
export type WorkflowFilterResponseDto = {
|
||||
/** Filter configuration */
|
||||
filterConfig: object | null;
|
||||
/** Filter ID */
|
||||
id: string;
|
||||
/** Filter order */
|
||||
order: number;
|
||||
/** Plugin filter ID */
|
||||
pluginFilterId: string;
|
||||
/** Workflow ID */
|
||||
workflowId: string;
|
||||
export type WorkflowStepResponseDto = {
|
||||
/** Step configuration */
|
||||
config: object | null;
|
||||
/** Step is enabled */
|
||||
enabled: boolean;
|
||||
/** Step plugin method */
|
||||
method: string;
|
||||
};
|
||||
export type WorkflowResponseDto = {
|
||||
/** Workflow actions */
|
||||
actions: WorkflowActionResponseDto[];
|
||||
/** Creation date */
|
||||
createdAt: string;
|
||||
/** Workflow description */
|
||||
description: string;
|
||||
description: string | null;
|
||||
/** Workflow enabled */
|
||||
enabled: boolean;
|
||||
/** Workflow filters */
|
||||
filters: WorkflowFilterResponseDto[];
|
||||
/** Workflow ID */
|
||||
id: string;
|
||||
/** Workflow name */
|
||||
name: string | null;
|
||||
/** Owner user ID */
|
||||
ownerId: string;
|
||||
/** Workflow steps */
|
||||
steps: WorkflowStepResponseDto[];
|
||||
/** Workflow trigger type */
|
||||
triggerType: PluginTriggerType;
|
||||
trigger: WorkflowTrigger;
|
||||
/** Update date */
|
||||
updatedAt: string;
|
||||
};
|
||||
export type WorkflowActionItemDto = {
|
||||
/** Action configuration */
|
||||
actionConfig?: object;
|
||||
/** Plugin action ID */
|
||||
pluginActionId: string;
|
||||
};
|
||||
export type WorkflowFilterItemDto = {
|
||||
/** Filter configuration */
|
||||
filterConfig?: object;
|
||||
/** Plugin filter ID */
|
||||
pluginFilterId: string;
|
||||
export type WorkflowStepDto = {
|
||||
/** Step configuration */
|
||||
config?: object | null;
|
||||
/** Step is enabled */
|
||||
enabled?: boolean;
|
||||
/** Step plugin method */
|
||||
method: string;
|
||||
};
|
||||
export type WorkflowCreateDto = {
|
||||
/** Workflow actions */
|
||||
actions: WorkflowActionItemDto[];
|
||||
/** Workflow description */
|
||||
description?: string;
|
||||
description?: string | null;
|
||||
/** Workflow enabled */
|
||||
enabled?: boolean;
|
||||
/** Workflow filters */
|
||||
filters: WorkflowFilterItemDto[];
|
||||
/** Workflow name */
|
||||
name: string;
|
||||
name?: string | null;
|
||||
steps?: WorkflowStepDto[];
|
||||
/** Workflow trigger type */
|
||||
triggerType: PluginTriggerType;
|
||||
trigger: WorkflowTrigger;
|
||||
};
|
||||
export type WorkflowTriggerResponseDto = {
|
||||
/** Trigger type */
|
||||
trigger: WorkflowTrigger;
|
||||
/** Workflow types */
|
||||
types: WorkflowType[];
|
||||
};
|
||||
export type WorkflowUpdateDto = {
|
||||
/** Workflow actions */
|
||||
actions?: WorkflowActionItemDto[];
|
||||
/** Workflow description */
|
||||
description?: string;
|
||||
description?: string | null;
|
||||
/** Workflow enabled */
|
||||
enabled?: boolean;
|
||||
/** Workflow filters */
|
||||
filters?: WorkflowFilterItemDto[];
|
||||
/** Workflow name */
|
||||
name?: string;
|
||||
name?: string | null;
|
||||
steps?: WorkflowStepDto[];
|
||||
/** Workflow trigger type */
|
||||
triggerType?: PluginTriggerType;
|
||||
trigger?: WorkflowTrigger;
|
||||
};
|
||||
export type SyncAckV1 = {};
|
||||
export type SyncAlbumDeleteV1 = {
|
||||
@@ -5309,22 +5261,56 @@ export function getPersonThumbnail({ id }: {
|
||||
/**
|
||||
* List all plugins
|
||||
*/
|
||||
export function getPlugins(opts?: Oazapfts.RequestOpts) {
|
||||
export function searchPlugins({ description, enabled, id, name, title, version }: {
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
version?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: PluginResponseDto[];
|
||||
}>("/plugins", {
|
||||
}>(`/plugins${QS.query(QS.explode({
|
||||
description,
|
||||
enabled,
|
||||
id,
|
||||
name,
|
||||
title,
|
||||
version
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* List all plugin triggers
|
||||
* Retrieve plugin methods
|
||||
*/
|
||||
export function getPluginTriggers(opts?: Oazapfts.RequestOpts) {
|
||||
export function searchPluginMethods({ description, enabled, id, name, pluginName, pluginVersion, title, trigger, $type }: {
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
pluginName?: string;
|
||||
pluginVersion?: string;
|
||||
title?: string;
|
||||
trigger?: WorkflowTrigger;
|
||||
$type?: WorkflowType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: PluginTriggerResponseDto[];
|
||||
}>("/plugins/triggers", {
|
||||
data: PluginMethodResponseDto[];
|
||||
}>(`/plugins/methods${QS.query(QS.explode({
|
||||
description,
|
||||
enabled,
|
||||
id,
|
||||
name,
|
||||
pluginName,
|
||||
pluginVersion,
|
||||
title,
|
||||
trigger,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
@@ -6753,11 +6739,23 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) {
|
||||
/**
|
||||
* List all workflows
|
||||
*/
|
||||
export function getWorkflows(opts?: Oazapfts.RequestOpts) {
|
||||
export function searchWorkflows({ description, enabled, id, name, trigger }: {
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
trigger?: WorkflowTrigger;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: WorkflowResponseDto[];
|
||||
}>("/workflows", {
|
||||
}>(`/workflows${QS.query(QS.explode({
|
||||
description,
|
||||
enabled,
|
||||
id,
|
||||
name,
|
||||
trigger
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
@@ -6776,6 +6774,17 @@ export function createWorkflow({ workflowCreateDto }: {
|
||||
body: workflowCreateDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* List all workflow triggers
|
||||
*/
|
||||
export function getWorkflowTriggers(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: WorkflowTriggerResponseDto[];
|
||||
}>("/workflows/triggers", {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Delete a workflow
|
||||
*/
|
||||
@@ -7145,12 +7154,11 @@ export enum PartnerDirection {
|
||||
SharedBy = "shared-by",
|
||||
SharedWith = "shared-with"
|
||||
}
|
||||
export enum PluginContextType {
|
||||
Asset = "asset",
|
||||
Album = "album",
|
||||
Person = "person"
|
||||
export enum WorkflowType {
|
||||
AssetV1 = "AssetV1",
|
||||
AssetPersonV1 = "AssetPersonV1"
|
||||
}
|
||||
export enum PluginTriggerType {
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = "AssetCreate",
|
||||
PersonRecognized = "PersonRecognized"
|
||||
}
|
||||
@@ -7218,7 +7226,7 @@ export enum JobName {
|
||||
VersionCheck = "VersionCheck",
|
||||
OcrQueueAll = "OcrQueueAll",
|
||||
Ocr = "Ocr",
|
||||
WorkflowRun = "WorkflowRun"
|
||||
WorkflowAssetCreate = "WorkflowAssetCreate"
|
||||
}
|
||||
export enum SearchSuggestionType {
|
||||
Country = "country",
|
||||
|
||||
2
packages/plugin-core/.gitignore
vendored
Normal file
2
packages/plugin-core/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/dist
|
||||
/node_modules
|
||||
11
packages/plugin-core/esbuild.js
Normal file
11
packages/plugin-core/esbuild.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
esbuild.build({
|
||||
entryPoints: ['src/index.ts'],
|
||||
outdir: 'dist',
|
||||
bundle: true,
|
||||
sourcemap: false,
|
||||
minify: false, // might want to use true for production build
|
||||
format: 'cjs', // needs to be CJS for now
|
||||
target: ['es2020'], // don't go over es2020 because quickjs doesn't support it
|
||||
});
|
||||
191
packages/plugin-core/manifest.json
Normal file
191
packages/plugin-core/manifest.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"name": "immich-plugin-core",
|
||||
"version": "2.0.1",
|
||||
"title": "Immich Core Plugin",
|
||||
"description": "Core workflow capabilities for Immich",
|
||||
"author": "Immich Team",
|
||||
"wasmPath": "dist/plugin.wasm",
|
||||
"methods": [
|
||||
{
|
||||
"name": "filterFileName",
|
||||
"title": "Filter by filename",
|
||||
"description": "Filter assets by filename pattern using text matching or regular expressions",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"title": "Filename pattern",
|
||||
"description": "Text or regex pattern to match against filename"
|
||||
},
|
||||
"matchType": {
|
||||
"type": "string",
|
||||
"title": "Match type",
|
||||
"enum": ["contains", "regex", "exact"],
|
||||
"default": "contains",
|
||||
"description": "Type of pattern matching to perform"
|
||||
},
|
||||
"caseSensitive": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether matching should be case-sensitive"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "filterFileType",
|
||||
"title": "Filter by file type",
|
||||
"description": "Filter assets by file type",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fileTypes": {
|
||||
"type": "array",
|
||||
"title": "File types",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["image", "video"]
|
||||
},
|
||||
"description": "Allowed file types"
|
||||
}
|
||||
},
|
||||
"required": ["fileTypes"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "filterPerson",
|
||||
"title": "Filter by person",
|
||||
"description": "Filter by detected person",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"personIds": {
|
||||
"type": "array",
|
||||
"title": "Person IDs",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "List of person to match",
|
||||
"subType": "people-picker"
|
||||
},
|
||||
"matchAny": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Match any name (true) or require all names (false)"
|
||||
}
|
||||
},
|
||||
"required": ["personIds"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assetArchive",
|
||||
"title": "Archive",
|
||||
"description": "Move the asset to archive",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {}
|
||||
},
|
||||
{
|
||||
"name": "assetFavorite",
|
||||
"title": "Favorite",
|
||||
"description": "Mark the asset as favorite or unfavorite",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inverse": {
|
||||
"type": "boolean",
|
||||
"title": "Inverse",
|
||||
"description": "Unfavorite by default, set to true to favorite instead",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "albumAddAssets",
|
||||
"title": "Add to Album",
|
||||
"description": "Add the item to a specified album",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"albumId": {
|
||||
"type": "string",
|
||||
"title": "Album ID",
|
||||
"description": "Target album ID",
|
||||
"uiHint": "albumId"
|
||||
}
|
||||
},
|
||||
"required": ["albumId"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "test",
|
||||
"title": "Test",
|
||||
"description": "Test method with complete configuration examples",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"number1": {
|
||||
"type": "number",
|
||||
"title": "Number 1",
|
||||
"description": "Basic number"
|
||||
},
|
||||
"number2": {
|
||||
"type": "number",
|
||||
"title": "Number 2",
|
||||
"array": true,
|
||||
"description": "List of numbers"
|
||||
},
|
||||
"string1": {
|
||||
"type": "string",
|
||||
"title": "String 1",
|
||||
"description": "Basic string"
|
||||
},
|
||||
"string2": {
|
||||
"type": "string",
|
||||
"title": "String 2",
|
||||
"array": true,
|
||||
"description": "List of strings"
|
||||
},
|
||||
"string3": {
|
||||
"type": "string",
|
||||
"title": "String 3",
|
||||
"enum": ["choice-1", "choice-2"],
|
||||
"description": "Select from a list"
|
||||
},
|
||||
"nested": {
|
||||
"type": "object",
|
||||
"title": "Nested",
|
||||
"description": "Nested properties for nesting",
|
||||
"properties": {
|
||||
"nested1": {
|
||||
"type": "string",
|
||||
"title": "Nested 1",
|
||||
"description": "Nested string"
|
||||
},
|
||||
"nested2": {
|
||||
"type": "number",
|
||||
"title": "Nested 2",
|
||||
"description": "Nested number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumId": {
|
||||
"type": "string",
|
||||
"title": "Album ID",
|
||||
"description": "Target album ID",
|
||||
"uiHint": "albumId"
|
||||
}
|
||||
},
|
||||
"required": ["albumId"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,4 +8,4 @@ run = "pnpm install --frozen-lockfile"
|
||||
|
||||
[tasks.build]
|
||||
depends = ["install"]
|
||||
run = "pnpm run build"
|
||||
run = "pnpm run --filter @immich/plugin-sdk --filter @immich/plugin-core build"
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "plugins",
|
||||
"name": "@immich/plugin-core",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,6 +14,7 @@
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.0.1",
|
||||
"esbuild": "^0.27.0",
|
||||
"typescript": "^5.3.2"
|
||||
"typescript": "^5.3.2",
|
||||
"@immich/plugin-sdk": "workspace:*"
|
||||
}
|
||||
}
|
||||
17
packages/plugin-core/src/index.d.ts
vendored
Normal file
17
packages/plugin-core/src/index.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
// copy from
|
||||
// import '@immich/plugin-sdk/host-functions';
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
albumAddAssets(ptr: PTR): I64;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'main' {
|
||||
export function assetFileFilter(): I32;
|
||||
export function assetArchive(): I32;
|
||||
export function assetFavorite(): I32;
|
||||
export function assetLock(): I32;
|
||||
export function assetTrash(): I32;
|
||||
export function albumAddAssets(): I32;
|
||||
export function test(): I32;
|
||||
}
|
||||
112
packages/plugin-core/src/index.ts
Normal file
112
packages/plugin-core/src/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
AssetStatus,
|
||||
AssetVisibility,
|
||||
WorkflowType,
|
||||
wrapper,
|
||||
} from '@immich/plugin-sdk';
|
||||
|
||||
type AssetFileFilterConfig = {
|
||||
pattern: string;
|
||||
matchType?: 'contains' | 'exact' | 'regex';
|
||||
caseSensitive?: boolean;
|
||||
};
|
||||
export const assetFileFilter = () => {
|
||||
return wrapper<WorkflowType.AssetV1>(({ data, config }) => {
|
||||
const {
|
||||
pattern,
|
||||
matchType = 'contains',
|
||||
caseSensitive = false,
|
||||
} = config as AssetFileFilterConfig;
|
||||
|
||||
const { asset } = data;
|
||||
|
||||
const fileName = asset.originalFileName || '';
|
||||
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
|
||||
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
||||
|
||||
if (matchType === 'exact') {
|
||||
return { workflow: { continue: searchName === searchPattern } };
|
||||
}
|
||||
|
||||
if (matchType === 'regex') {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(searchPattern, flags);
|
||||
return { workflow: { continue: regex.test(fileName) } };
|
||||
}
|
||||
|
||||
return { workflow: { continue: searchName.includes(searchPattern) } };
|
||||
});
|
||||
};
|
||||
|
||||
type AssetArchiveConfig = {
|
||||
inverse?: boolean;
|
||||
};
|
||||
export const assetArchive = () => {
|
||||
return wrapper<WorkflowType.AssetV1, AssetArchiveConfig>(
|
||||
({ config, data }) => {
|
||||
const target: AssetVisibility = config.inverse
|
||||
? AssetVisibility.Timeline
|
||||
: AssetVisibility.Archive;
|
||||
if (target !== data.asset.visibility) {
|
||||
return {
|
||||
changes: {
|
||||
asset: { visibility: target },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
type AssetFavoriteConfig = {
|
||||
inverse?: boolean;
|
||||
};
|
||||
export const assetFavorite = () => {
|
||||
return wrapper<WorkflowType.AssetV1, AssetFavoriteConfig>(
|
||||
({ config, data }) => {
|
||||
const target = config.inverse ? false : true;
|
||||
if (target !== data.asset.isFavorite) {
|
||||
return {
|
||||
changes: {
|
||||
asset: { isFavorite: target },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const assetLock = () => {
|
||||
return wrapper<WorkflowType.AssetV1>(() => ({
|
||||
changes: { asset: { visibility: AssetVisibility.Locked } },
|
||||
}));
|
||||
};
|
||||
|
||||
type AssetTrashConfig = {
|
||||
inverse?: boolean;
|
||||
};
|
||||
export const assetTrash = () => {
|
||||
return wrapper<WorkflowType.AssetV1, AssetTrashConfig>(({ config }) => ({
|
||||
changes: {
|
||||
asset: config.inverse
|
||||
? { deletedAt: null, status: AssetStatus.Active }
|
||||
: { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed },
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
type AssetAddToAlbumConfig = {
|
||||
albumId: string;
|
||||
};
|
||||
export const albumAddAssets = () => {
|
||||
return wrapper<WorkflowType.AssetV1, AssetAddToAlbumConfig>(
|
||||
({ config, data, functions }) => {
|
||||
functions.albumAddAssets(config.albumId, [data.asset.id]);
|
||||
return {};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const test = () => {
|
||||
return wrapper(() => ({}));
|
||||
};
|
||||
@@ -1,19 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020", // Specify ECMAScript target version
|
||||
"module": "commonjs", // Specify module code generation
|
||||
"lib": [
|
||||
"es2020"
|
||||
], // Specify a list of library files to be included in the compilation
|
||||
"types": [
|
||||
"@extism/js-pdk",
|
||||
"./src/index.d.ts"
|
||||
], // Specify a list of type definition files to be included in the compilation
|
||||
"module": "nodenext", // Specify module code generation
|
||||
"outDir": "./dist",
|
||||
"lib": ["es2020"], // Specify a list of library files to be included in the compilation
|
||||
"types": ["@extism/js-pdk"], // Specify a list of type definition files to be included in the compilation
|
||||
"strict": true, // Enable all strict type-checking options
|
||||
"esModuleInterop": true, // Enables compatibility with Babel-style module imports
|
||||
"moduleResolution": "nodenext",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"skipLibCheck": true, // Skip type checking of declaration files
|
||||
"allowJs": true, // Allow JavaScript files to be compiled
|
||||
"noEmit": true // Do not emit outputs (no .js or .d.ts files)
|
||||
"allowJs": true // Allow JavaScript files to be compiled
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts" // Include all TypeScript files in src directory
|
||||
2
packages/plugin-sdk/.gitignore
vendored
Normal file
2
packages/plugin-sdk/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/dist
|
||||
/node_modules
|
||||
11
packages/plugin-sdk/esbuild.js
Normal file
11
packages/plugin-sdk/esbuild.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import esbuild from 'esbuild';
|
||||
|
||||
esbuild.build({
|
||||
entryPoints: ['src/index.ts'],
|
||||
outdir: 'dist',
|
||||
bundle: true,
|
||||
sourcemap: false,
|
||||
minify: false,
|
||||
format: 'esm',
|
||||
target: ['es2020'],
|
||||
});
|
||||
38
packages/plugin-sdk/package.json
Normal file
38
packages/plugin-sdk/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@immich/plugin-sdk",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./host-functions": {
|
||||
"import": "./dist/host-functions.js",
|
||||
"types": "./dist/host-functions.d.ts"
|
||||
},
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "node esbuild.js && tsc --emitDeclarationOnly && tsc-alias"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1",
|
||||
"@types/node": "^24.11.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1"
|
||||
}
|
||||
}
|
||||
33
packages/plugin-sdk/src/enum.ts
Normal file
33
packages/plugin-sdk/src/enum.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = 'AssetCreate',
|
||||
PersonRecognized = 'PersonRecognized',
|
||||
}
|
||||
|
||||
export enum WorkflowType {
|
||||
AssetV1 = 'AssetV1',
|
||||
AssetPersonV1 = 'AssetPersonV1',
|
||||
}
|
||||
|
||||
export enum AssetType {
|
||||
Image = 'IMAGE',
|
||||
Video = 'VIDEO',
|
||||
Audio = 'AUDIO',
|
||||
Other = 'OTHER',
|
||||
}
|
||||
|
||||
export enum AssetStatus {
|
||||
Active = 'active',
|
||||
Trashed = 'trashed',
|
||||
Deleted = 'deleted',
|
||||
}
|
||||
|
||||
export enum AssetVisibility {
|
||||
Archive = 'archive',
|
||||
Timeline = 'timeline',
|
||||
|
||||
/**
|
||||
* Video part of the LivePhotos and MotionPhotos
|
||||
*/
|
||||
Hidden = 'hidden',
|
||||
Locked = 'locked',
|
||||
}
|
||||
41
packages/plugin-sdk/src/host-functions.ts
Normal file
41
packages/plugin-sdk/src/host-functions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
albumAddAssets(ptr: PTR): I64;
|
||||
}
|
||||
}
|
||||
|
||||
const host = Host.getFunctions();
|
||||
type HostFunctionName = keyof typeof host;
|
||||
|
||||
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
|
||||
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args }));
|
||||
const fn = host[name];
|
||||
const handler = Memory.find(fn(pointer1.offset));
|
||||
|
||||
try {
|
||||
const result = JSON.parse(handler.readString()) as
|
||||
| {
|
||||
success: true;
|
||||
response: R;
|
||||
}
|
||||
| { success: false; status: number; message: string };
|
||||
|
||||
if (result.success) {
|
||||
return result.response;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to call host function "${name}", received ${
|
||||
result.status
|
||||
} - ${JSON.stringify(result.message)}`
|
||||
);
|
||||
} finally {
|
||||
handler.free();
|
||||
pointer1.free();
|
||||
}
|
||||
};
|
||||
|
||||
export const hostFunctions = (authToken: string) => ({
|
||||
albumAddAssets: (albumId: string, assetIds: string[]) =>
|
||||
call('albumAddAssets', authToken, [albumId, { ids: assetIds }]),
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user