From b6e1de5999ea96999ea69d87a7a228ee1fca1651 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 20 Jul 2026 20:44:16 -0700 Subject: [PATCH] feat: Modernize the visual card editor. (#2586) --- docs/configuration/menu.md | 2 +- docs/images/icons/frigate.svg | 46 +- docs/images/icons/iris.svg | 50 +- docs/images/icons/motioneye.svg | 260 +- docs/images/icons/reolink.svg | 54 +- docs/images/icons/tplink.svg | 6 +- docs/index.html | 7 +- docs/usage/custom-icons.md | 29 +- package.json | 1 - rollup.config.js | 7 +- scripts/clean-dist-plugin.js | 32 + scripts/docs-update-icons.sh | 21 +- scripts/svg-path-plugin.js | 28 + src/camera-manager/frigate/assets/frigate.svg | 45 - .../motioneye/assets/motioneye.svg | 259 - src/camera-manager/reolink/assets/reolink.svg | 53 - src/camera-manager/tplink/assets/tplink.svg | 5 - .../config/load-keyboard-shortcuts.ts | 19 +- src/card-controller/media-player-manager.ts | 11 +- src/card.ts | 3 + src/components-lib/editor/README.md | 13 + src/components-lib/editor/controller.ts | 245 + src/components-lib/editor/doc-links.ts | 111 + src/components-lib/editor/form-data.ts | 194 + src/components-lib/editor/form-labels.ts | 42 + src/components-lib/editor/forms-controller.ts | 209 + src/components-lib/editor/intents.ts | 28 + .../editor/keyboard-shortcuts-controller.ts | 86 + .../editor/list-forms-controller.ts | 97 + .../editor/list-pages-controller.ts | 48 + src/components-lib/editor/paths.ts | 23 + src/components-lib/editor/schema/cameras.ts | 472 ++ .../schema/common/controls/next-previous.ts | 79 + .../editor/schema/common/controls/ptz.ts | 112 + .../schema/common/controls/thumbnails.ts | 93 + .../editor/schema/common/controls/timeline.ts | 111 + .../editor/schema/common/display.ts | 57 + .../editor/schema/common/image.ts | 45 + .../editor/schema/common/interaction-mode.ts | 12 + .../editor/schema/common/media-actions.ts | 42 + .../editor/schema/common/proxy.ts | 104 + .../editor/schema/common/selectors.ts | 43 + .../editor/schema/common/transition-effect.ts | 14 + .../editor/schema/dimensions.ts | 40 + src/components-lib/editor/schema/folders.ts | 41 + src/components-lib/editor/schema/image.ts | 25 + src/components-lib/editor/schema/live.ts | 164 + .../editor/schema/media-gallery.ts | 34 + .../editor/schema/media-viewer.ts | 80 + src/components-lib/editor/schema/menu.ts | 176 + .../editor/schema/performance.ts | 46 + src/components-lib/editor/schema/profiles.ts | 31 + src/components-lib/editor/schema/registry.ts | 124 + .../editor/schema/remote-control.ts | 42 + .../editor/schema/status-bar.ts | 100 + src/components-lib/editor/schema/timeline.ts | 20 + src/components-lib/editor/schema/view.ts | 237 + .../editor/section-controller.ts | 68 + src/components-lib/editor/titles.ts | 70 + src/components-lib/editor/types.ts | 79 + src/components-lib/icon-controller.ts | 29 +- src/components-lib/key-assigner-controller.ts | 17 + src/components-lib/menu-button-controller.ts | 2 +- src/components-lib/timeline/controller.ts | 6 +- src/components/editor/cameras.ts | 237 + src/components/editor/doc-link.ts | 66 + src/components/editor/folders.ts | 109 + src/components/editor/form.ts | 30 + src/components/editor/keyboard-shortcuts.ts | 86 + src/components/editor/list.ts | 136 + src/components/editor/page.ts | 60 + src/components/editor/section.ts | 123 + src/components/effects/check.ts | 9 +- src/components/icon.ts | 27 +- src/components/key-assigner.ts | 4 +- src/components/loading.ts | 2 +- src/components/timeline-core.ts | 4 +- src/config/const.ts | 159 + src/config/management.ts | 113 +- src/config/profiles/casting.ts | 2 +- src/config/profiles/doorbell.ts | 2 +- src/config/profiles/low-performance.ts | 2 +- src/config/profiles/scrubbing.ts | 2 +- src/config/schema/common/controls/timeline.ts | 15 +- src/config/schema/performance.ts | 5 +- src/config/schema/view.ts | 19 +- src/const.ts | 487 +- src/declarations.d.ts | 5 +- src/editor.ts | 4226 +---------------- src/ha/custom-icons.ts | 64 + src/ha/side-load-ha-elements.ts | 7 + src/ha/types.ts | 108 + src/images/README.md | 11 +- src/images/icons/frigate.svg | 1 + src/images/icons/iris.svg | 1 + src/images/icons/motioneye.svg | 1 + src/images/icons/reolink.svg | 1 + src/images/icons/tplink.svg | 1 + src/images/iris.svg | 49 - src/localize/languages/ca.json | 56 +- src/localize/languages/de.json | 49 +- src/localize/languages/en.json | 155 +- src/localize/languages/fr.json | 56 +- src/localize/languages/it.json | 10 +- src/localize/languages/pl.json | 56 +- src/localize/languages/pt-BR.json | 10 +- src/localize/languages/pt-PT.json | 10 +- src/localize/localize.ts | 11 +- src/scss/editor-doc-link.scss | 34 + src/scss/editor-expander-body.scss | 45 + src/scss/editor-keyboard-shortcuts.scss | 19 + src/scss/editor-list.scss | 66 + src/scss/editor-page.scss | 36 + src/scss/editor-section.scss | 12 + src/scss/editor.scss | 155 +- src/scss/icon.scss | 8 +- src/scss/key-assigner.scss | 92 +- src/scss/loading.scss | 16 +- src/scss/themes/base.scss | 16 + src/utils/animation.ts | 3 +- .../media-player-manager.test.ts | 4 +- .../components-lib/editor/controller.test.ts | 358 ++ tests/components-lib/editor/doc-links.test.ts | 53 + tests/components-lib/editor/form-data.test.ts | 518 ++ .../components-lib/editor/form-labels.test.ts | 75 + .../editor/forms-controller.test.ts | 198 + .../keyboard-shortcuts-controller.test.ts | 132 + .../editor/list-forms-controller.test.ts | 233 + .../editor/list-pages-controller.test.ts | 73 + tests/components-lib/editor/paths.test.ts | 43 + .../editor/schema/common/selectors.test.ts | 42 + .../editor/schema/completeness.test.ts | 312 ++ .../editor/schema/registry.test.ts | 90 + .../editor/section-controller.test.ts | 100 + tests/components-lib/editor/titles.test.ts | 74 + tests/components-lib/icon-controller.test.ts | 49 +- .../key-assigner-controller.test.ts | 29 + .../menu-button-controller.test.ts | 4 +- .../notification-popup-controller.test.ts | 2 +- tests/config/management.test.ts | 119 +- tests/config/types.test.ts | 1 - tests/ha/custom-icons.test.ts | 103 + tests/localize/localize.test.ts | 8 +- tsconfig.json | 3 + vite.config.ts | 3 + yarn.lock | 115 +- 146 files changed, 8487 insertions(+), 5992 deletions(-) create mode 100644 scripts/clean-dist-plugin.js create mode 100644 scripts/svg-path-plugin.js delete mode 100644 src/camera-manager/frigate/assets/frigate.svg delete mode 100644 src/camera-manager/motioneye/assets/motioneye.svg delete mode 100644 src/camera-manager/reolink/assets/reolink.svg delete mode 100644 src/camera-manager/tplink/assets/tplink.svg create mode 100644 src/components-lib/editor/README.md create mode 100644 src/components-lib/editor/controller.ts create mode 100644 src/components-lib/editor/doc-links.ts create mode 100644 src/components-lib/editor/form-data.ts create mode 100644 src/components-lib/editor/form-labels.ts create mode 100644 src/components-lib/editor/forms-controller.ts create mode 100644 src/components-lib/editor/intents.ts create mode 100644 src/components-lib/editor/keyboard-shortcuts-controller.ts create mode 100644 src/components-lib/editor/list-forms-controller.ts create mode 100644 src/components-lib/editor/list-pages-controller.ts create mode 100644 src/components-lib/editor/paths.ts create mode 100644 src/components-lib/editor/schema/cameras.ts create mode 100644 src/components-lib/editor/schema/common/controls/next-previous.ts create mode 100644 src/components-lib/editor/schema/common/controls/ptz.ts create mode 100644 src/components-lib/editor/schema/common/controls/thumbnails.ts create mode 100644 src/components-lib/editor/schema/common/controls/timeline.ts create mode 100644 src/components-lib/editor/schema/common/display.ts create mode 100644 src/components-lib/editor/schema/common/image.ts create mode 100644 src/components-lib/editor/schema/common/interaction-mode.ts create mode 100644 src/components-lib/editor/schema/common/media-actions.ts create mode 100644 src/components-lib/editor/schema/common/proxy.ts create mode 100644 src/components-lib/editor/schema/common/selectors.ts create mode 100644 src/components-lib/editor/schema/common/transition-effect.ts create mode 100644 src/components-lib/editor/schema/dimensions.ts create mode 100644 src/components-lib/editor/schema/folders.ts create mode 100644 src/components-lib/editor/schema/image.ts create mode 100644 src/components-lib/editor/schema/live.ts create mode 100644 src/components-lib/editor/schema/media-gallery.ts create mode 100644 src/components-lib/editor/schema/media-viewer.ts create mode 100644 src/components-lib/editor/schema/menu.ts create mode 100644 src/components-lib/editor/schema/performance.ts create mode 100644 src/components-lib/editor/schema/profiles.ts create mode 100644 src/components-lib/editor/schema/registry.ts create mode 100644 src/components-lib/editor/schema/remote-control.ts create mode 100644 src/components-lib/editor/schema/status-bar.ts create mode 100644 src/components-lib/editor/schema/timeline.ts create mode 100644 src/components-lib/editor/schema/view.ts create mode 100644 src/components-lib/editor/section-controller.ts create mode 100644 src/components-lib/editor/titles.ts create mode 100644 src/components-lib/editor/types.ts create mode 100644 src/components/editor/cameras.ts create mode 100644 src/components/editor/doc-link.ts create mode 100644 src/components/editor/folders.ts create mode 100644 src/components/editor/form.ts create mode 100644 src/components/editor/keyboard-shortcuts.ts create mode 100644 src/components/editor/list.ts create mode 100644 src/components/editor/page.ts create mode 100644 src/components/editor/section.ts create mode 100644 src/config/const.ts create mode 100644 src/ha/custom-icons.ts create mode 100644 src/images/icons/frigate.svg create mode 100644 src/images/icons/iris.svg create mode 100644 src/images/icons/motioneye.svg create mode 100644 src/images/icons/reolink.svg create mode 100644 src/images/icons/tplink.svg delete mode 100644 src/images/iris.svg create mode 100644 src/scss/editor-doc-link.scss create mode 100644 src/scss/editor-expander-body.scss create mode 100644 src/scss/editor-keyboard-shortcuts.scss create mode 100644 src/scss/editor-list.scss create mode 100644 src/scss/editor-page.scss create mode 100644 src/scss/editor-section.scss create mode 100644 tests/components-lib/editor/controller.test.ts create mode 100644 tests/components-lib/editor/doc-links.test.ts create mode 100644 tests/components-lib/editor/form-data.test.ts create mode 100644 tests/components-lib/editor/form-labels.test.ts create mode 100644 tests/components-lib/editor/forms-controller.test.ts create mode 100644 tests/components-lib/editor/keyboard-shortcuts-controller.test.ts create mode 100644 tests/components-lib/editor/list-forms-controller.test.ts create mode 100644 tests/components-lib/editor/list-pages-controller.test.ts create mode 100644 tests/components-lib/editor/paths.test.ts create mode 100644 tests/components-lib/editor/schema/common/selectors.test.ts create mode 100644 tests/components-lib/editor/schema/completeness.test.ts create mode 100644 tests/components-lib/editor/schema/registry.test.ts create mode 100644 tests/components-lib/editor/section-controller.test.ts create mode 100644 tests/components-lib/editor/titles.test.ts create mode 100644 tests/ha/custom-icons.test.ts diff --git a/docs/configuration/menu.md b/docs/configuration/menu.md index b8e5b17c..c6ca451a 100644 --- a/docs/configuration/menu.md +++ b/docs/configuration/menu.md @@ -183,7 +183,7 @@ menu: enabled: true inert: false alignment: matching - icon: iris + icon: advanced-camera-card:iris live: priority: 50 enabled: true diff --git a/docs/images/icons/frigate.svg b/docs/images/icons/frigate.svg index bdbdc102..86a024e6 100644 --- a/docs/images/icons/frigate.svg +++ b/docs/images/icons/frigate.svg @@ -1,45 +1 @@ - - - - - - + diff --git a/docs/images/icons/iris.svg b/docs/images/icons/iris.svg index 0616d097..d1b3da1c 100644 --- a/docs/images/icons/iris.svg +++ b/docs/images/icons/iris.svg @@ -1,49 +1 @@ - - - - - - - - - + diff --git a/docs/images/icons/motioneye.svg b/docs/images/icons/motioneye.svg index 57c10b9c..4b321aca 100644 --- a/docs/images/icons/motioneye.svg +++ b/docs/images/icons/motioneye.svg @@ -1,259 +1 @@ - - - -image/svg+xml + diff --git a/docs/images/icons/reolink.svg b/docs/images/icons/reolink.svg index ff9abe6c..88447c17 100644 --- a/docs/images/icons/reolink.svg +++ b/docs/images/icons/reolink.svg @@ -1,53 +1 @@ - - - - - - - - - - - - + diff --git a/docs/images/icons/tplink.svg b/docs/images/icons/tplink.svg index f5ad54e0..0227968b 100644 --- a/docs/images/icons/tplink.svg +++ b/docs/images/icons/tplink.svg @@ -1,5 +1 @@ - - - - - + diff --git a/docs/index.html b/docs/index.html index 00202e41..09ea4472 100644 --- a/docs/index.html +++ b/docs/index.html @@ -26,10 +26,11 @@ --cover-heading-font-size-max: 48; } .svg-icon { - background-color: rgb(255, 255, 255); + border-radius: 4px; + padding: 2px; vertical-align: middle; - background: repeating-conic-gradient(#80808091 0% 25%, transparent 0% 50%) 50% / - 4px 4px; + background: repeating-conic-gradient(#3a3a3a 0% 25%, #282828 0% 50%) 50% / 4px + 4px; } #__sidebar img { max-width: 48px; diff --git a/docs/usage/custom-icons.md b/docs/usage/custom-icons.md index 5724a9e9..76dfaa25 100644 --- a/docs/usage/custom-icons.md +++ b/docs/usage/custom-icons.md @@ -1,14 +1,21 @@ # Custom Icons -The [`menu`](../configuration/menu.md) and -[`status_bar`](../configuration/status-bar.md) both support a small set of -builtin custom items not found in the traditional [Material Design Icons -(`mdi`)](https://pictogrammers.com/library/mdi/) set by setting the relevant `icon` paramter to the following values. +The card registers an `advanced-camera-card` iconset with Home Assistant, +providing a small set of builtin icons not found in the traditional [Material +Design Icons (`mdi`)](https://pictogrammers.com/library/mdi/) set. Once the +card resource is loaded, these icons work in _any_ Home Assistant icon field +(inside or outside this card, e.g. an entity icon) and appear in the Home +Assistant icon picker. -| Icon Name | Resulting Icon | -| ----------- | --------------------------------------------------------------------------- | -| `frigate` | ![Frigate Icon](../images/icons/frigate.svg ':size=24 :class=svg-icon') | -| `iris` | ![Iris Icon](../images/icons/iris.svg ':size=24 :class=svg-icon') | -| `motioneye` | ![motionEye Icon](../images/icons/motioneye.svg ':size=24 :class=svg-icon') | -| `reolink` | ![Reolink Icon](../images/icons/reolink.svg ':size=24 :class=svg-icon') | -| `tplink` | ![TPLink Icon](../images/icons/tplink.svg ':size=24 :class=svg-icon') | +| Icon Name | Resulting Icon | +| -------------------------------- | --------------------------------------------------------------------------- | +| `advanced-camera-card:frigate` | ![Frigate Icon](../images/icons/frigate.svg ':size=24 :class=svg-icon') | +| `advanced-camera-card:iris` | ![Iris Icon](../images/icons/iris.svg ':size=24 :class=svg-icon') | +| `advanced-camera-card:motioneye` | ![motionEye Icon](../images/icons/motioneye.svg ':size=24 :class=svg-icon') | +| `advanced-camera-card:reolink` | ![Reolink Icon](../images/icons/reolink.svg ':size=24 :class=svg-icon') | +| `advanced-camera-card:tplink` | ![TPLink Icon](../images/icons/tplink.svg ':size=24 :class=svg-icon') | + +?> For backwards compatibility, icon fields within this card (e.g. +[`menu`](../configuration/menu.md) and +[`status_bar`](../configuration/status-bar.md) items) also accept the bare +legacy names (`frigate`, `iris`, `motioneye`, `reolink`, `tplink`). diff --git a/package.json b/package.json index c99f1b8b..96f436b9 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "rollup-plugin-git-info": "^1.0.0", "rollup-plugin-serve": "^1.1.1", "rollup-plugin-styler": "^1.8.0", - "rollup-plugin-svgo": "^2.0.0", "rollup-plugin-visualizer": "^5.12.0", "sass": "patch:sass@npm%3A1.77.4#~/.yarn/patches/sass-npm-1.77.4-13b6910aea.patch", "semantic-release": "^24.1.1", diff --git a/rollup.config.js b/rollup.config.js index d46e3ace..9b881031 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,9 +8,11 @@ import typescript from '@rollup/plugin-typescript'; import gitInfo from 'rollup-plugin-git-info'; import serve from 'rollup-plugin-serve'; import styles from 'rollup-plugin-styler'; -import svgo from 'rollup-plugin-svgo'; import { visualizer } from 'rollup-plugin-visualizer'; +import { cleanDist } from './scripts/clean-dist-plugin.js'; +import { svgPath } from './scripts/svg-path-plugin.js'; + const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1'; const dev = watch || process.env.DEV === 'true' || process.env.DEV === '1'; @@ -35,6 +37,7 @@ const serveopts = { * @type {import('rollup').RollupOptions['plugins']} */ const plugins = [ + cleanDist(), gitInfo.default( // Limit git-info to the project's own package.json. Without this it also // rewrites any dependency's imported package.json into ESM, which then breaks @@ -54,7 +57,7 @@ const plugins = [ includePaths: ['./node_modules/'], }, }), - svgo(), + svgPath(), image({ exclude: '**/*.svg' }), nodeResolve({ browser: true, diff --git a/scripts/clean-dist-plugin.js b/scripts/clean-dist-plugin.js new file mode 100644 index 00000000..87a5e3f8 --- /dev/null +++ b/scripts/clean-dist-plugin.js @@ -0,0 +1,32 @@ +import { existsSync, readdirSync, rmSync } from 'node:fs'; + +/** + * Rollup plugin: deletes prior build artifacts from `dist/` (which otherwise + * accumulate stale hashed chunks that can be served in place of fresh output). + * Deletes only top-level build artifacts by extension (no recursion), so + * anything unexpected in `dist/` survives. Cleans once per process: watch mode + * cleans at startup, not on every incremental rebuild. + * + * @type {() => import('rollup').Plugin} + */ +export const cleanDist = () => { + let cleaned = false; + return { + name: 'clean-dist', + buildStart() { + if (cleaned) { + return; + } + cleaned = true; + const dist = new URL('../dist/', import.meta.url); + if (!existsSync(dist)) { + return; + } + for (const entry of readdirSync(dist, { withFileTypes: true })) { + if (entry.isFile() && /\.js(\.map)?$/.test(entry.name)) { + rmSync(new URL(entry.name, dist)); + } + } + }, + }; +}; diff --git a/scripts/docs-update-icons.sh b/scripts/docs-update-icons.sh index 1e6362e1..de5f9099 100755 --- a/scripts/docs-update-icons.sh +++ b/scripts/docs-update-icons.sh @@ -2,22 +2,9 @@ # This script copies icons out of the source tree for documentation. -FRIGATE="./src/camera-manager/frigate/assets/frigate.svg" -IRIS="./src/images/iris.svg" -MOTIONEYE="./src/camera-manager/motioneye/assets/motioneye.svg" -REOLINK="./src/camera-manager/reolink/assets/reolink.svg" -TPLINK="./src/camera-manager/tplink/assets/tplink.svg" - DIR_ICONS="./docs/images/icons" -copy() { - PATH="$1" - echo "Copying image $PATH to $DIR_ICONS" - /bin/cp "$PATH" "$DIR_ICONS" -} - -copy "$FRIGATE" -copy "$IRIS" -copy "$MOTIONEYE" -copy "$REOLINK" -copy "$TPLINK" +for ICON in ./src/images/icons/*.svg; do + echo "Copying image $ICON to $DIR_ICONS" + /bin/cp "$ICON" "$DIR_ICONS" +done diff --git a/scripts/svg-path-plugin.js b/scripts/svg-path-plugin.js new file mode 100644 index 00000000..614c16a7 --- /dev/null +++ b/scripts/svg-path-plugin.js @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; + +/** + * Rollup/Vite plugin: importing an SVG yields `{ path, viewBox }` extracted at + * build time, the shape a Home Assistant custom iconset serves. The SVG must + * be a single-path icon. + * + * @type {() => import('rollup').Plugin} + */ +export const svgPath = () => ({ + name: 'svg-path', + + // Vite: run before its builtin asset handling. + enforce: 'pre', + + load(id) { + if (!id.endsWith('.svg')) { + return null; + } + const source = readFileSync(id, 'utf-8'); + const path = / d="([^"]*)"/.exec(source)?.[1]; + const viewBox = /viewBox="([^"]*)"/.exec(source)?.[1]; + if (!path || !viewBox) { + throw new Error(`Imported SVG must have a path and viewBox: ${id}`); + } + return `export default ${JSON.stringify({ path, viewBox })};`; + }, +}); diff --git a/src/camera-manager/frigate/assets/frigate.svg b/src/camera-manager/frigate/assets/frigate.svg deleted file mode 100644 index bdbdc102..00000000 --- a/src/camera-manager/frigate/assets/frigate.svg +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - diff --git a/src/camera-manager/motioneye/assets/motioneye.svg b/src/camera-manager/motioneye/assets/motioneye.svg deleted file mode 100644 index 57c10b9c..00000000 --- a/src/camera-manager/motioneye/assets/motioneye.svg +++ /dev/null @@ -1,259 +0,0 @@ - - - -image/svg+xml diff --git a/src/camera-manager/reolink/assets/reolink.svg b/src/camera-manager/reolink/assets/reolink.svg deleted file mode 100644 index ff9abe6c..00000000 --- a/src/camera-manager/reolink/assets/reolink.svg +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - diff --git a/src/camera-manager/tplink/assets/tplink.svg b/src/camera-manager/tplink/assets/tplink.svg deleted file mode 100644 index f5ad54e0..00000000 --- a/src/camera-manager/tplink/assets/tplink.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/card-controller/config/load-keyboard-shortcuts.ts b/src/card-controller/config/load-keyboard-shortcuts.ts index 4cd4546c..ca31a149 100644 --- a/src/card-controller/config/load-keyboard-shortcuts.ts +++ b/src/card-controller/config/load-keyboard-shortcuts.ts @@ -1,7 +1,8 @@ import type { PTZAction } from '../../config/schema/actions/custom/ptz'; -import type { - KeyboardShortcuts, - PTZKeyboardShortcutName, +import { + PTZ_KEYBOARD_SHORTCUTS, + type KeyboardShortcuts, + type PTZKeyboardShortcutName, } from '../../config/schema/view'; import { createPTZMultiAction } from '../../utils/action'; import type { CardConfigLoaderAPI, TaggedAutomation } from '../types'; @@ -38,7 +39,6 @@ const ptzKeyboardShortcutToPTZAction = ( case 'ptz_zoom_out': return 'zoom_out'; } - /* istanbul ignore next: No (current) way to reach this code -- @preserve */ return null; }; @@ -52,14 +52,9 @@ const convertKeyboardShortcutsToAutomations = ( const automations: TaggedAutomation[] = []; - for (const name of [ - 'ptz_down', - 'ptz_left', - 'ptz_right', - 'ptz_up', - 'ptz_zoom_in', - 'ptz_zoom_out', - ] as const) { + // `ptz_home` has no start/stop action, so it maps to a null action here and + // is handled separately below. + for (const name of PTZ_KEYBOARD_SHORTCUTS) { const shortcut = shortcuts[name]; const ptzAction = ptzKeyboardShortcutToPTZAction(name); if (!shortcut || !ptzAction) { diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts index 96ae8ae7..dff8ae29 100644 --- a/src/card-controller/media-player-manager.ts +++ b/src/card-controller/media-player-manager.ts @@ -1,10 +1,5 @@ import type { CameraConfig } from '../config/schema/cameras'; import type { AdvancedCameraCardConfig } from '../config/schema/types'; -import { - MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, - MEDIA_PLAYER_SUPPORT_STOP, - MEDIA_PLAYER_SUPPORT_TURN_OFF, -} from '../const'; import type { Entity } from '../ha/registry/entity/types'; import { supportsFeature } from '../ha/supports-feature'; import { errorToConsole } from '../utils/basic'; @@ -12,6 +7,12 @@ import type { ViewMedia } from '../view/item'; import { ViewItemClassifier } from '../view/item-classifier'; import type { CardMediaPlayerAPI } from './types'; +// Media player support bitmask flags. Taken from: +// https://github.com/home-assistant/frontend/blob/a759767d794f02527d127802831e68d3caf0cb7a/src/data/media-player.ts#L82 +export const MEDIA_PLAYER_SUPPORT_TURN_OFF = 256; +export const MEDIA_PLAYER_SUPPORT_STOP = 4096; +export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; + export class MediaPlayerManager { private _mediaPlayers: string[] = []; diff --git a/src/card.ts b/src/card.ts index 51ab6299..65d9ef8f 100644 --- a/src/card.ts +++ b/src/card.ts @@ -54,6 +54,7 @@ import type { RawAdvancedCameraCardConfig, } from './config/types.js'; import { REPO_URL } from './const.js'; +import { registerCustomIconset } from './ha/custom-icons.js'; import type { HomeAssistant, LovelaceCardEditor } from './ha/types.js'; import { localize } from './localize/localize.js'; import cardStyle from './scss/card.scss'; @@ -105,6 +106,8 @@ console.info( getEntitySuggestion: ConfigManager.getEntitySuggestion, }); +registerCustomIconset(); + // Expose currently-connected card instances on `window.advancedCameraCards` for // console-based debugging and user support. `??=` so a double-loaded card // shares one array (same pattern as `customCards` above). diff --git a/src/components-lib/editor/README.md b/src/components-lib/editor/README.md new file mode 100644 index 00000000..b23f835f --- /dev/null +++ b/src/components-lib/editor/README.md @@ -0,0 +1,13 @@ +# Card editor + +![Architecture of the card editor](architecture.svg) + +The SVG is also its own editable source: it embeds the draw.io diagram, so open +`architecture.svg` directly in draw.io / diagrams.net to change it. + +## Maintaining the diagram + +`architecture.svg` is a dual file: the **rendered SVG** (shown above / in +GitHub) plus the **editable mxGraph XML** embedded in its `content="..."` +attribute. Keep them in sync -- never commit an SVG whose embedded XML changed +but whose rendered picture did not, or the image above goes stale. diff --git a/src/components-lib/editor/controller.ts b/src/components-lib/editor/controller.ts new file mode 100644 index 00000000..eac7ca74 --- /dev/null +++ b/src/components-lib/editor/controller.ts @@ -0,0 +1,245 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import { CONF_CAMERAS, CONF_FOLDERS, CONF_PROFILES } from '../../config/const'; +import { + addConfigArrayItem, + copyConfig, + deleteConfigArrayItem, + getConfigValue, + isConfigUpgradeable, + moveConfigArrayItem, + upgradeConfig, +} from '../../config/management'; +import { setProfiles } from '../../config/profiles/set-profiles'; +import { profilesSchema, type ProfileType } from '../../config/schema/profiles'; +import { configDefaults } from '../../config/schema/types'; +import type { + RawAdvancedCameraCardConfig, + RawAdvancedCameraCardConfigArray, +} from '../../config/types'; +import { fireHASSEvent } from '../../ha/fire-hass-event'; +import { sideLoadHomeAssistantElements } from '../../ha/side-load-ha-elements'; +import type { HomeAssistant } from '../../ha/types'; +import { localize } from '../../localize/localize'; +import { errorToConsole, isRecord } from '../../utils/basic'; +import { getCameraID } from '../../utils/camera'; +import { getFolderID } from '../../utils/folder'; +import { applyConfigChanges } from './form-data'; +import type { FormsInput } from './forms-controller'; +import type { EditorIntent } from './intents'; +import type { FormRequestOptions } from './schema/registry'; +import { getEditorCameraTitle, getEditorFolderTitle } from './titles'; + +type EditorControllerHost = ReactiveControllerHost & EventTarget; + +// A card-state notice the editor shows as a banner above the sections. +interface EditorNotice { + type: 'info' | 'warning'; + message: string; +} + +// Interpret a raw configuration value as an array of configuration objects. +// Non-object entries become empty objects rather than being dropped, so that +// list indices always match the configuration's own indices (editor edits +// address items by index). +const toConfigArray = (value: unknown): RawAdvancedCameraCardConfigArray => + Array.isArray(value) ? value.map((entry) => (isRecord(entry) ? entry : {})) : []; + +/** + * The editor's view of the card configuration: its contents, the defaults an + * unset field falls back to, and anything about it worth telling the user. + * The sections read the configuration through here and report back what the + * user asked to do; this is the only place that changes it. + */ +export class EditorController implements ReactiveController { + private _host: EditorControllerHost; + + // Note: The configuration is not parsed with Zod, so it may be partially or + // completely invalid. A partially valid configuration is more useful than + // none, to allow the user to fix the broken parts. + private _config: RawAdvancedCameraCardConfig | null = null; + + // The defaults shown for unset fields; profiles change them. + private _defaults = copyConfig(configDefaults); + + private _configUpgradeable = false; + private _initialized = false; + private _hass?: HomeAssistant; + + // The parsed configuration profiles (empty when unset or invalid). + private _profiles: ProfileType[] = []; + + constructor(host: EditorControllerHost) { + this._host = host; + host.addController(this); + } + + public hostConnected(): void { + // No connection-time work. + } + + // Load the Home Assistant elements the editor renders. A failure leaves the + // editor with degraded form elements and is retried on the next update; it + // is logged so the cause is at least visible. + public initialize(): void { + if (this._initialized) { + return; + } + sideLoadHomeAssistantElements() + .then(() => { + this._initialized = true; + }) + .catch((e) => errorToConsole(e)); + } + + public setConfig(config: RawAdvancedCameraCardConfig): void { + this._applyConfig(config); + this._host.requestUpdate(); + } + + // Adopt a configuration and derive everything that depends on it. Used for + // both an externally supplied configuration and an edit made in the editor, + // such that the two cannot diverge. + private _applyConfig(config: RawAdvancedCameraCardConfig): void { + this._config = config; + this._configUpgradeable = isConfigUpgradeable(config); + + // The defaults are rebuilt from scratch so that removing (or breaking) a + // profile also removes its adjusted defaults. + const profiles = profilesSchema.safeParse(getConfigValue(config, CONF_PROFILES)); + this._profiles = profiles.success ? profiles.data ?? [] : []; + const defaults = copyConfig(configDefaults); + if (profiles.success) { + setProfiles(config, defaults, profiles.data); + } + this._defaults = defaults; + } + + public getNotices(): EditorNotice[] { + const notices: EditorNotice[] = []; + + if (this._profiles.includes('low-performance')) { + notices.push({ + type: 'warning', + message: localize('config.performance.warning'), + }); + } + + const overrides = this._config?.['overrides']; + if (Array.isArray(overrides) && overrides.length) { + notices.push({ type: 'info', message: localize('config.overrides.info') }); + } + return notices; + } + + public getConfig(): RawAdvancedCameraCardConfig | null { + return this._config; + } + + public getDefaults(): RawAdvancedCameraCardConfig { + return this._defaults; + } + + /** + * Get what a section needs to show its forms: the configuration the forms + * display, the defaults an unset field falls back to, and the lists its + * dropdowns choose from (the entities, the cameras a camera may depend on, + * the folders its media may come from). + * @returns The section input. + */ + public getFormsInput(): FormsInput { + return { + config: this._config ?? {}, + defaults: this._defaults, + options: this._getFormRequestOptions(), + }; + } + + private _getFormRequestOptions(): FormRequestOptions { + return { + cameras: this.getCameras().map((camera, index) => ({ + value: getCameraID(camera), + label: getEditorCameraTitle(index, camera, this._hass), + })), + folders: this.getFolders().map((folder, index) => ({ + value: getFolderID(folder, index), + label: getEditorFolderTitle(index, folder), + })), + }; + } + + public setHASS(hass: HomeAssistant): void { + this._hass = hass; + } + + public getHASS(): HomeAssistant | undefined { + return this._hass; + } + + public isConfigUpgradeable(): boolean { + return this._configUpgradeable; + } + + public upgrade(): void { + this._modifyConfig(upgradeConfig); + } + + public getCameras(): RawAdvancedCameraCardConfigArray { + return toConfigArray(getConfigValue(this._config ?? {}, CONF_CAMERAS)); + } + + public getFolders(): RawAdvancedCameraCardConfigArray { + return toConfigArray(getConfigValue(this._config ?? {}, CONF_FOLDERS)); + } + + /** + * Carry out what a section reported the user asked for. Every path an intent + * carries is absolute, so nothing here needs to know which section it came + * from. + * @param intent What the user asked to do. + */ + public applyIntent(intent: EditorIntent): void { + if (!this._config) { + return; + } + switch (intent.type) { + case 'changes': { + const newConfig = applyConfigChanges(this._config, intent.changes); + if (newConfig) { + this._updateConfig(newConfig); + } + return; + } + case 'list-add': + this._modifyConfig((config) => + addConfigArrayItem(config, intent.path, intent.item), + ); + return; + case 'list-move': + this._modifyConfig((config) => + moveConfigArrayItem(config, intent.path, intent.from, intent.to), + ); + return; + case 'list-delete': + this._modifyConfig((config) => + deleteConfigArrayItem(config, intent.path, intent.index), + ); + return; + } + } + + private _modifyConfig(func: (config: RawAdvancedCameraCardConfig) => boolean): void { + if (this._config) { + const newConfig = copyConfig(this._config); + if (func(newConfig)) { + this._updateConfig(newConfig); + } + } + } + + private _updateConfig(config: RawAdvancedCameraCardConfig): void { + this._applyConfig(config); + this._host.requestUpdate(); + fireHASSEvent(this._host, 'config-changed', { config }); + } +} diff --git a/src/components-lib/editor/doc-links.ts b/src/components-lib/editor/doc-links.ts new file mode 100644 index 00000000..8ef86b6a --- /dev/null +++ b/src/components-lib/editor/doc-links.ts @@ -0,0 +1,111 @@ +import { DOCS_URL } from '../../const'; +import type { HAFormSchema } from '../../ha/types'; +import { getFormContainerPath, stripArrayIndices } from './paths'; + +// Documentation links keyed by configuration path (without array indices). +// Sections and expandable groups with an entry here render a documentation +// link row. +const DOC_LINKS: Record = { + cameras: 'configuration/cameras/README', + 'cameras.capabilities': 'configuration/cameras/README?id=capabilities', + 'cameras.cast': 'configuration/cameras/README?id=cast', + 'cameras.dependencies': 'configuration/cameras/README?id=dependencies', + 'cameras.dimensions': 'configuration/cameras/README?id=dimensions', + 'cameras.dimensions.layout': 'configuration/cameras/README?id=layout-configuration', + 'cameras.engine': 'configuration/cameras/engine', + 'cameras.frigate': 'configuration/cameras/engine?id=frigate', + 'cameras.go2rtc': 'configuration/cameras/live-provider?id=go2rtc', + 'cameras.image': 'configuration/cameras/live-provider?id=image', + 'cameras.live_provider': 'configuration/cameras/live-provider', + 'cameras.media': 'configuration/cameras/README?id=media', + 'cameras.motioneye': 'configuration/cameras/engine?id=motioneye', + 'cameras.proxy': 'configuration/cameras/README?id=proxy', + 'cameras.reolink': 'configuration/cameras/engine?id=reolink', + 'cameras.triggers': 'configuration/cameras/README?id=triggers', + 'cameras.triggers.reviews': 'configuration/cameras/README?id=reviews', + 'cameras.webrtc_card': 'configuration/cameras/live-provider?id=webrtc_card', + dimensions: 'configuration/dimensions', + folders: 'configuration/folders', + 'folders.ha': 'configuration/folders?id=ha', + image: 'configuration/image', + 'image.proxy': 'configuration/image?id=proxy', + live: 'configuration/live', + 'live.controls': 'configuration/live?id=controls', + 'live.controls.call': 'configuration/live?id=call', + 'live.controls.next_previous': 'configuration/live?id=next_previous', + 'live.controls.ptz': 'configuration/live?id=ptz', + 'live.controls.thumbnails': 'configuration/live?id=thumbnails', + 'live.controls.timeline': 'configuration/live?id=timeline', + 'live.controls.timeline.format': 'configuration/live?id=format', + 'live.display': 'configuration/live?id=display', + 'live.microphone': 'configuration/live?id=microphone', + media_gallery: 'configuration/media-gallery', + 'media_gallery.controls.filter': 'configuration/media-gallery?id=filter', + 'media_gallery.controls.thumbnails': 'configuration/media-gallery?id=thumbnails', + media_viewer: 'configuration/media-viewer', + 'media_viewer.controls': 'configuration/media-viewer?id=controls', + 'media_viewer.controls.next_previous': 'configuration/media-viewer?id=next_previous', + 'media_viewer.controls.thumbnails': 'configuration/media-viewer?id=thumbnails', + 'media_viewer.controls.timeline': 'configuration/media-viewer?id=timeline', + 'media_viewer.controls.timeline.format': 'configuration/media-viewer?id=format', + 'media_viewer.display': 'configuration/media-viewer?id=display', + menu: 'configuration/menu', + 'menu.buttons': 'configuration/menu?id=buttons', + options: 'configuration/README', + performance: 'configuration/performance', + 'performance.features': 'configuration/performance?id=features', + 'performance.style': 'configuration/performance?id=style', + profiles: 'configuration/profiles', + remote_control: 'configuration/remote-control', + 'remote_control.entities': 'configuration/remote-control?id=entities', + status_bar: 'configuration/status-bar', + 'status_bar.items': 'configuration/status-bar?id=items', + timeline: 'configuration/timeline', + 'timeline.controls.thumbnails': 'configuration/timeline?id=thumbnails', + 'timeline.format': 'configuration/timeline?id=format', + view: 'configuration/view', + 'view.default_reset': 'configuration/view?id=default_reset', + 'view.issues': 'configuration/view?id=issues', + 'view.keyboard_shortcuts': 'configuration/view?id=keyboard_shortcuts', + 'view.triggers': 'configuration/view?id=triggers', + 'view.triggers.actions': 'configuration/view?id=trigger-action-configuration', +}; + +/** + * Get the documentation URL for a configuration path. + * @param path The configuration path segments (array indices are ignored). + * @returns A full documentation URL, or null if the path has no documentation + * link. + */ +export const getDocURL = (path: (string | number)[]): string | null => { + const docPath = DOC_LINKS[stripArrayIndices(path).join('.')]; + return docPath ? `${DOCS_URL}/#/${docPath}` : null; +}; + +/** + * Get the documentation-link path for a form node. + * @param basePath The path of the form's data within the configuration. + * @param schema The node's schema. + * @param options Path context provided by `ha-form` for nested containers. + * @returns The configuration path to link to, or null for no link. + */ +export const getDocLinkPath = ( + basePath: (string | number)[], + schema: HAFormSchema, + options?: { path?: string[] }, +): (string | number)[] | null => { + // Documentation links attach to containers (sections/groups), never to + // individual fields. + if ('selector' in schema) { + return null; + } + + // A nameless group (an editor-only grouping such as "Engines") carries an + // explicit `docPath`; a named group derives its path from the configuration + // path. + return schema.docPath + ? schema.docPath.split('.') + : schema.name + ? [...basePath, ...getFormContainerPath(options), schema.name] + : null; +}; diff --git a/src/components-lib/editor/form-data.ts b/src/components-lib/editor/form-data.ts new file mode 100644 index 00000000..6c87248c --- /dev/null +++ b/src/components-lib/editor/form-data.ts @@ -0,0 +1,194 @@ +import { isEqual } from 'lodash-es'; + +import { + copyConfig, + deleteConfigValue, + getConfigValue, + setConfigValue, +} from '../../config/management'; +import type { RawAdvancedCameraCardConfig } from '../../config/types'; +import type { HAFormSchema, HAFormSelectorSchema } from '../../ha/types'; +import { isRecord } from '../../utils/basic'; +import { + isComputedFieldBinding, + type ConfigChange, + type ConfigPath, + type EditorForm, + type FieldBinding, +} from './types'; + +/** + * Call a callback for every selector-based (leaf) field in a form schema, with + * the field's path relative to the form data (named expandable containers nest + * their contents). + * @param schema The form schema. + * @param callback The callback to call with the relative path and field. + */ +export const forEachFieldRecursively = ( + schema: readonly HAFormSchema[], + callback: (path: string[], field: HAFormSelectorSchema) => void, +): void => { + const recurse = (items: readonly HAFormSchema[], prefix: string[]): void => { + for (const item of items) { + if ('selector' in item) { + callback([...prefix, item.name], item); + } else { + // A nameless expandable is a visual-only grouping, so it is not added + // to the path. + recurse(item.schema, !item.name ? prefix : [...prefix, item.name]); + } + } + }; + recurse(schema, []); +}; + +const findBinding = (form: EditorForm, formPath: string[]): FieldBinding | undefined => + form.bindings?.find((binding) => isEqual(binding.formPath, formPath)); + +// The value to show for a bound field. A computed binding works its own value +// out; otherwise the configured value is shown, and only a missing value falls +// back to the default. A field set to null is shown as null. +const readBoundFieldValue = ( + binding: FieldBinding, + config: RawAdvancedCameraCardConfig, + defaults: RawAdvancedCameraCardConfig, +): unknown => { + if (isComputedFieldBinding(binding)) { + return binding.read(config, defaults); + } + const configured = getConfigValue(config, binding.configPath); + return configured === undefined + ? getConfigValue(defaults, binding.configPath) + : configured; +}; + +/** + * Compute the data object a form displays: the raw configuration values, with + * each absent field filled in from the (profile-adjusted) configuration + * defaults, so the form shows the effective value of every field. + * @param form The form. + * @param config The whole raw (possibly invalid) configuration. + * @param defaults The whole configuration defaults. + * @returns A new data object for the form to display. + */ +export const computeDisplayedData = ( + form: EditorForm, + config: RawAdvancedCameraCardConfig, + defaults: RawAdvancedCameraCardConfig, +): RawAdvancedCameraCardConfig => { + const raw = form.basePath.length ? getConfigValue(config, form.basePath) : config; + const formDefaults = form.basePath.length + ? getConfigValue(defaults, form.basePath) + : defaults; + + const data: RawAdvancedCameraCardConfig = isRecord(raw) ? copyConfig(raw) : {}; + + forEachFieldRecursively(form.schema, (path) => { + const binding = findBinding(form, path); + if (binding) { + // A bound field is stored elsewhere in the configuration, so anything + // the copy above holds at this path belongs to a different setting and + // is replaced. + const value = readBoundFieldValue(binding, config, defaults); + setConfigValue(data, path, value === undefined ? undefined : copyConfig(value)); + return; + } + + if (getConfigValue(data, path) !== undefined) { + return; + } + // The configuration does not set this field, so show its default (looked + // up by path since the defaults object mirrors the configuration shape). + const defaultValue = isRecord(formDefaults) + ? getConfigValue(formDefaults, path) + : undefined; + if (defaultValue !== undefined) { + // Copied so no mutation of the data can reach into the defaults. + setConfigValue(data, path, copyConfig(defaultValue)); + } + }); + return data; +}; + +/** + * Compute the configuration changes a form edit represents, by comparing the + * data the form emitted against the data it was displaying. Only schema-declared + * leaf fields are compared, so any other content the emitted object carries + * (including display defaults the user did not touch) never produces a change. + * String values are trimmed, and a field the user emptied requests deletion of + * the underlying configuration key. Paths are absolute, so a caller applies + * them without needing to know where the form sits. + * @param form The form. + * @param displayed The data the form was displaying. + * @param emitted The data object the form emitted. + * @param config The whole raw configuration (needed by bound fields). + * @param defaults The whole configuration defaults (likewise). + * @returns The list of configuration changes. + */ +export const computeConfigChanges = ( + form: EditorForm, + displayed: RawAdvancedCameraCardConfig, + emitted: unknown, + config: RawAdvancedCameraCardConfig, + defaults: RawAdvancedCameraCardConfig = {}, +): ConfigChange[] => { + const changes: ConfigChange[] = []; + if (!isRecord(emitted)) { + return changes; + } + forEachFieldRecursively(form.schema, (path) => { + const before = getConfigValue(displayed, path); + let after = getConfigValue(emitted, path); + if (typeof after === 'string') { + after = after.trim(); + } + if (isEqual(before, after)) { + return; + } + + const binding = findBinding(form, path); + if (binding && isComputedFieldBinding(binding)) { + changes.push(...binding.write(after, config, defaults)); + return; + } + + const configPath: ConfigPath = binding + ? binding.configPath + : [...form.basePath, ...path]; + if (after === undefined || after === '') { + changes.push({ path: configPath, type: 'delete' }); + } else { + changes.push({ path: configPath, type: 'set', value: after }); + } + }); + return changes; +}; + +/** + * Apply configuration changes to a raw configuration. Changes are applied onto + * a copy of the existing configuration, so configuration content the schema + * does not know about is left untouched. + * @param config The raw configuration (not modified). + * @param changes The changes to apply, at absolute paths. + * @returns A new configuration with the changes applied, or null if the + * changes leave the configuration unmodified. + */ +export const applyConfigChanges = ( + config: RawAdvancedCameraCardConfig, + changes: readonly ConfigChange[], +): RawAdvancedCameraCardConfig | null => { + if (!changes.length) { + return null; + } + const newConfig = copyConfig(config); + for (const change of changes) { + if (change.type === 'delete') { + deleteConfigValue(newConfig, change.path); + } else { + // Copied so no mutation of the form data can reach into the returned + // configuration. + setConfigValue(newConfig, change.path, copyConfig(change.value)); + } + } + return isEqual(newConfig, config) ? null : newConfig; +}; diff --git a/src/components-lib/editor/form-labels.ts b/src/components-lib/editor/form-labels.ts new file mode 100644 index 00000000..51af7bd6 --- /dev/null +++ b/src/components-lib/editor/form-labels.ts @@ -0,0 +1,42 @@ +import type { HAFormSchema } from '../../ha/types'; +import { localize } from '../../localize/localize'; +import { getFormContainerPath, stripArrayIndices } from './paths'; + +/** + * Get the localization key for a configuration path. + * @param path The configuration path segments. + * @returns The localization key. + */ +export const getLocalizationKeyForPath = (path: (string | number)[]): string => + ['config', ...stripArrayIndices(path)].join('.'); + +/** + * Compute the label for a form field. Intended for use as an `ha-form` + * `computeLabel` callback, bound to the path of the form's data within the + * configuration. Container nodes are labelled by their explicit title, never + * by configuration path (their headers already render the title). + * @param basePath The path of the form's data within the configuration. + * @param schema The field's schema. + * @param options Path context provided by `ha-form` for fields nested inside + * containers. + * @returns A localized label. + */ +export const computeFormLabel = ( + basePath: (string | number)[], + schema: HAFormSchema, + options?: { path?: string[] }, +): string => { + if (schema.label !== undefined) { + return schema.label; + } + if (!('selector' in schema)) { + return schema.title ?? schema.name ?? ''; + } + return localize( + getLocalizationKeyForPath([ + ...basePath, + ...getFormContainerPath(options), + schema.name, + ]), + ); +}; diff --git a/src/components-lib/editor/forms-controller.ts b/src/components-lib/editor/forms-controller.ts new file mode 100644 index 00000000..6aef46b7 --- /dev/null +++ b/src/components-lib/editor/forms-controller.ts @@ -0,0 +1,209 @@ +import type { TemplateResult } from 'lit'; +import { isEqual } from 'lodash-es'; + +import type { RawAdvancedCameraCardConfig } from '../../config/types'; +import type { HAFormSchema } from '../../ha/types'; +import { getDocLinkPath } from './doc-links'; +import { computeConfigChanges, computeDisplayedData } from './form-data'; +import { computeFormLabel } from './form-labels'; +import { getForms, type FormRequest, type FormRequestOptions } from './schema/registry'; +import type { ConfigChange, ConfigPath, EditorForm } from './types'; + +// A form together with the callbacks `ha-form` needs. Held as one object and +// reused across renders: `ha-form` compares each of these by identity, and +// rebuilding them would re-render every field of every open form whenever +// anything else in the editor changes. +export interface FormContext { + // The fields to show, and where in the configuration they live. + form: EditorForm; + + // The values the fields show: the configured ones, with anything unset + // filled in from the defaults. + displayedData: RawAdvancedCameraCardConfig; + + // The name to show for a field. + computeLabel: ( + schema: HAFormSchema, + data?: unknown, + options?: { path?: string[] }, + ) => string; + + // The documentation link to show under a field, or null for a field that + // has no documentation of its own. + computeHelper: ( + schema: HAFormSchema, + options?: { path?: string[] }, + ) => TemplateResult | null; + + // Called by `ha-form` with the whole form's data whenever the user edits a + // field. + valueChanged: (ev: CustomEvent<{ value?: unknown }>) => void; +} + +// Everything a form needs to be built and filled in: the user's configuration, +// the defaults a field shows when the configuration does not set it, and the +// lists of cameras and folders the dropdowns choose from. +export interface FormsInput { + // The user's configuration, which may be partially or completely invalid. + config: RawAdvancedCameraCardConfig; + + // The value each field shows when the configuration does not set it, with + // any adjustments the configured profiles make. + defaults: RawAdvancedCameraCardConfig; + + // The lists a dropdown chooses from: the cameras and the folders. + options: FormRequestOptions; +} + +/** + * Builds the forms for one part of the configuration (e.g. the menu section, or + * a camera) and works out what each one displays, what its fields are called, + * where its documentation is, and what an edit to it means for the + * configuration. + * + * The forms are built again only if a different part is asked for, or if the + * lists the dropdowns choose from change. `ha-form` compares what it is given + * by identity, so handing it a new object every render would redraw every field + * and interrupt whatever the user was typing. + * + * This never changes the configuration. An edit is passed to `onChanges` as a + * list of changes, each naming the full path of the value it applies to. + */ +export class FormsController { + // Reports the changes an edit represents. The changes carry absolute + // configuration paths, so the receiver applies them without knowing which + // form produced them. + private _onChanges: (changes: ConfigChange[]) => void; + + // Renders the documentation link for a configuration path. Injected because + // a template belongs to the rendering layer. + private _renderDocumentation: (path: ConfigPath) => TemplateResult | null; + + private _request: FormRequest | null = null; + private _options: FormRequestOptions | null = null; + + // Empty until the first `setInput`, which is harmless: there are no forms to + // read it until then. + private _input: FormsInput = { + config: {}, + defaults: {}, + options: { cameras: [], folders: [] }, + }; + + private _contexts: FormContext[] = []; + + constructor( + onChanges: (changes: ConfigChange[]) => void, + renderDocumentation: (path: ConfigPath) => TemplateResult | null, + ) { + this._onChanges = onChanges; + this._renderDocumentation = renderDocumentation; + } + + /** + * Set which forms are wanted and the configuration they display. The + * contexts are rebuilt only when something they depend on has actually + * changed, so that a change elsewhere in the editor leaves open forms alone. + * @param request Which part of the configuration the forms are for. + * @param input The configuration, the defaults and the dropdown choices. + */ + public setInput(request: FormRequest, input: FormsInput): void { + // Whether the forms *might* differ. A request builds its forms from only + // some of the options, so this asks the question rather than answering it. + const mightDiffer = + !isEqual(this._request, request) || !isEqual(this._options, input.options); + this._request = request; + this._options = input.options; + + // Whether they actually do. Building forms is cheap; replacing them is not, + // since `ha-form` compares by identity and redraws every field it is handed + // anew. Renaming a camera, for instance, changes the list of cameras every + // *other* camera's dependency dropdown offers, while that camera's own form + // (which never lists itself) comes back exactly as it was. + const rebuilt = mightDiffer ? getForms(request, input.options) : null; + const changedForms = + rebuilt && + !isEqual( + rebuilt, + this._contexts.map((context) => context.form), + ) + ? rebuilt + : null; + + const previous = this._input; + if ( + !changedForms && + previous.config === input.config && + previous.defaults === input.defaults + ) { + return; + } + this._input = input; + + // Only what a form displays depends on the configuration. Its schema and + // its callbacks do not, so when the forms themselves are unchanged only the + // displayed data is replaced: rebuilding the rest would have `ha-form` + // re-render every field of every open form, losing whatever the user was + // typing. + this._contexts = changedForms + ? changedForms.map((form, index) => this._buildContext(form, index, input)) + : this._contexts.map((context) => ({ + ...context, + displayedData: computeDisplayedData( + context.form, + input.config, + input.defaults, + ), + })); + } + + public getContexts(): FormContext[] { + return this._contexts; + } + + private _buildContext( + form: EditorForm, + index: number, + input: FormsInput, + ): FormContext { + return { + form, + displayedData: computeDisplayedData(form, input.config, input.defaults), + computeLabel: ( + schema: HAFormSchema, + _data?: unknown, + options?: { path?: string[] }, + ) => computeFormLabel(form.basePath, schema, options), + + // Caution: `ha-form` types the helper as a string, but interpolates it + // into a Lit template, so a template renders as-is; should a future Home + // Assistant version coerce it to a string, the link would degrade to + // text. + computeHelper: (schema: HAFormSchema, options?: { path?: string[] }) => { + const path = getDocLinkPath(form.basePath, schema, options); + return path ? this._renderDocumentation(path) : null; + }, + valueChanged: (ev: CustomEvent<{ value?: unknown }>) => + this._valueChanged(index, ev), + }; + } + + private _valueChanged(index: number, ev: CustomEvent<{ value?: unknown }>): void { + const context = this._contexts[index]; + if (!context) { + return; + } + + const changes = computeConfigChanges( + context.form, + context.displayedData, + ev.detail?.value, + this._input.config, + this._input.defaults, + ); + + if (changes.length) { + this._onChanges(changes); + } + } +} diff --git a/src/components-lib/editor/intents.ts b/src/components-lib/editor/intents.ts new file mode 100644 index 00000000..9d93471c --- /dev/null +++ b/src/components-lib/editor/intents.ts @@ -0,0 +1,28 @@ +import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event'; +import type { ConfigChange, ConfigPath } from './types'; + +// Something the user has asked to do to the configuration, addressed +// absolutely. A section reports these rather than editing anything itself: the +// editor holds the configuration and is the only thing that writes to it. +export type EditorIntent = + | { type: 'changes'; changes: ConfigChange[] } + | { type: 'list-add'; path: ConfigPath; item: unknown } + | { type: 'list-move'; path: ConfigPath; from: number; to: number } + | { type: 'list-delete'; path: ConfigPath; index: number }; + +const EDITOR_INTENT_EVENT = 'editor:intent'; + +/** + * Report an intent to the editor. The event travels up out of the reporting + * component's shadow root, and only the editor listens for it: nothing in + * between interprets it, and the paths it carries are absolute, so it means + * the same wherever it is seen. + * @param target The element reporting the intent. + * @param intent What the user asked to do. + */ +export const fireEditorIntent = (target: EventTarget, intent: EditorIntent): void => { + fireAdvancedCameraCardEvent(target, EDITOR_INTENT_EVENT, intent, { + bubbles: true, + composed: true, + }); +}; diff --git a/src/components-lib/editor/keyboard-shortcuts-controller.ts b/src/components-lib/editor/keyboard-shortcuts-controller.ts new file mode 100644 index 00000000..eadf85b4 --- /dev/null +++ b/src/components-lib/editor/keyboard-shortcuts-controller.ts @@ -0,0 +1,86 @@ +import type { ReactiveController, ReactiveControllerHost, TemplateResult } from 'lit'; + +import { CONF_VIEW_KEYBOARD_SHORTCUTS } from '../../config/const'; +import { getConfigValue } from '../../config/management'; +import { PTZ_KEYBOARD_SHORTCUTS, type KeyboardShortcut } from '../../config/schema/view'; +import { FormsController, type FormContext, type FormsInput } from './forms-controller'; +import { fireEditorIntent } from './intents'; +import type { FormRequest } from './schema/registry'; +import type { ConfigChange, ConfigPath } from './types'; + +type KeyboardShortcutsControllerHost = ReactiveControllerHost & EventTarget; + +const FORM_REQUEST: FormRequest = { kind: 'section', name: 'view.keyboard_shortcuts' }; + +/** + * The keyboard shortcuts panel: the settings that apply to all the shortcuts, + * and the key assigned to each one. A key is assigned by pressing it rather + * than by filling in a field, so the individual shortcuts cannot be part of + * the panel's own form. + */ +export class KeyboardShortcutsController implements ReactiveController { + private _host: KeyboardShortcutsControllerHost; + private _forms: FormsController; + private _input: FormsInput | null = null; + + constructor( + host: KeyboardShortcutsControllerHost, + renderDocumentation: (path: ConfigPath) => TemplateResult | null, + ) { + this._host = host; + this._forms = new FormsController( + (changes: ConfigChange[]) => + fireEditorIntent(this._host, { type: 'changes', changes }), + renderDocumentation, + ); + host.addController(this); + } + + public hostConnected(): void { + // No connection-time work. + } + + public setInput(input: FormsInput): void { + this._input = input; + this._forms.setInput(FORM_REQUEST, input); + } + + public getContexts(): FormContext[] { + return this._forms.getContexts(); + } + + /** + * Get the key assigned to each shortcut: the configured one, or the default + * where the configuration says nothing. + * @returns The shortcuts, keyed by name and in the order they are shown. + */ + public getShortcuts(): Record { + const shortcuts: Record = {}; + for (const name of PTZ_KEYBOARD_SHORTCUTS) { + shortcuts[name] = getConfigValue( + this._input?.config ?? {}, + `${CONF_VIEW_KEYBOARD_SHORTCUTS}.${name}`, + getConfigValue(this._input?.defaults ?? {}, [ + ...CONF_VIEW_KEYBOARD_SHORTCUTS.split('.'), + name, + ]), + ); + } + return shortcuts; + } + + public setShortcut(name: string, value: KeyboardShortcut | null): void { + fireEditorIntent(this._host, { + type: 'changes', + changes: [ + { + path: [...CONF_VIEW_KEYBOARD_SHORTCUTS.split('.'), name], + // A null shortcut is set rather than deleted: it means the user + // deliberately unassigned a key that has a default. + type: 'set', + value, + }, + ], + }); + } +} diff --git a/src/components-lib/editor/list-forms-controller.ts b/src/components-lib/editor/list-forms-controller.ts new file mode 100644 index 00000000..14304217 --- /dev/null +++ b/src/components-lib/editor/list-forms-controller.ts @@ -0,0 +1,97 @@ +import type { ReactiveController, ReactiveControllerHost, TemplateResult } from 'lit'; + +import { EqualityMap } from '../../cache/equality-map'; +import { getConfigValue } from '../../config/management'; +import type { RawAdvancedCameraCardConfigArray } from '../../config/types'; +import { isRecord } from '../../utils/basic'; +import { FormsController, type FormContext, type FormsInput } from './forms-controller'; +import { fireEditorIntent } from './intents'; +import type { FormRequest } from './schema/registry'; +import type { ConfigChange, ConfigPath } from './types'; + +type ListFormsControllerHost = ReactiveControllerHost & EventTarget; + +/** + * The forms for a part of the editor that shows a list the user can add to, + * reorder and delete from (e.g. cameras, folders, a camera's event triggers). + * Every item in such a list has its own forms, so the forms are kept one set + * per item rather than under fixed names, and the list itself is read straight + * from the configuration, where it is an array. + * + * This never changes the configuration. An edit, or a change to the list, is + * reported to the editor as an intent naming the full path it applies to. + */ +export class ListFormsController implements ReactiveController { + private _host: ListFormsControllerHost; + private _renderDocumentation: (path: ConfigPath) => TemplateResult | null; + + private _input: FormsInput | null = null; + + // One set of forms per item asked for, kept between renders so that editing + // one item does not redraw the others. Keyed by the request itself, matched + // by value. + private _formsControllers = new EqualityMap(); + + constructor( + host: ListFormsControllerHost, + renderDocumentation: (path: ConfigPath) => TemplateResult | null, + ) { + this._host = host; + this._renderDocumentation = renderDocumentation; + host.addController(this); + } + + public hostConnected(): void { + // No connection-time work. + } + + public setInput(input: FormsInput): void { + this._input = input; + } + + /** + * Read a list of items from the configuration. Non-object entries become + * empty objects rather than being dropped, so that positions in the list + * always match the configuration's own indices (edits address items by + * index). + * @param path The path of the list. + * @returns The items. + */ + public getList(path: ConfigPath): RawAdvancedCameraCardConfigArray { + const value = this._input ? getConfigValue(this._input.config, path) : null; + return Array.isArray(value) + ? value.map((entry) => (isRecord(entry) ? entry : {})) + : []; + } + + public getFormContexts(request: FormRequest): FormContext[] { + const input = this._input; + if (!input) { + return []; + } + let formsController = this._formsControllers.get(request); + if (!formsController) { + formsController = new FormsController( + (changes: ConfigChange[]) => + fireEditorIntent(this._host, { type: 'changes', changes }), + this._renderDocumentation, + ); + this._formsControllers.set(request, formsController); + } + + formsController.setInput(request, input); + return formsController.getContexts(); + } + + public addItem(path: ConfigPath, item: unknown): void { + fireEditorIntent(this._host, { type: 'list-add', path, item }); + } + + public moveItem(path: ConfigPath, from: number, to: number): void { + fireEditorIntent(this._host, { type: 'list-move', path, from, to }); + } + + public deleteItem(path: ConfigPath, index: number): void { + fireEditorIntent(this._host, { type: 'list-delete', path, index }); + } +} diff --git a/src/components-lib/editor/list-pages-controller.ts b/src/components-lib/editor/list-pages-controller.ts new file mode 100644 index 00000000..f7940b11 --- /dev/null +++ b/src/components-lib/editor/list-pages-controller.ts @@ -0,0 +1,48 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +// One step of the path into the editor: which list was drilled into, and the +// position within it of the item opened. +interface ListPage { + list: string; + index: number; +} + +/** + * The path of list items the user has drilled into. An empty path is the top + * list itself; each further step names the list entered and the item's position + * in it, so `[{list: 'cameras', index: 2}, {list: 'events', index: 0}]` is the + * first event of the third camera. + * + * Only one step is shown at a time, so opening an item replaces the list rather + * than expanding within it. + */ +export class ListPagesController implements ReactiveController { + private _host: ReactiveControllerHost; + private _path: ListPage[] = []; + + constructor(host: ReactiveControllerHost) { + this._host = host; + host.addController(this); + } + + public hostConnected(): void { + // No connection-time work. + } + + public getPath(): readonly ListPage[] { + return this._path; + } + + public open(list: string, index: number): void { + this._path = [...this._path, { list, index }]; + this._host.requestUpdate(); + } + + public back(): void { + if (!this._path.length) { + return; + } + this._path = this._path.slice(0, -1); + this._host.requestUpdate(); + } +} diff --git a/src/components-lib/editor/paths.ts b/src/components-lib/editor/paths.ts new file mode 100644 index 00000000..76d90c3d --- /dev/null +++ b/src/components-lib/editor/paths.ts @@ -0,0 +1,23 @@ +/** + * Convert the container path `ha-form` passes to `computeLabel`/`computeHelper` + * into configuration order. `ha-form` builds the path by appending each + * enclosing group's name as the call climbs outward, so the segments arrive + * innermost-first (e.g. `['dashboard', 'cast']` for `cast.dashboard`). + * @param options The options object passed by `ha-form`. + * @returns The container path segments in configuration order. + */ +export const getFormContainerPath = (options?: { path?: string[] }): string[] => + [...(options?.path ?? [])].reverse(); + +/** + * Strip array indices from a configuration path. Localization keys and + * documentation links mirror configuration paths without them (e.g. + * `config.cameras.title` covers every camera). + * @param path The configuration path segments. + * @returns The path segments without array indices. + */ +export const stripArrayIndices = (path: (string | number)[]): string[] => + path.filter( + (segment): segment is string => + typeof segment === 'string' && !/^\d+$/.test(segment), + ); diff --git a/src/components-lib/editor/schema/cameras.ts b/src/components-lib/editor/schema/cameras.ts new file mode 100644 index 00000000..0aa4ba7b --- /dev/null +++ b/src/components-lib/editor/schema/cameras.ts @@ -0,0 +1,472 @@ +import type { + HAFormExpandableSchema, + HAFormSchema, + HASelectSelectorOption, +} from '../../../ha/types'; +import { localize } from '../../../localize/localize'; +import { capabilityKeys } from '../../../types'; +import { getImageFieldsSchema } from './common/image'; +import { getProxySchema } from './common/proxy'; +import { createNumberSelector, createSelectSelector } from './common/selectors'; + +const percentageSelector = createNumberSelector({ min: 0, max: 100 }); + +const getMediaLayoutSchema = (): HAFormExpandableSchema => ({ + name: 'layout', + type: 'expandable', + title: localize('config.cameras.dimensions.layout.editor_label'), + icon: 'mdi:page-layout-body', + schema: [ + { + name: 'fit', + selector: createSelectSelector([ + { + value: 'contain', + label: localize('config.cameras.dimensions.layout.fits.contain'), + }, + { + value: 'cover', + label: localize('config.cameras.dimensions.layout.fits.cover'), + }, + { + value: 'fill', + label: localize('config.cameras.dimensions.layout.fits.fill'), + }, + ]), + }, + { + name: 'position', + type: 'expandable', + title: localize('config.cameras.dimensions.layout.position.editor_label'), + schema: [ + { name: 'x', selector: percentageSelector }, + { name: 'y', selector: percentageSelector }, + ], + }, + { + name: 'view_box', + type: 'expandable', + title: localize('config.cameras.dimensions.layout.view_box.editor_label'), + schema: [ + { name: 'top', selector: percentageSelector }, + { name: 'bottom', selector: percentageSelector }, + { name: 'left', selector: percentageSelector }, + { name: 'right', selector: percentageSelector }, + ], + }, + { + name: 'pan', + type: 'expandable', + title: localize('config.cameras.dimensions.layout.pan.editor_label'), + schema: [ + { name: 'x', selector: percentageSelector }, + { name: 'y', selector: percentageSelector }, + ], + }, + { + name: 'zoom', + selector: createNumberSelector({ min: 1, max: 10 }), + }, + ], +}); + +interface CameraSchemaOptions { + otherCameras: HASelectSelectorOption[]; + folders: HASelectSelectorOption[]; +} + +/** + * Get the schema for a single camera (everything except the triggers group, + * which is rendered by a hand-built panel so it can host the events array + * list). + * @param options The camera and folder lists the dropdowns choose from. + * @returns The camera form schema. + */ +export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] => { + const capabilityOptions: HASelectSelectorOption[] = capabilityKeys.map((key) => ({ + value: key, + label: localize(`config.cameras.capabilities.capabilities.${key}`), + })); + + return [ + { name: 'camera_entity', selector: { entity: { domain: 'camera' } } }, + { + name: 'live_provider', + selector: createSelectSelector([ + { value: 'auto', label: localize('config.cameras.live_providers.auto') }, + { value: 'ha', label: localize('config.cameras.live_providers.ha') }, + { value: 'image', label: localize('config.cameras.live_providers.image') }, + { value: 'jsmpeg', label: localize('config.cameras.live_providers.jsmpeg') }, + { value: 'go2rtc', label: localize('config.cameras.live_providers.go2rtc') }, + { + value: 'go2rtc-experimental', + label: localize('config.cameras.live_providers.go2rtc-experimental'), + }, + { + value: 'webrtc-card', + label: localize('config.cameras.live_providers.webrtc-card'), + }, + ]), + }, + { name: 'title', selector: { text: {} } }, + { name: 'icon', selector: { icon: {} } }, + { name: 'id', selector: { text: {} } }, + { name: 'always_error_if_entity_unavailable', selector: { boolean: {} } }, + + { + name: 'capabilities', + type: 'expandable', + title: localize('config.cameras.capabilities.editor_label'), + icon: 'mdi:cog-stop', + schema: [ + { + name: 'disable', + selector: createSelectSelector(capabilityOptions, { multiple: true }), + }, + { + name: 'disable_except', + selector: createSelectSelector(capabilityOptions, { multiple: true }), + }, + { + name: 'force', + selector: createSelectSelector( + [ + { + value: '2-way-audio', + label: localize('config.cameras.capabilities.capabilities.2-way-audio'), + }, + ], + { multiple: true }, + ), + }, + ], + }, + { + name: 'cast', + type: 'expandable', + title: localize('config.cameras.cast.editor_label'), + icon: 'mdi:cast', + schema: [ + { + name: 'method', + selector: createSelectSelector([ + { + value: 'standard', + label: localize('config.cameras.cast.methods.standard'), + }, + { + value: 'dashboard', + label: localize('config.cameras.cast.methods.dashboard'), + }, + ]), + }, + { + name: 'dashboard', + type: 'expandable', + title: localize('config.cameras.cast.dashboard.editor_label'), + schema: [ + { name: 'dashboard_path', selector: { text: {} } }, + { name: 'view_path', selector: { text: {} } }, + ], + }, + ], + }, + { + name: 'dependencies', + type: 'expandable', + title: localize('config.cameras.dependencies.editor_label'), + icon: 'mdi:graph', + schema: [ + { name: 'all_cameras', selector: { boolean: {} } }, + { + name: 'cameras', + selector: createSelectSelector(options.otherCameras, { multiple: true }), + }, + ], + }, + { + name: 'dimensions', + type: 'expandable', + title: localize('config.cameras.dimensions.editor_label'), + icon: 'mdi:aspect-ratio', + schema: [ + { name: 'aspect_ratio', selector: { text: {} } }, + { + name: 'rotation', + selector: createSelectSelector([ + { value: 0, label: localize('config.cameras.dimensions.rotations.0') }, + { value: 90, label: localize('config.cameras.dimensions.rotations.90') }, + { value: 180, label: localize('config.cameras.dimensions.rotations.180') }, + { value: 270, label: localize('config.cameras.dimensions.rotations.270') }, + ]), + }, + getMediaLayoutSchema(), + ], + }, + { + // Nameless: a visual grouping only: + // `engine`/`frigate`/`motioneye`/`reolink` are direct camera keys, not + // nested under an `engine` object. + type: 'expandable', + title: localize('config.cameras.engines.editor_label'), + icon: 'mdi:engine', + docPath: 'cameras.engine', + schema: [ + { + name: 'engine', + label: localize('config.cameras.engine'), + selector: createSelectSelector([ + { value: 'auto', label: localize('config.cameras.engines.auto') }, + { value: 'frigate', label: localize('config.cameras.engines.frigate') }, + { value: 'generic', label: localize('config.cameras.engines.generic') }, + { value: 'motioneye', label: localize('config.cameras.engines.motioneye') }, + { value: 'reolink', label: localize('config.cameras.engines.reolink') }, + { value: 'tplink', label: localize('config.cameras.engines.tplink') }, + ]), + }, + { + name: 'frigate', + type: 'expandable', + title: localize('config.cameras.frigate.editor_label'), + icon: 'advanced-camera-card:frigate', + schema: [ + { name: 'camera_name', selector: { text: {} } }, + { name: 'url', selector: { text: {} } }, + { name: 'labels', selector: createSelectSelector([], { multiple: true }) }, + { name: 'zones', selector: createSelectSelector([], { multiple: true }) }, + { name: 'client_id', selector: { text: {} } }, + ], + }, + { + name: 'motioneye', + type: 'expandable', + title: localize('config.cameras.motioneye.editor_label'), + icon: 'advanced-camera-card:motioneye', + schema: [ + { name: 'url', selector: { text: {} } }, + { + name: 'images', + type: 'expandable', + title: localize('config.cameras.motioneye.images.editor_label'), + schema: [ + { name: 'directory_pattern', selector: { text: {} } }, + { name: 'file_pattern', selector: { text: {} } }, + ], + }, + { + name: 'movies', + type: 'expandable', + title: localize('config.cameras.motioneye.movies.editor_label'), + schema: [ + { name: 'directory_pattern', selector: { text: {} } }, + { name: 'file_pattern', selector: { text: {} } }, + ], + }, + ], + }, + { + name: 'reolink', + type: 'expandable', + title: localize('config.cameras.reolink.editor_label'), + icon: 'advanced-camera-card:reolink', + schema: [ + { name: 'url', selector: { text: {} } }, + { + name: 'media_resolution', + label: localize('config.cameras.reolink.media_resolution.editor_label'), + selector: createSelectSelector([ + { + value: 'high', + label: localize('config.cameras.reolink.media_resolution.high'), + }, + { + value: 'low', + label: localize('config.cameras.reolink.media_resolution.low'), + }, + ]), + }, + ], + }, + ], + }, + { + // Nameless: a visual grouping only: `go2rtc`/`image`/`webrtc_card` are + // direct camera keys. + type: 'expandable', + title: localize('config.cameras.live_provider_options.editor_label'), + icon: 'mdi:cctv', + docPath: 'cameras.live_provider', + schema: [ + { + name: 'go2rtc', + type: 'expandable', + title: localize('config.cameras.go2rtc.editor_label'), + icon: 'mdi:eye', + schema: [ + { + name: 'modes', + label: localize('config.cameras.go2rtc.modes.editor_label'), + selector: createSelectSelector( + [ + { + value: 'webrtc', + label: localize('config.cameras.go2rtc.modes.webrtc'), + }, + { value: 'mse', label: localize('config.cameras.go2rtc.modes.mse') }, + { value: 'mp4', label: localize('config.cameras.go2rtc.modes.mp4') }, + { + value: 'mjpeg', + label: localize('config.cameras.go2rtc.modes.mjpeg'), + }, + ], + { multiple: true }, + ), + }, + { name: 'stream', selector: { text: {} } }, + { name: 'url', selector: { text: {} } }, + { name: 'metadata_fetch_timeout_seconds', selector: createNumberSelector() }, + ], + }, + { + name: 'image', + type: 'expandable', + title: localize('config.cameras.image.editor_label'), + icon: 'mdi:image', + schema: getImageFieldsSchema(), + }, + { + name: 'webrtc_card', + type: 'expandable', + title: localize('config.cameras.webrtc_card.editor_label'), + icon: 'mdi:webrtc', + schema: [ + { name: 'entity', selector: { entity: { domain: 'camera' } } }, + { name: 'url', selector: { text: {} } }, + ], + }, + ], + }, + { + name: 'media', + type: 'expandable', + title: localize('config.cameras.media.editor_label'), + icon: 'mdi:play-box-outline', + schema: [ + { + name: 'type', + selector: createSelectSelector([ + { value: 'auto', label: localize('config.common.media_types.auto') }, + { value: 'reviews', label: localize('config.common.media_types.reviews') }, + { value: 'events', label: localize('config.common.media_types.events') }, + { + value: 'recordings', + label: localize('config.common.media_types.recordings'), + }, + { value: 'folder', label: localize('config.common.media_types.folder') }, + ]), + }, + { + name: 'events_type', + selector: createSelectSelector([ + { value: 'all', label: localize('config.common.events_media_types.all') }, + { + value: 'clips', + label: localize('config.common.events_media_types.clips'), + }, + { + value: 'snapshots', + label: localize('config.common.events_media_types.snapshots'), + }, + ]), + }, + { + name: 'reviewed', + selector: createSelectSelector([ + { + value: 'unreviewed', + label: localize('config.cameras.media.revieweds.unreviewed'), + }, + { + value: 'reviewed', + label: localize('config.cameras.media.revieweds.reviewed'), + }, + { value: 'all', label: localize('config.cameras.media.revieweds.all') }, + ]), + }, + { + name: 'folders', + selector: createSelectSelector(options.folders, { multiple: true }), + }, + ], + }, + getProxySchema({ + title: localize('config.cameras.proxy.editor_label'), + includeLive: true, + includeMedia: true, + }), + ]; +}; + +/** + * Get the schema for a camera's trigger scalars (the events list is rendered + * separately by the array editor). + * @param entities The entity IDs offered for the trigger-entities field. + * @returns The triggers form schema. + */ +export const getCameraTriggersSchema = (): HAFormSchema[] => [ + { name: 'occupancy', selector: { boolean: {} } }, + { name: 'motion', selector: { boolean: {} } }, + { name: 'doorbell', selector: { boolean: {} } }, + { name: 'entities', selector: { entity: { multiple: true } } }, + { + name: 'media_events', + label: localize('config.cameras.triggers.media_events.editor_label'), + selector: createSelectSelector( + [ + { + value: 'events', + label: localize('config.cameras.triggers.media_events.events'), + }, + { + value: 'clips', + label: localize('config.cameras.triggers.media_events.clips'), + }, + { + value: 'snapshots', + label: localize('config.cameras.triggers.media_events.snapshots'), + }, + ], + { multiple: true }, + ), + }, + { + name: 'reviews', + type: 'expandable', + title: localize('config.cameras.triggers.reviews.editor_label'), + icon: 'mdi:check-circle', + schema: [ + { + name: 'severities', + label: localize('common.severity'), + selector: createSelectSelector( + [ + { value: 'high', label: localize('common.severities.high') }, + { value: 'medium', label: localize('common.severities.medium') }, + { value: 'low', label: localize('common.severities.low') }, + ], + { multiple: true }, + ), + }, + { name: 'description', selector: { boolean: {} } }, + ], + }, +]; + +/** + * Get the schema for a single Home Assistant event trigger. + * @returns The event form schema. + */ +export const getTriggerEventSchema = (): HAFormSchema[] => [ + { name: 'event_type', selector: { text: {} } }, + { name: 'event_data', selector: { object: {} } }, +]; diff --git a/src/components-lib/editor/schema/common/controls/next-previous.ts b/src/components-lib/editor/schema/common/controls/next-previous.ts new file mode 100644 index 00000000..974444d8 --- /dev/null +++ b/src/components-lib/editor/schema/common/controls/next-previous.ts @@ -0,0 +1,79 @@ +import { BUTTON_SIZE_MIN } from '../../../../../config/schema/common/const'; +import type { + HAFormExpandableSchema, + HASelectSelectorOption, +} from '../../../../../ha/types'; +import { localize } from '../../../../../localize/localize'; +import { createNumberSelector, createSelectSelector } from '../selectors'; + +/** + * Get the schema for the next/previous controls. + * @param options Which style and auto-hide values to offer (they vary by + * section: `thumbnails` and `icons` styles and the `call` auto-hide only apply + * where the section supports them). + * @returns The form schema. + */ +export const getNextPreviousSchema = (options?: { + allowIcons?: boolean; + allowThumbnails?: boolean; + allowCall?: boolean; +}): HAFormExpandableSchema => { + const styleOptions: HASelectSelectorOption[] = [ + { + value: 'chevrons', + label: localize('config.common.controls.next_previous.styles.chevrons'), + }, + ]; + if (options?.allowIcons) { + styleOptions.push({ + value: 'icons', + label: localize('config.common.controls.next_previous.styles.icons'), + }); + } + styleOptions.push({ + value: 'none', + label: localize('config.common.controls.next_previous.styles.none'), + }); + if (options?.allowThumbnails) { + styleOptions.push({ + value: 'thumbnails', + label: localize('config.common.controls.next_previous.styles.thumbnails'), + }); + } + + const autoHideOptions: HASelectSelectorOption[] = []; + if (options?.allowCall) { + autoHideOptions.push({ + value: 'call', + label: localize('config.common.auto_hide_conditions.call'), + }); + } + autoHideOptions.push({ + value: 'casting', + label: localize('config.common.auto_hide_conditions.casting'), + }); + + return { + name: 'next_previous', + type: 'expandable', + title: localize('config.common.controls.next_previous.editor_label'), + icon: 'mdi:arrow-right-bold-circle', + schema: [ + { + name: 'style', + label: localize('config.common.controls.next_previous.style'), + selector: createSelectSelector(styleOptions), + }, + { + name: 'size', + label: localize('config.common.controls.next_previous.size'), + selector: createNumberSelector({ min: BUTTON_SIZE_MIN }), + }, + { + name: 'auto_hide', + label: localize('config.common.controls.next_previous.auto_hide'), + selector: createSelectSelector(autoHideOptions, { multiple: true }), + }, + ], + }; +}; diff --git a/src/components-lib/editor/schema/common/controls/ptz.ts b/src/components-lib/editor/schema/common/controls/ptz.ts new file mode 100644 index 00000000..b9ba38a0 --- /dev/null +++ b/src/components-lib/editor/schema/common/controls/ptz.ts @@ -0,0 +1,112 @@ +import type { + HAFormExpandableSchema, + HASelectSelectorOption, +} from '../../../../../ha/types'; +import { localize } from '../../../../../localize/localize'; +import { createSelectSelector } from '../selectors'; + +// The PTZ controls are shared between live (physical or digital pan/tilt/zoom) +// and the media viewer (digital pan/zoom of the media). The label keys live +// under `config.common.controls.ptz` for both. Live offers an `auto` mode +// (physical PTZ when the camera supports it); the viewer is digital-only. +export const getPTZSchema = (options?: { + includeAutoMode?: boolean; +}): HAFormExpandableSchema => { + const modeOptions: HASelectSelectorOption[] = [ + { value: 'off', label: localize('config.common.controls.ptz.modes.off') }, + ]; + if (options?.includeAutoMode) { + modeOptions.push({ + value: 'auto', + label: localize('config.common.controls.ptz.modes.auto'), + }); + } + modeOptions.push({ + value: 'on', + label: localize('config.common.controls.ptz.modes.on'), + }); + + return { + name: 'ptz', + type: 'expandable', + title: localize('config.common.controls.ptz.editor_label'), + icon: 'mdi:pan', + schema: [ + { + name: 'mode', + label: localize('config.common.controls.ptz.mode'), + selector: createSelectSelector(modeOptions), + }, + { + name: 'type', + label: localize('config.common.controls.ptz.type'), + selector: createSelectSelector([ + { + value: 'buttons', + label: localize('config.common.controls.ptz.types.buttons'), + }, + { + value: 'gestures', + label: localize('config.common.controls.ptz.types.gestures'), + }, + ]), + }, + { + name: 'position', + label: localize('config.common.controls.ptz.position'), + selector: createSelectSelector([ + { + value: 'top-left', + label: localize('config.common.controls.ptz.positions.top-left'), + }, + { + value: 'top-right', + label: localize('config.common.controls.ptz.positions.top-right'), + }, + { + value: 'bottom-left', + label: localize('config.common.controls.ptz.positions.bottom-left'), + }, + { + value: 'bottom-right', + label: localize('config.common.controls.ptz.positions.bottom-right'), + }, + ]), + }, + { + name: 'orientation', + label: localize('config.common.controls.ptz.orientation'), + selector: createSelectSelector([ + { + value: 'vertical', + label: localize('config.common.controls.ptz.orientations.vertical'), + }, + { + value: 'horizontal', + label: localize('config.common.controls.ptz.orientations.horizontal'), + }, + ]), + }, + { + name: 'hide_pan_tilt', + label: localize('config.common.controls.ptz.hide_pan_tilt'), + selector: { boolean: {} }, + }, + { + name: 'hide_zoom', + label: localize('config.common.controls.ptz.hide_zoom'), + selector: { boolean: {} }, + }, + { + name: 'hide_home', + label: localize('config.common.controls.ptz.hide_home'), + selector: { boolean: {} }, + }, + { + name: 'hide_type', + label: localize('config.common.controls.ptz.hide_type'), + selector: { boolean: {} }, + }, + ], + }; +}; diff --git a/src/components-lib/editor/schema/common/controls/thumbnails.ts b/src/components-lib/editor/schema/common/controls/thumbnails.ts new file mode 100644 index 00000000..3a7aa10f --- /dev/null +++ b/src/components-lib/editor/schema/common/controls/thumbnails.ts @@ -0,0 +1,93 @@ +import { + THUMBNAIL_WIDTH_MAX, + THUMBNAIL_WIDTH_MIN, +} from '../../../../../config/schema/common/controls/thumbnails'; +import type { HAFormExpandableSchema, HAFormSchema } from '../../../../../ha/types'; +import { localize } from '../../../../../localize/localize'; +import { createNumberSelector, createSelectSelector } from '../selectors'; + +/** + * Get the schema for a thumbnails control group. + * @param options Whether to include the mode field. + * @returns The form schema for the group. + */ +export const getThumbnailsSchema = (options?: { + includeMode?: boolean; +}): HAFormExpandableSchema => { + const schema: HAFormSchema[] = []; + if (options?.includeMode) { + schema.push({ + name: 'mode', + label: localize('config.common.controls.thumbnails.mode'), + selector: createSelectSelector([ + { + value: 'none', + label: localize('config.common.controls.thumbnails.modes.none'), + }, + { + value: 'above', + label: localize('config.common.controls.thumbnails.modes.above'), + }, + { + value: 'below', + label: localize('config.common.controls.thumbnails.modes.below'), + }, + { + value: 'left', + label: localize('config.common.controls.thumbnails.modes.left'), + }, + { + value: 'right', + label: localize('config.common.controls.thumbnails.modes.right'), + }, + ]), + }); + } + schema.push( + { + name: 'size', + label: localize('config.common.controls.thumbnails.size'), + selector: createNumberSelector({ + min: THUMBNAIL_WIDTH_MIN, + max: THUMBNAIL_WIDTH_MAX, + }), + }, + { + name: 'show_details', + label: localize('config.common.controls.thumbnails.show_details'), + selector: { boolean: {} }, + }, + { + name: 'show_favorite_control', + label: localize('config.common.controls.thumbnails.show_favorite_control'), + selector: { boolean: {} }, + }, + { + name: 'show_timeline_control', + label: localize('config.common.controls.thumbnails.show_timeline_control'), + selector: { boolean: {} }, + }, + { + name: 'show_download_control', + label: localize('config.common.controls.thumbnails.show_download_control'), + selector: { boolean: {} }, + }, + { + name: 'show_review_control', + label: localize('config.common.controls.thumbnails.show_review_control'), + selector: { boolean: {} }, + }, + { + name: 'show_info_control', + label: localize('config.common.controls.thumbnails.show_info_control'), + selector: { boolean: {} }, + }, + ); + return { + name: 'thumbnails', + type: 'expandable', + title: localize('config.common.controls.thumbnails.editor_label'), + icon: 'mdi:image-text', + schema, + }; +}; diff --git a/src/components-lib/editor/schema/common/controls/timeline.ts b/src/components-lib/editor/schema/common/controls/timeline.ts new file mode 100644 index 00000000..061d5599 --- /dev/null +++ b/src/components-lib/editor/schema/common/controls/timeline.ts @@ -0,0 +1,111 @@ +import type { HAFormExpandableSchema, HAFormSchema } from '../../../../../ha/types'; +import { localize } from '../../../../../localize/localize'; +import { createNumberSelector, createSelectSelector } from '../selectors'; + +/** + * Get the schema for the core timeline fields (shared between the timeline + * section and the mini timelines). + * @param options Whether to include `pan_mode`. Its non-`pan` modes seek + * within playing media, so only a mini timeline shown alongside media has it. + * @returns The form schema. + */ +export const getTimelineCoreSchema = (options?: { + includePanMode?: boolean; +}): HAFormSchema[] => { + const schema: HAFormSchema[] = [ + { + name: 'style', + label: localize('config.common.timeline.style'), + selector: createSelectSelector([ + { value: 'ribbon', label: localize('config.common.timeline.styles.ribbon') }, + { value: 'stack', label: localize('config.common.timeline.styles.stack') }, + ]), + }, + ]; + if (options?.includePanMode) { + schema.push({ + name: 'pan_mode', + label: localize('config.common.controls.timeline.pan_mode'), + selector: createSelectSelector([ + { + value: 'pan', + label: localize('config.common.controls.timeline.pan_modes.pan'), + }, + { + value: 'seek', + label: localize('config.common.controls.timeline.pan_modes.seek'), + }, + { + value: 'seek-in-media', + label: localize('config.common.controls.timeline.pan_modes.seek-in-media'), + }, + { + value: 'seek-in-camera', + label: localize('config.common.controls.timeline.pan_modes.seek-in-camera'), + }, + ]), + }); + } + schema.push( + { + name: 'window_seconds', + label: localize('config.common.timeline.window_seconds'), + selector: createNumberSelector(), + }, + { + name: 'clustering_threshold', + label: localize('config.common.timeline.clustering_threshold'), + selector: createNumberSelector(), + }, + { + name: 'show_recordings', + label: localize('config.common.timeline.show_recordings'), + selector: { boolean: {} }, + }, + { + name: 'format', + type: 'expandable', + title: localize('config.common.controls.timeline.format.editor_label'), + icon: 'mdi:clock-edit', + schema: [ + { + name: '24h', + label: localize('config.common.controls.timeline.format.24h'), + selector: { boolean: {} }, + }, + ], + }, + ); + return schema; +}; + +/** + * Get the schema for a mini timeline (the timeline shown for + * live/media_viewer): the placement `mode` plus the shared core fields + * (including `pan_mode`, which only mini timelines honor). + * @returns The expandable form schema. + */ +export const getMiniTimelineSchema = (): HAFormExpandableSchema => ({ + name: 'timeline', + type: 'expandable', + title: localize('config.common.controls.timeline.editor_label'), + icon: 'mdi:chart-gantt', + schema: [ + { + name: 'mode', + label: localize('config.common.controls.timeline.mode'), + selector: createSelectSelector([ + { value: 'none', label: localize('config.common.controls.timeline.modes.none') }, + { + value: 'above', + label: localize('config.common.controls.timeline.modes.above'), + }, + { + value: 'below', + label: localize('config.common.controls.timeline.modes.below'), + }, + ]), + }, + ...getTimelineCoreSchema({ includePanMode: true }), + ], +}); diff --git a/src/components-lib/editor/schema/common/display.ts b/src/components-lib/editor/schema/common/display.ts new file mode 100644 index 00000000..4cb80402 --- /dev/null +++ b/src/components-lib/editor/schema/common/display.ts @@ -0,0 +1,57 @@ +import type { HAFormExpandableSchema } from '../../../../ha/types'; +import { localize } from '../../../../localize/localize'; +import { createNumberSelector, createSelectSelector } from './selectors'; + +/** + * Get the schema for the view display controls (single vs grid layout). + * @returns The form schema. + */ +export const getDisplaySchema = (): HAFormExpandableSchema => ({ + name: 'display', + type: 'expandable', + title: localize('config.common.display.editor_label'), + icon: 'mdi:palette-swatch', + schema: [ + { + name: 'mode', + label: localize('config.common.display.mode'), + selector: createSelectSelector([ + { value: 'single', label: localize('display_modes.single') }, + { value: 'grid', label: localize('display_modes.grid') }, + ]), + }, + { + name: 'grid_selected_position', + label: localize('config.common.display.grid_selected_position'), + selector: createSelectSelector([ + { + value: 'default', + label: localize('config.common.display.grid_selected_positions.default'), + }, + { + value: 'first', + label: localize('config.common.display.grid_selected_positions.first'), + }, + { + value: 'last', + label: localize('config.common.display.grid_selected_positions.last'), + }, + ]), + }, + { + name: 'grid_selected_width_factor', + label: localize('config.common.display.grid_selected_width_factor'), + selector: createNumberSelector({ min: 0 }), + }, + { + name: 'grid_columns', + label: localize('config.common.display.grid_columns'), + selector: createNumberSelector({ min: 0 }), + }, + { + name: 'grid_max_columns', + label: localize('config.common.display.grid_max_columns'), + selector: createNumberSelector({ min: 0 }), + }, + ], +}); diff --git a/src/components-lib/editor/schema/common/image.ts b/src/components-lib/editor/schema/common/image.ts new file mode 100644 index 00000000..b6dc5556 --- /dev/null +++ b/src/components-lib/editor/schema/common/image.ts @@ -0,0 +1,45 @@ +import type { HAFormSchema } from '../../../../ha/types'; +import { localize } from '../../../../localize/localize'; +import { createNumberSelector, createSelectSelector } from './selectors'; + +/** + * Get the schema for a set of image source fields. + * @returns The form schema. + */ +export const getImageFieldsSchema = (): HAFormSchema[] => [ + { + name: 'mode', + label: localize('config.common.image.mode'), + selector: createSelectSelector([ + { value: 'auto', label: localize('config.common.image.modes.auto') }, + { value: 'camera', label: localize('config.common.image.modes.camera') }, + { value: 'default', label: localize('config.common.image.modes.default') }, + { value: 'entity', label: localize('config.common.image.modes.entity') }, + { + value: 'screensaver', + label: localize('config.common.image.modes.screensaver'), + }, + { value: 'url', label: localize('config.common.image.modes.url') }, + ]), + }, + { + name: 'url', + label: localize('config.common.image.url'), + selector: { text: {} }, + }, + { + name: 'entity', + label: localize('config.common.image.entity'), + selector: { entity: {} }, + }, + { + name: 'entity_parameters', + label: localize('config.common.image.entity_parameters'), + selector: { text: {} }, + }, + { + name: 'refresh_seconds', + label: localize('config.common.image.refresh_seconds'), + selector: createNumberSelector(), + }, +]; diff --git a/src/components-lib/editor/schema/common/interaction-mode.ts b/src/components-lib/editor/schema/common/interaction-mode.ts new file mode 100644 index 00000000..d5dfb39f --- /dev/null +++ b/src/components-lib/editor/schema/common/interaction-mode.ts @@ -0,0 +1,12 @@ +import type { HASelectSelectorOption } from '../../../../ha/types'; +import { localize } from '../../../../localize/localize'; + +/** + * Get the options for an interaction mode select. + * @returns The select options. + */ +export const getInteractionModeOptions = (): HASelectSelectorOption[] => [ + { value: 'all', label: localize('config.common.interaction_modes.all') }, + { value: 'inactive', label: localize('config.common.interaction_modes.inactive') }, + { value: 'active', label: localize('config.common.interaction_modes.active') }, +]; diff --git a/src/components-lib/editor/schema/common/media-actions.ts b/src/components-lib/editor/schema/common/media-actions.ts new file mode 100644 index 00000000..d778fcd5 --- /dev/null +++ b/src/components-lib/editor/schema/common/media-actions.ts @@ -0,0 +1,42 @@ +import type { HASelectSelectorOption } from '../../../../ha/types'; +import { localize } from '../../../../localize/localize'; + +// A media-action condition option. `value` is the raw config enum value. The +// label key defaults to the value; `microphone`/`call` override it because +// their wording depends on mute vs unmute. +const condition = (value: string, labelKey: string = value): HASelectSelectorOption => ({ + value, + label: localize(`config.common.media_action_conditions.${labelKey}`), +}); + +export const getMediaActionPositiveOptions = (): HASelectSelectorOption[] => [ + condition('selected'), + condition('visible'), +]; + +export const getMediaActionNegativeOptions = (): HASelectSelectorOption[] => [ + condition('unselected'), + condition('hidden'), +]; + +export const getLiveAutoMuteOptions = (): HASelectSelectorOption[] => [ + ...getMediaActionNegativeOptions(), + condition('microphone', 'microphone_mute'), + condition('call', 'call_mute'), +]; + +export const getLiveAutoUnmuteOptions = (): HASelectSelectorOption[] => [ + ...getMediaActionPositiveOptions(), + condition('microphone', 'microphone_unmute'), + condition('call', 'call_unmute'), +]; + +export const getMicrophoneMuteOptions = (): HASelectSelectorOption[] => [ + ...getMediaActionNegativeOptions(), + condition('call', 'call_mute'), +]; + +export const getMicrophoneUnmuteOptions = (): HASelectSelectorOption[] => [ + ...getMediaActionPositiveOptions(), + condition('call', 'call_unmute'), +]; diff --git a/src/components-lib/editor/schema/common/proxy.ts b/src/components-lib/editor/schema/common/proxy.ts new file mode 100644 index 00000000..c0602f54 --- /dev/null +++ b/src/components-lib/editor/schema/common/proxy.ts @@ -0,0 +1,104 @@ +import type { + HAFormExpandableSchema, + HAFormSchema, + HASelectSelectorOption, +} from '../../../../ha/types'; +import { localize } from '../../../../localize/localize'; +import { createSelectSelector } from './selectors'; + +/** + * Get the schema for a media proxy group. + * @param options The group title and which optional fields to include. + * @returns The form schema for the group. + */ +export const getProxySchema = (options: { + title: string; + includeEnabled?: boolean; + includeLive?: boolean; + includeMedia?: boolean; +}): HAFormExpandableSchema => { + const proxyModeOptions: HASelectSelectorOption[] = [ + { value: 'auto', label: localize('config.common.proxy.modes.auto') }, + { value: true, label: localize('config.common.proxy.modes.true') }, + { value: false, label: localize('config.common.proxy.modes.false') }, + ]; + + const schema: HAFormSchema[] = []; + if (options.includeEnabled) { + schema.push({ + name: 'enabled', + label: localize('config.common.proxy.modes.true'), + selector: { boolean: {} }, + }); + } + if (options.includeLive) { + schema.push({ + name: 'live', + label: localize('config.cameras.proxy.live'), + selector: createSelectSelector(proxyModeOptions), + }); + } + if (options.includeMedia) { + schema.push({ + name: 'media', + label: localize('config.cameras.proxy.media'), + selector: createSelectSelector(proxyModeOptions), + }); + } + schema.push( + { + name: 'dynamic', + label: localize('config.common.proxy.dynamic'), + selector: { boolean: {} }, + }, + { + name: 'ssl_verification', + label: localize('config.common.proxy.ssl_verification.editor_label'), + selector: createSelectSelector([ + { + value: 'auto', + label: localize('config.common.proxy.ssl_verification.auto'), + }, + { + value: true, + label: localize('config.common.proxy.ssl_verification.true'), + }, + { + value: false, + label: localize('config.common.proxy.ssl_verification.false'), + }, + ]), + }, + { + name: 'ssl_ciphers', + label: localize('config.common.proxy.ssl_ciphers.editor_label'), + selector: createSelectSelector([ + { value: 'auto', label: localize('config.common.proxy.ssl_ciphers.auto') }, + { + value: 'default', + label: localize('config.common.proxy.ssl_ciphers.default'), + }, + { + value: 'insecure', + label: localize('config.common.proxy.ssl_ciphers.insecure'), + }, + { + value: 'intermediate', + label: localize('config.common.proxy.ssl_ciphers.intermediate'), + }, + { + value: 'modern', + label: localize('config.common.proxy.ssl_ciphers.modern'), + }, + ]), + }, + ); + + return { + name: 'proxy', + type: 'expandable', + title: options.title, + icon: 'mdi:arrow-decision', + schema, + }; +}; diff --git a/src/components-lib/editor/schema/common/selectors.ts b/src/components-lib/editor/schema/common/selectors.ts new file mode 100644 index 00000000..7e3c2126 --- /dev/null +++ b/src/components-lib/editor/schema/common/selectors.ts @@ -0,0 +1,43 @@ +import type { + HANumberSelector, + HASelectSelector, + HASelectSelectorOption, +} from '../../../../ha/types'; + +/** + * Create a dropdown select selector. Free-form values are allowed only when + * no options are given. + * @param options The options to offer. + * @param params Whether multiple values may be selected. + * @returns A select selector. + */ +export const createSelectSelector = ( + options: string[] | HASelectSelectorOption[], + params?: { multiple?: boolean }, +): HASelectSelector => ({ + select: { + mode: 'dropdown', + multiple: !!params?.multiple, + custom_value: !options.length, + options, + }, +}); + +/** + * Create a number selector: a slider when bounded by a maximum, otherwise an + * input box. + * @param params The bounds and step size. + * @returns A number selector. + */ +export const createNumberSelector = (params?: { + min?: number; + max?: number; + step?: number; +}): HANumberSelector => ({ + number: { + min: params?.min ?? 0, + max: params?.max, + mode: params?.max === undefined ? 'box' : 'slider', + step: params?.step, + }, +}); diff --git a/src/components-lib/editor/schema/common/transition-effect.ts b/src/components-lib/editor/schema/common/transition-effect.ts new file mode 100644 index 00000000..a4fa2672 --- /dev/null +++ b/src/components-lib/editor/schema/common/transition-effect.ts @@ -0,0 +1,14 @@ +import type { HAFormSchema } from '../../../../ha/types'; +import { localize } from '../../../../localize/localize'; +import { createSelectSelector } from './selectors'; + +// The "slide" transition effect between media. The field label auto-derives +// from each section's path; only the option labels are shared (under +// `config.common.transition_effects`). +export const getTransitionEffectSchema = (): HAFormSchema => ({ + name: 'transition_effect', + selector: createSelectSelector([ + { value: 'none', label: localize('config.common.transition_effects.none') }, + { value: 'slide', label: localize('config.common.transition_effects.slide') }, + ]), +}); diff --git a/src/components-lib/editor/schema/dimensions.ts b/src/components-lib/editor/schema/dimensions.ts new file mode 100644 index 00000000..7ec92c89 --- /dev/null +++ b/src/components-lib/editor/schema/dimensions.ts @@ -0,0 +1,40 @@ +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { createSelectSelector } from './common/selectors'; + +/** + * Get the form for the dimensions section. + * @returns The section forms. + */ +export const getDimensionsSectionForms = (): EditorForm[] => [ + { + basePath: ['dimensions'], + schema: [ + { + name: 'aspect_ratio_mode', + selector: createSelectSelector([ + { + value: 'dynamic', + label: localize('config.dimensions.aspect_ratio_modes.dynamic'), + }, + { + value: 'static', + label: localize('config.dimensions.aspect_ratio_modes.static'), + }, + { + value: 'unconstrained', + label: localize('config.dimensions.aspect_ratio_modes.unconstrained'), + }, + ]), + }, + { + name: 'aspect_ratio', + selector: { text: {} }, + }, + { + name: 'height', + selector: { text: {} }, + }, + ], + }, +]; diff --git a/src/components-lib/editor/schema/folders.ts b/src/components-lib/editor/schema/folders.ts new file mode 100644 index 00000000..ae9ba3ed --- /dev/null +++ b/src/components-lib/editor/schema/folders.ts @@ -0,0 +1,41 @@ +import type { HAFormSchema } from '../../../ha/types'; +import { localize } from '../../../localize/localize'; +import { createSelectSelector } from './common/selectors'; + +/** + * Get the schema for a single folder. Array sections expose just the item + * fields; the editor renders one form per folder at `basePath: ['folders', i]`. + * @returns The form schema for one folder. + */ +export const getFolderSchema = (): HAFormSchema[] => [ + { + name: 'type', + selector: createSelectSelector([ + { value: 'ha', label: localize('config.folders.types.ha') }, + ]), + }, + { + name: 'title', + selector: { text: {} }, + }, + { + name: 'icon', + selector: { icon: {} }, + }, + { + name: 'id', + selector: { text: {} }, + }, + { + name: 'ha', + type: 'expandable', + title: localize('config.folders.ha.editor_label'), + icon: 'mdi:home-assistant', + schema: [ + { + name: 'url', + selector: { text: {} }, + }, + ], + }, +]; diff --git a/src/components-lib/editor/schema/image.ts b/src/components-lib/editor/schema/image.ts new file mode 100644 index 00000000..e4aae6f6 --- /dev/null +++ b/src/components-lib/editor/schema/image.ts @@ -0,0 +1,25 @@ +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { getImageFieldsSchema } from './common/image'; +import { getProxySchema } from './common/proxy'; + +/** + * Get the section forms for the image section. + * @returns The section forms. + */ +export const getImageSectionForms = (): EditorForm[] => [ + { + basePath: ['image'], + schema: [ + ...getImageFieldsSchema(), + { + name: 'zoomable', + selector: { boolean: {} }, + }, + getProxySchema({ + title: localize('config.common.image.proxy.editor_label'), + includeEnabled: true, + }), + ], + }, +]; diff --git a/src/components-lib/editor/schema/live.ts b/src/components-lib/editor/schema/live.ts new file mode 100644 index 00000000..28a2c284 --- /dev/null +++ b/src/components-lib/editor/schema/live.ts @@ -0,0 +1,164 @@ +import { BUTTON_SIZE_MIN } from '../../../config/schema/common/const'; +import type { HAFormExpandableSchema } from '../../../ha/types'; +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { getNextPreviousSchema } from './common/controls/next-previous'; +import { getPTZSchema } from './common/controls/ptz'; +import { getThumbnailsSchema } from './common/controls/thumbnails'; +import { getMiniTimelineSchema } from './common/controls/timeline'; +import { getDisplaySchema } from './common/display'; +import { + getLiveAutoMuteOptions, + getLiveAutoUnmuteOptions, + getMediaActionNegativeOptions, + getMediaActionPositiveOptions, + getMicrophoneMuteOptions, + getMicrophoneUnmuteOptions, +} from './common/media-actions'; +import { createNumberSelector, createSelectSelector } from './common/selectors'; +import { getTransitionEffectSchema } from './common/transition-effect'; + +const getCallSchema = (): HAFormExpandableSchema => ({ + name: 'call', + type: 'expandable', + title: localize('config.live.controls.call.editor_label'), + icon: 'mdi:phone', + schema: [ + { name: 'lock', selector: { boolean: {} } }, + { + name: 'ringtone', + type: 'expandable', + title: localize('config.live.controls.call.ringtone.editor_label'), + icon: 'mdi:music-note', + schema: [ + { + name: 'type', + selector: createSelectSelector([ + { + value: 'none', + label: localize('config.live.controls.call.ringtone.types.none'), + }, + { + value: 'chime', + label: localize('config.live.controls.call.ringtone.types.chime'), + }, + { + value: 'westminster', + label: localize('config.live.controls.call.ringtone.types.westminster'), + }, + { + value: 'arpeggio', + label: localize('config.live.controls.call.ringtone.types.arpeggio'), + }, + { + value: 'melody', + label: localize('config.live.controls.call.ringtone.types.melody'), + }, + { + value: 'custom', + label: localize('config.live.controls.call.ringtone.types.custom'), + }, + ]), + }, + { name: 'url', selector: { text: {} } }, + { name: 'repeat', selector: createNumberSelector({ min: 0 }) }, + ], + }, + { name: 'unanswered_timeout_seconds', selector: createNumberSelector({ min: 0 }) }, + { name: 'button_size', selector: createNumberSelector({ min: BUTTON_SIZE_MIN }) }, + ], +}); + +const getControlsSchema = (): HAFormExpandableSchema => ({ + name: 'controls', + type: 'expandable', + title: localize('config.live.controls.editor_label'), + icon: 'mdi:gamepad', + schema: [ + { + name: 'builtin', + label: localize('config.common.controls.builtin'), + selector: { boolean: {} }, + }, + { + name: 'wheel', + label: localize('config.common.controls.wheel'), + selector: { boolean: {} }, + }, + getCallSchema(), + getNextPreviousSchema({ allowIcons: true, allowCall: true }), + getThumbnailsSchema({ includeMode: true }), + getMiniTimelineSchema(), + getPTZSchema({ includeAutoMode: true }), + ], +}); + +const getMicrophoneSchema = (): HAFormExpandableSchema => ({ + name: 'microphone', + type: 'expandable', + title: localize('config.live.microphone.editor_label'), + icon: 'mdi:microphone', + schema: [ + { name: 'disconnect_seconds', selector: createNumberSelector({ min: 0 }) }, + { name: 'always_connected', selector: { boolean: {} } }, + { + name: 'auto_mute', + selector: createSelectSelector(getMicrophoneMuteOptions(), { multiple: true }), + }, + { + name: 'auto_unmute', + selector: createSelectSelector(getMicrophoneUnmuteOptions(), { multiple: true }), + }, + { + name: 'mute_after_microphone_mute_seconds', + selector: createNumberSelector({ min: 0 }), + }, + ], +}); + +/** + * Get the forms for the live section. + * @returns The section forms. + */ +export const getLiveSectionForms = (): EditorForm[] => [ + { + basePath: ['live'], + schema: [ + { name: 'preload', selector: { boolean: {} } }, + { name: 'draggable', selector: { boolean: {} } }, + { name: 'zoomable', selector: { boolean: {} } }, + { name: 'lazy_load', selector: { boolean: {} } }, + { + name: 'lazy_unload', + selector: createSelectSelector(getMediaActionNegativeOptions(), { + multiple: true, + }), + }, + { + name: 'auto_play', + selector: createSelectSelector(getMediaActionPositiveOptions(), { + multiple: true, + }), + }, + { + name: 'auto_pause', + selector: createSelectSelector(getMediaActionNegativeOptions(), { + multiple: true, + }), + }, + { + name: 'auto_mute', + selector: createSelectSelector(getLiveAutoMuteOptions(), { multiple: true }), + }, + { + name: 'auto_unmute', + selector: createSelectSelector(getLiveAutoUnmuteOptions(), { multiple: true }), + }, + getTransitionEffectSchema(), + { name: 'show_image_during_load', selector: { boolean: {} } }, + getDisplaySchema(), + getControlsSchema(), + getMicrophoneSchema(), + ], + }, +]; diff --git a/src/components-lib/editor/schema/media-gallery.ts b/src/components-lib/editor/schema/media-gallery.ts new file mode 100644 index 00000000..b469601e --- /dev/null +++ b/src/components-lib/editor/schema/media-gallery.ts @@ -0,0 +1,34 @@ +import type { HAFormExpandableSchema } from '../../../ha/types'; +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { getThumbnailsSchema } from './common/controls/thumbnails'; +import { createSelectSelector } from './common/selectors'; + +const getFilterSchema = (): HAFormExpandableSchema => ({ + name: 'filter', + type: 'expandable', + title: localize('config.common.controls.filter.editor_label'), + icon: 'mdi:filter-cog', + schema: [ + { + name: 'mode', + label: localize('config.common.controls.filter.mode'), + selector: createSelectSelector([ + { value: 'none', label: localize('config.common.controls.filter.modes.none') }, + { value: 'left', label: localize('config.common.controls.filter.modes.left') }, + { value: 'right', label: localize('config.common.controls.filter.modes.right') }, + ]), + }, + ], +}); + +/** + * Get the form for the media gallery section. + * @returns The section forms. + */ +export const getMediaGallerySectionForms = (): EditorForm[] => [ + { + basePath: ['media_gallery', 'controls'], + schema: [getThumbnailsSchema(), getFilterSchema()], + }, +]; diff --git a/src/components-lib/editor/schema/media-viewer.ts b/src/components-lib/editor/schema/media-viewer.ts new file mode 100644 index 00000000..d63ff40b --- /dev/null +++ b/src/components-lib/editor/schema/media-viewer.ts @@ -0,0 +1,80 @@ +import type { HAFormExpandableSchema } from '../../../ha/types'; +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { getNextPreviousSchema } from './common/controls/next-previous'; +import { getPTZSchema } from './common/controls/ptz'; +import { getThumbnailsSchema } from './common/controls/thumbnails'; +import { getMiniTimelineSchema } from './common/controls/timeline'; +import { getDisplaySchema } from './common/display'; +import { + getMediaActionNegativeOptions, + getMediaActionPositiveOptions, +} from './common/media-actions'; +import { createSelectSelector } from './common/selectors'; +import { getTransitionEffectSchema } from './common/transition-effect'; + +const getControlsSchema = (): HAFormExpandableSchema => ({ + name: 'controls', + type: 'expandable', + title: localize('config.media_viewer.controls.editor_label'), + icon: 'mdi:gamepad', + schema: [ + { + name: 'builtin', + label: localize('config.common.controls.builtin'), + selector: { boolean: {} }, + }, + { + name: 'wheel', + label: localize('config.common.controls.wheel'), + selector: { boolean: {} }, + }, + getNextPreviousSchema({ allowThumbnails: true }), + getThumbnailsSchema({ includeMode: true }), + getMiniTimelineSchema(), + getPTZSchema({ includeAutoMode: false }), + ], +}); + +/** + * Get the forms for the media viewer section. + * @returns The section forms. + */ +export const getMediaViewerSectionForms = (): EditorForm[] => [ + { + basePath: ['media_viewer'], + schema: [ + { + name: 'auto_play', + selector: createSelectSelector(getMediaActionPositiveOptions(), { + multiple: true, + }), + }, + { + name: 'auto_pause', + selector: createSelectSelector(getMediaActionNegativeOptions(), { + multiple: true, + }), + }, + { + name: 'auto_mute', + selector: createSelectSelector(getMediaActionNegativeOptions(), { + multiple: true, + }), + }, + { + name: 'auto_unmute', + selector: createSelectSelector(getMediaActionPositiveOptions(), { + multiple: true, + }), + }, + { name: 'draggable', selector: { boolean: {} } }, + { name: 'zoomable', selector: { boolean: {} } }, + { name: 'lazy_load', selector: { boolean: {} } }, + getTransitionEffectSchema(), + { name: 'snapshot_click_plays_clip', selector: { boolean: {} } }, + getDisplaySchema(), + getControlsSchema(), + ], + }, +]; diff --git a/src/components-lib/editor/schema/menu.ts b/src/components-lib/editor/schema/menu.ts new file mode 100644 index 00000000..8121d622 --- /dev/null +++ b/src/components-lib/editor/schema/menu.ts @@ -0,0 +1,176 @@ +import { BUTTON_SIZE_MIN, MENU_PRIORITY_MAX } from '../../../config/schema/common/const'; +import type { HAFormExpandableSchema, HAFormSchema } from '../../../ha/types'; +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { createNumberSelector, createSelectSelector } from './common/selectors'; + +// The menu buttons in their display order. +const MENU_BUTTONS = [ + // Iris menu button is first. + 'iris', + + // Alphabetical. + 'call', + 'camera_ui', + 'cameras', + 'clips', + 'display_mode', + 'download', + 'expand', + 'folders', + 'fullscreen', + 'gallery', + 'image', + 'info', + 'live', + 'media_player', + 'microphone', + 'mute', + 'pip', + 'play', + 'ptz_controls', + 'ptz_home', + 'recordings', + 'reviews', + 'screenshot', + 'set_review', + 'snapshots', + 'substreams', + 'timeline', +]; + +// The microphone button alone carries a `type` (momentary vs toggle). +const getMicrophoneTypeSchema = (): HAFormSchema => ({ + name: 'type', + label: localize('config.menu.buttons.type'), + selector: createSelectSelector([ + { value: 'momentary', label: localize('config.menu.buttons.types.momentary') }, + { value: 'toggle', label: localize('config.menu.buttons.types.toggle') }, + ]), +}); + +const getMenuButtonSchema = ( + button: string, + extraSchema: HAFormSchema[] = [], +): HAFormExpandableSchema => ({ + name: button, + type: 'expandable', + title: localize(`config.menu.buttons.${button}`), + icon: 'mdi:gesture-tap-button', + schema: [ + { + name: 'enabled', + label: localize('config.menu.buttons.enabled'), + selector: { boolean: {} }, + }, + { + name: 'alignment', + label: localize('config.menu.buttons.alignment'), + selector: createSelectSelector([ + { + value: 'matching', + label: localize('config.menu.buttons.alignments.matching'), + }, + { + value: 'opposing', + label: localize('config.menu.buttons.alignments.opposing'), + }, + ]), + }, + { + name: 'permanent', + label: localize('config.menu.buttons.permanent'), + selector: { boolean: {} }, + }, + { + name: 'priority', + label: localize('config.menu.buttons.priority'), + selector: createNumberSelector({ max: MENU_PRIORITY_MAX }), + }, + { + name: 'icon', + label: localize('config.menu.buttons.icon'), + selector: { icon: {} }, + }, + { + name: 'state_color', + label: localize('config.menu.buttons.state_color'), + selector: { boolean: {} }, + }, + { + name: 'inert', + label: localize('config.menu.buttons.inert'), + selector: { boolean: {} }, + }, + ...extraSchema, + ], +}); + +/** + * Get the forms for the menu section. + * @returns The section forms. + */ +export const getMenuSectionForms = (): EditorForm[] => [ + { + basePath: ['menu'], + schema: [ + { + name: 'style', + selector: createSelectSelector([ + { value: 'none', label: localize('config.menu.styles.none') }, + { value: 'hidden', label: localize('config.menu.styles.hidden') }, + { value: 'overlay', label: localize('config.menu.styles.overlay') }, + { value: 'hover', label: localize('config.menu.styles.hover') }, + { value: 'hover-card', label: localize('config.menu.styles.hover-card') }, + { value: 'outside', label: localize('config.menu.styles.outside') }, + ]), + }, + { + name: 'position', + selector: createSelectSelector([ + { value: 'left', label: localize('config.menu.positions.left') }, + { value: 'right', label: localize('config.menu.positions.right') }, + { value: 'top', label: localize('config.menu.positions.top') }, + { value: 'bottom', label: localize('config.menu.positions.bottom') }, + ]), + }, + { + name: 'alignment', + selector: createSelectSelector([ + { value: 'left', label: localize('config.menu.alignments.left') }, + { value: 'right', label: localize('config.menu.alignments.right') }, + { value: 'top', label: localize('config.menu.alignments.top') }, + { value: 'bottom', label: localize('config.menu.alignments.bottom') }, + ]), + }, + { + name: 'auto_hide', + selector: createSelectSelector( + [ + { + value: 'call', + label: localize('config.common.auto_hide_conditions.call'), + }, + { + value: 'casting', + label: localize('config.common.auto_hide_conditions.casting'), + }, + ], + { multiple: true }, + ), + }, + { + name: 'button_size', + selector: createNumberSelector({ min: BUTTON_SIZE_MIN }), + }, + ], + }, + { + basePath: ['menu', 'buttons'], + schema: MENU_BUTTONS.map((button) => + button === 'microphone' + ? getMenuButtonSchema(button, [getMicrophoneTypeSchema()]) + : getMenuButtonSchema(button), + ), + }, +]; diff --git a/src/components-lib/editor/schema/performance.ts b/src/components-lib/editor/schema/performance.ts new file mode 100644 index 00000000..f3bcd16b --- /dev/null +++ b/src/components-lib/editor/schema/performance.ts @@ -0,0 +1,46 @@ +import { MEDIA_CHUNK_SIZE_MAX } from '../../../config/schema/performance'; +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { createNumberSelector } from './common/selectors'; + +/** + * Get the form for the performance section (except the low-performance profile + * warning, which is a custom info row). + * @returns The section forms. + */ +export const getPerformanceSectionForms = (): EditorForm[] => [ + { + basePath: ['performance'], + schema: [ + { + name: 'features', + type: 'expandable', + title: localize('config.performance.features.editor_label'), + icon: 'mdi:feature-search', + schema: [ + { name: 'card_loading_indicator', selector: { boolean: {} } }, + { name: 'card_loading_effects', selector: { boolean: {} } }, + { name: 'animated_progress_indicator', selector: { boolean: {} } }, + { + name: 'media_chunk_size', + selector: createNumberSelector({ max: MEDIA_CHUNK_SIZE_MAX }), + }, + { + name: 'max_simultaneous_engine_requests', + selector: createNumberSelector({ min: 1 }), + }, + ], + }, + { + name: 'style', + type: 'expandable', + title: localize('config.performance.style.editor_label'), + icon: 'mdi:palette-swatch-variant', + schema: [ + { name: 'border_radius', selector: { boolean: {} } }, + { name: 'box_shadow', selector: { boolean: {} } }, + ], + }, + ], + }, +]; diff --git a/src/components-lib/editor/schema/profiles.ts b/src/components-lib/editor/schema/profiles.ts new file mode 100644 index 00000000..d3614b0e --- /dev/null +++ b/src/components-lib/editor/schema/profiles.ts @@ -0,0 +1,31 @@ +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { createSelectSelector } from './common/selectors'; + +/** + * Get the form for the profiles section. + * @returns The section forms. + */ +export const getProfilesSectionForms = (): EditorForm[] => [ + { + basePath: [], + schema: [ + { + name: 'profiles', + label: localize('config.profiles.editor_label'), + selector: createSelectSelector( + [ + { value: 'casting', label: localize('config.profiles.casting') }, + { value: 'doorbell', label: localize('config.profiles.doorbell') }, + { + value: 'low-performance', + label: localize('config.profiles.low-performance'), + }, + { value: 'scrubbing', label: localize('config.profiles.scrubbing') }, + ], + { multiple: true }, + ), + }, + ], + }, +]; diff --git a/src/components-lib/editor/schema/registry.ts b/src/components-lib/editor/schema/registry.ts new file mode 100644 index 00000000..bd84b324 --- /dev/null +++ b/src/components-lib/editor/schema/registry.ts @@ -0,0 +1,124 @@ +import { CONF_CAMERAS, CONF_FOLDERS } from '../../../config/const'; +import type { HASelectSelectorOption } from '../../../ha/types'; +import type { EditorForm } from '../types'; +import { + getCameraSchema, + getCameraTriggersSchema, + getTriggerEventSchema, +} from './cameras'; +import { getDimensionsSectionForms } from './dimensions'; +import { getFolderSchema } from './folders'; +import { getImageSectionForms } from './image'; +import { getLiveSectionForms } from './live'; +import { getMediaGallerySectionForms } from './media-gallery'; +import { getMediaViewerSectionForms } from './media-viewer'; +import { getMenuSectionForms } from './menu'; +import { getPerformanceSectionForms } from './performance'; +import { getProfilesSectionForms } from './profiles'; +import { getRemoteControlSectionForms } from './remote-control'; +import { getStatusBarSectionForms } from './status-bar'; +import { getTimelineSectionForms } from './timeline'; +import { getViewKeyboardShortcutsSectionForms, getViewSectionForms } from './view'; + +// The forms an editor component asks for. A request carries its indices as +// numbers rather than being named by a path string, so no caller has to build +// a string key and no builder has to pull one apart again. +export type FormRequest = + | { kind: 'section'; name: string } + | { kind: 'folder'; index: number } + | { kind: 'camera'; index: number } + | { kind: 'camera-triggers'; cameraIndex: number } + | { kind: 'camera-event'; cameraIndex: number; eventIndex: number }; + +// The lists a form's dropdowns choose from, gathered from the rest of the +// configuration: the cameras and the folders. Entity fields are not among them: +// they use Home Assistant's own entity picker, which reads the entities itself. +// The cameras are in configuration order, so that a camera's own form can leave +// itself out. +export interface FormRequestOptions { + cameras: HASelectSelectorOption[]; + folders: HASelectSelectorOption[]; +} + +const getSectionFormsByName = (name: string): EditorForm[] => { + switch (name) { + case 'dimensions': + return getDimensionsSectionForms(); + case 'image': + return getImageSectionForms(); + case 'live': + return getLiveSectionForms(); + case 'media_gallery': + return getMediaGallerySectionForms(); + case 'media_viewer': + return getMediaViewerSectionForms(); + case 'menu': + return getMenuSectionForms(); + case 'performance': + return getPerformanceSectionForms(); + case 'profiles': + return getProfilesSectionForms(); + case 'remote_control': + return getRemoteControlSectionForms(); + case 'status_bar': + return getStatusBarSectionForms(); + case 'timeline': + return getTimelineSectionForms(); + case 'view': + return getViewSectionForms(); + case 'view.keyboard_shortcuts': + return getViewKeyboardShortcutsSectionForms(); + } + return []; +}; + +/** + * Get the forms for a request. + * @param request What forms are wanted. + * @param options The lists the form's dropdowns choose from. + * @returns The forms, or an empty list for a section that has none. + */ +export const getForms = ( + request: FormRequest, + options: FormRequestOptions, +): EditorForm[] => { + switch (request.kind) { + case 'section': + return getSectionFormsByName(request.name); + case 'folder': + return [{ basePath: [CONF_FOLDERS, request.index], schema: getFolderSchema() }]; + case 'camera': + return [ + { + basePath: [CONF_CAMERAS, request.index], + schema: getCameraSchema({ + // A camera cannot depend on itself. + otherCameras: options.cameras.filter( + (_camera, index) => index !== request.index, + ), + folders: options.folders, + }), + }, + ]; + case 'camera-triggers': + return [ + { + basePath: [CONF_CAMERAS, request.cameraIndex, 'triggers'], + schema: getCameraTriggersSchema(), + }, + ]; + case 'camera-event': + return [ + { + basePath: [ + CONF_CAMERAS, + request.cameraIndex, + 'triggers', + 'events', + request.eventIndex, + ], + schema: getTriggerEventSchema(), + }, + ]; + } +}; diff --git a/src/components-lib/editor/schema/remote-control.ts b/src/components-lib/editor/schema/remote-control.ts new file mode 100644 index 00000000..79e25613 --- /dev/null +++ b/src/components-lib/editor/schema/remote-control.ts @@ -0,0 +1,42 @@ +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { createSelectSelector } from './common/selectors'; + +/** + * Get the form for the remote control section. + * @returns The section forms. + */ +export const getRemoteControlSectionForms = (): EditorForm[] => [ + { + basePath: ['remote_control'], + schema: [ + { + name: 'entities', + type: 'expandable', + title: localize('config.remote_control.entities.editor_label'), + icon: 'mdi:devices', + schema: [ + { + name: 'camera', + selector: { entity: { domain: 'input_select' } }, + }, + { + name: 'camera_priority', + selector: createSelectSelector([ + { + value: 'card', + label: localize('config.remote_control.entities.camera_priorities.card'), + }, + { + value: 'entity', + label: localize( + 'config.remote_control.entities.camera_priorities.entity', + ), + }, + ]), + }, + ], + }, + ], + }, +]; diff --git a/src/components-lib/editor/schema/status-bar.ts b/src/components-lib/editor/schema/status-bar.ts new file mode 100644 index 00000000..d25205ec --- /dev/null +++ b/src/components-lib/editor/schema/status-bar.ts @@ -0,0 +1,100 @@ +import { STATUS_BAR_PRIORITY_MAX } from '../../../config/schema/common/const'; +import { STATUS_BAR_HEIGHT_MIN } from '../../../config/schema/status-bar'; +import type { HAFormExpandableSchema } from '../../../ha/types'; +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { createNumberSelector, createSelectSelector } from './common/selectors'; + +const STATUS_BAR_ITEMS = [ + 'engine', + 'issues', + 'resolution', + 'severity', + 'technology', + 'title', +]; + +const getStatusBarItemSchema = (item: string): HAFormExpandableSchema => ({ + name: item, + type: 'expandable', + title: localize(`config.status_bar.items.${item}`), + icon: 'mdi:feature-search', + schema: [ + { + name: 'enabled', + label: localize('config.status_bar.items.enabled'), + selector: { boolean: {} }, + }, + { + name: 'permanent', + label: localize('config.status_bar.items.permanent'), + selector: { boolean: {} }, + }, + { + name: 'priority', + label: localize('config.status_bar.items.priority'), + selector: createNumberSelector({ max: STATUS_BAR_PRIORITY_MAX }), + }, + ], +}); + +/** + * Get the forms for the status bar section. + * @returns The section forms. + */ +export const getStatusBarSectionForms = (): EditorForm[] => [ + { + basePath: ['status_bar'], + schema: [ + { + name: 'style', + selector: createSelectSelector([ + { value: 'hover', label: localize('config.status_bar.styles.hover') }, + { + value: 'hover-card', + label: localize('config.status_bar.styles.hover-card'), + }, + { value: 'none', label: localize('config.status_bar.styles.none') }, + { value: 'outside', label: localize('config.status_bar.styles.outside') }, + { value: 'overlay', label: localize('config.status_bar.styles.overlay') }, + { value: 'popup', label: localize('config.status_bar.styles.popup') }, + ]), + }, + { + name: 'position', + selector: createSelectSelector([ + { value: 'top', label: localize('config.status_bar.positions.top') }, + { value: 'bottom', label: localize('config.status_bar.positions.bottom') }, + ]), + }, + { + name: 'auto_hide', + selector: createSelectSelector( + [ + { + value: 'call', + label: localize('config.common.auto_hide_conditions.call'), + }, + { + value: 'casting', + label: localize('config.common.auto_hide_conditions.casting'), + }, + ], + { multiple: true }, + ), + }, + { + name: 'height', + selector: createNumberSelector({ min: STATUS_BAR_HEIGHT_MIN }), + }, + { + name: 'popup_seconds', + selector: createNumberSelector({ min: 0, max: 60 }), + }, + ], + }, + { + basePath: ['status_bar', 'items'], + schema: STATUS_BAR_ITEMS.map(getStatusBarItemSchema), + }, +]; diff --git a/src/components-lib/editor/schema/timeline.ts b/src/components-lib/editor/schema/timeline.ts new file mode 100644 index 00000000..4b1d8b29 --- /dev/null +++ b/src/components-lib/editor/schema/timeline.ts @@ -0,0 +1,20 @@ +import type { EditorForm } from '../types'; +import { getThumbnailsSchema } from './common/controls/thumbnails'; +import { getTimelineCoreSchema } from './common/controls/timeline'; + +/** + * Get the forms for the timeline section. The core timeline fields and the + * thumbnail controls live under different configuration paths, so they render + * as two forms. + * @returns The section forms. + */ +export const getTimelineSectionForms = (): EditorForm[] => [ + { + basePath: ['timeline'], + schema: getTimelineCoreSchema(), + }, + { + basePath: ['timeline', 'controls'], + schema: [getThumbnailsSchema({ includeMode: true })], + }, +]; diff --git a/src/components-lib/editor/schema/view.ts b/src/components-lib/editor/schema/view.ts new file mode 100644 index 00000000..8ca50d28 --- /dev/null +++ b/src/components-lib/editor/schema/view.ts @@ -0,0 +1,237 @@ +import type { HASelectSelectorOption } from '../../../ha/types'; +import { localize } from '../../../localize/localize'; +import type { EditorForm } from '../types'; +import { getInteractionModeOptions } from './common/interaction-mode'; +import { createNumberSelector, createSelectSelector } from './common/selectors'; + +const getViewModeOptions = (): HASelectSelectorOption[] => [ + { value: 'auto', label: localize('config.view.views.auto') }, + { value: 'clip', label: localize('config.view.views.clip') }, + { value: 'clips', label: localize('config.view.views.clips') }, + { value: 'diagnostics', label: localize('config.view.views.diagnostics') }, + { value: 'folder', label: localize('config.view.views.folder') }, + { value: 'folders', label: localize('config.view.views.folders') }, + { value: 'gallery', label: localize('config.view.views.gallery') }, + { value: 'image', label: localize('config.view.views.image') }, + { value: 'live', label: localize('config.view.views.live') }, + { value: 'media', label: localize('config.view.views.media') }, + { value: 'recording', label: localize('config.view.views.recording') }, + { value: 'recordings', label: localize('config.view.views.recordings') }, + { value: 'review', label: localize('config.view.views.review') }, + { value: 'reviews', label: localize('config.view.views.reviews') }, + { value: 'snapshot', label: localize('config.view.views.snapshot') }, + { value: 'snapshots', label: localize('config.view.views.snapshots') }, + { value: 'timeline', label: localize('config.view.views.timeline') }, +]; + +/** + * Get the form for the view section (excluding the keyboard shortcuts, which + * render in their own panel). + * @param options The entity IDs offered for entity fields. + * @returns The section forms. + */ +export const getViewSectionForms = (): EditorForm[] => [ + { + basePath: ['view'], + schema: [ + { + name: 'default', + selector: createSelectSelector(getViewModeOptions()), + }, + { + name: 'camera_select', + selector: createSelectSelector([ + ...getViewModeOptions(), + { value: 'current', label: localize('config.view.views.current') }, + ]), + }, + { + name: 'dim', + selector: { boolean: {} }, + }, + { + name: 'interaction_seconds', + selector: createNumberSelector(), + }, + { + name: 'default_cycle_camera', + selector: { boolean: {} }, + }, + { + name: 'default_reset', + type: 'expandable', + title: localize('config.view.default_reset.editor_label'), + icon: 'mdi:restart', + schema: [ + { + name: 'after_interaction', + selector: { boolean: {} }, + }, + { + name: 'every_seconds', + selector: createNumberSelector(), + }, + { + name: 'interaction_mode', + label: localize('config.view.default_reset.interaction_mode'), + selector: createSelectSelector(getInteractionModeOptions()), + }, + { + name: 'entities', + selector: { entity: { multiple: true } }, + }, + ], + }, + { + name: 'issues', + type: 'expandable', + title: localize('config.view.issues.editor_label'), + icon: 'mdi:alert-circle-outline', + schema: [ + { + name: 'interaction_mode', + label: localize('config.view.issues.interaction_mode'), + selector: createSelectSelector(getInteractionModeOptions()), + }, + { + name: 'retry_seconds', + selector: createNumberSelector(), + }, + ], + }, + { + name: 'triggers', + type: 'expandable', + title: localize('config.view.triggers.editor_label'), + icon: 'mdi:target-account', + schema: [ + { + name: 'filter_selected_camera', + selector: { boolean: {} }, + }, + { + name: 'show_trigger_status', + selector: { boolean: {} }, + }, + { + name: 'untrigger_delay_seconds', + selector: createNumberSelector(), + }, + { + name: 'untrigger_force_seconds', + selector: createNumberSelector(), + }, + { + name: 'event_hold_seconds', + selector: createNumberSelector(), + }, + { + name: 'actions', + type: 'expandable', + title: localize('config.view.triggers.actions.editor_label'), + icon: 'mdi:cogs', + schema: [ + { + name: 'trigger', + label: localize('config.view.triggers.actions.trigger'), + selector: createSelectSelector([ + { + value: 'call', + label: localize('config.view.triggers.actions.triggers.call'), + }, + { + value: 'default', + label: localize('config.view.triggers.actions.triggers.default'), + }, + { + value: 'live', + label: localize('config.view.triggers.actions.triggers.live'), + }, + { + value: 'media', + label: localize('config.view.triggers.actions.triggers.media'), + }, + { + value: 'none', + label: localize('config.view.triggers.actions.triggers.none'), + }, + { + value: 'update', + label: localize('config.view.triggers.actions.triggers.update'), + }, + ]), + }, + { + name: 'untrigger', + label: localize('config.view.triggers.actions.untrigger'), + selector: createSelectSelector([ + { + value: 'call', + label: localize('config.view.triggers.actions.untriggers.call'), + }, + { + value: 'default', + label: localize('config.view.triggers.actions.untriggers.default'), + }, + { + value: 'none', + label: localize('config.view.triggers.actions.untriggers.none'), + }, + ]), + }, + { + name: 'interaction_mode', + label: localize('config.view.triggers.actions.interaction_mode'), + selector: createSelectSelector(getInteractionModeOptions()), + }, + ], + }, + ], + }, + ], + }, + // `themes` is the only configurable key under `view.theme` (`overrides` is + // free-form CSS kept YAML-only), so it renders bare rather than in an + // expandable panel. The separate `EditorForm` exists only to nest it under + // `theme` via its `basePath`. + { + basePath: ['view', 'theme'], + schema: [ + { + name: 'themes', + label: localize('config.view.theme.themes.editor_label'), + selector: createSelectSelector( + [ + { value: 'ha', label: localize('config.view.theme.themes.ha') }, + { value: 'dark', label: localize('config.view.theme.themes.dark') }, + { value: 'light', label: localize('config.view.theme.themes.light') }, + { + value: 'traditional', + label: localize('config.view.theme.themes.traditional'), + }, + ], + { multiple: true }, + ), + }, + ], + }, +]; + +/** + * Get the form for the keyboard shortcuts panel. Only the enabled flag is a + * form field: a keystroke cannot be captured by any `ha-form` selector, so each + * shortcut in `PTZ_KEYBOARD_SHORTCUTS` is rendered by a key assigner widget + * instead. + * @returns The section forms. + */ +export const getViewKeyboardShortcutsSectionForms = (): EditorForm[] => [ + { + basePath: ['view', 'keyboard_shortcuts'], + schema: [ + { + name: 'enabled', + selector: { boolean: {} }, + }, + ], + }, +]; diff --git a/src/components-lib/editor/section-controller.ts b/src/components-lib/editor/section-controller.ts new file mode 100644 index 00000000..d1d5660c --- /dev/null +++ b/src/components-lib/editor/section-controller.ts @@ -0,0 +1,68 @@ +import type { ReactiveController, ReactiveControllerHost, TemplateResult } from 'lit'; + +import { FormsController, type FormContext, type FormsInput } from './forms-controller'; +import { fireEditorIntent } from './intents'; +import type { FormRequest } from './schema/registry'; +import type { ConfigChange, ConfigPath } from './types'; + +type SectionEditorControllerHost = ReactiveControllerHost & EventTarget; + +/** + * One top-level section of the editor: whether its panel is open, and the + * forms it shows. A section is given the whole configuration, because a + * dropdown in one section may list values set in another, but it never changes + * it: an edit is reported to the editor as an intent. + */ +export class SectionController implements ReactiveController { + private _host: SectionEditorControllerHost; + private _formsController: FormsController; + + private _open = false; + + // A section's body is built only once it has been opened, and is kept + // afterwards: closing animates, and a section the user never opens never + // builds its forms at all. + private _everOpened = false; + + constructor( + host: SectionEditorControllerHost, + renderDocumentation: (path: ConfigPath) => TemplateResult | null, + ) { + this._host = host; + this._formsController = new FormsController( + (changes: ConfigChange[]) => + fireEditorIntent(this._host, { type: 'changes', changes }), + renderDocumentation, + ); + host.addController(this); + } + + public hostConnected(): void { + // No connection-time work. + } + + public setInput(request: FormRequest, input: FormsInput): void { + this._formsController.setInput(request, input); + } + + public getContexts(): FormContext[] { + return this._formsController.getContexts(); + } + + public isOpen(): boolean { + return this._open; + } + + public wasEverOpened(): boolean { + return this._everOpened; + } + + public setOpen(open: boolean): void { + if (open === this._open) { + return; + } + this._open = open; + this._everOpened = this._everOpened || open; + this._host.requestUpdate(); + } +} diff --git a/src/components-lib/editor/titles.ts b/src/components-lib/editor/titles.ts new file mode 100644 index 00000000..8faddb1d --- /dev/null +++ b/src/components-lib/editor/titles.ts @@ -0,0 +1,70 @@ +import { getEntityTitle } from '../../ha/get-entity-title'; +import type { HomeAssistant } from '../../ha/types'; +import { localize } from '../../localize/localize'; +import { isRecord, prettifyTitle } from '../../utils/basic'; + +/** + * Get an editor title for a camera, starting with the most likely to be + * useful attribute and working towards the least useful. The camera manager + * cannot provide a title here as it cannot start with an unparsed and + * unloaded configuration. + * @param index The index of the camera in the cameras array. + * @param cameraConfig The raw camera configuration. + * @param hass The HomeAssistant object. + * @returns A string title. + */ +export const getEditorCameraTitle = ( + index: number, + cameraConfig: unknown, + hass?: HomeAssistant, +): string => { + const config = isRecord(cameraConfig) ? cameraConfig : {}; + const webrtcCardEntity = isRecord(config.webrtc_card) + ? config.webrtc_card.entity + : null; + const frigateCameraName = isRecord(config.frigate) ? config.frigate.camera_name : null; + return ( + (typeof config.title === 'string' && config.title) || + (typeof config.camera_entity === 'string' + ? getEntityTitle(hass, config.camera_entity) + : '') || + (typeof webrtcCardEntity === 'string' && webrtcCardEntity) || + (typeof frigateCameraName === 'string' && frigateCameraName + ? prettifyTitle(frigateCameraName) + : '') || + (typeof config.id === 'string' && config.id) || + localize('editor.camera') + ' #' + index + ); +}; + +/** + * Get an editor title for a folder. + * @param index The index of the folder in the folders array. + * @param folderConfig The raw folder configuration. + * @returns A string title. + */ +export const getEditorFolderTitle = (index: number, folderConfig: unknown): string => { + const config = isRecord(folderConfig) ? folderConfig : {}; + return ( + (typeof config.title === 'string' && config.title) || + (typeof config.id === 'string' && config.id) || + localize('common.folder') + ' #' + index + ); +}; + +/** + * Get an editor title for a camera trigger event. + * @param index The index of the event in the events array. + * @param eventConfig The raw event configuration. + * @returns A string title. + */ +export const getEditorTriggerEventTitle = ( + index: number, + eventConfig: unknown, +): string => { + const config = isRecord(eventConfig) ? eventConfig : {}; + return ( + (typeof config.event_type === 'string' && config.event_type) || + localize('common.event') + ' #' + index + ); +}; diff --git a/src/components-lib/editor/types.ts b/src/components-lib/editor/types.ts new file mode 100644 index 00000000..5453e9bb --- /dev/null +++ b/src/components-lib/editor/types.ts @@ -0,0 +1,79 @@ +import type { RawAdvancedCameraCardConfig } from '../../config/types'; +import type { HAFormSchema } from '../../ha/types'; + +// The location of a value within a configuration. Numbers address array items. +export type ConfigPath = (string | number)[]; + +// A change to make to a configuration, at an absolute path. +export type ConfigChange = + | { path: ConfigPath; type: 'set'; value: unknown } + | { path: ConfigPath; type: 'delete' }; + +// Where a form field's value lives in the configuration, for the fields whose +// location is not simply their position within the form. +// +// A field is addressed by `formPath`, its path within the form's own data. By +// default that path is also its path within the configuration (below the form's +// base path), which is what the ordinary section forms rely on. A binding +// overrides that, either by naming a different configuration path, or by +// reading and writing the value itself for a field whose form representation +// differs from the way it is stored. +// +// `formPath` must name a field, not a group: a group's fields are bound +// individually. A `formPath` naming no field of the form binds nothing. +// +// A binding matters only for a form that gathers fields from across the +// configuration rather than editing one subtree; a form bound to a single +// subtree never needs one. +export type FieldBinding = PathFieldBinding | ComputedFieldBinding; + +// A field stored at a configuration path of its own, unchanged. +interface PathFieldBinding { + formPath: string[]; + configPath: ConfigPath; +} + +// A field whose form representation differs from the way it is stored, and +// which therefore reads and writes itself. +interface ComputedFieldBinding { + formPath: string[]; + + // The value to show for the field, given the whole configuration and the + // configuration defaults. + read: ( + config: RawAdvancedCameraCardConfig, + defaults: RawAdvancedCameraCardConfig, + ) => unknown; + + // The changes that storing a new value for the field represents. The + // defaults are given as well as the configuration so that a field returning + // to its default can be deleted rather than written out: one form field may + // stand for many configuration keys, and writing every one of them would + // fill the configuration with values the user never set. + write: ( + value: unknown, + config: RawAdvancedCameraCardConfig, + defaults: RawAdvancedCameraCardConfig, + ) => ConfigChange[]; +} + +export const isComputedFieldBinding = ( + binding: FieldBinding, +): binding is ComputedFieldBinding => 'read' in binding; + +// One `ha-form` for part of a section. A section splits into more than one of +// these when its fields live at different configuration paths, since each form +// binds to a single base path. For example, the timeline section uses one form +// for its top-level fields (`timeline.*`) and another for its thumbnail +// controls (`timeline.controls.thumbnails.*`). +export interface EditorForm { + // The path of this form's data within the configuration; empty if it edits + // the configuration root. + basePath: ConfigPath; + schema: HAFormSchema[]; + + // Fields that are not stored where their position in the form implies. A + // form gathering fields from across the configuration (rather than editing + // one subtree) uses these. + bindings?: FieldBinding[]; +} diff --git a/src/components-lib/icon-controller.ts b/src/components-lib/icon-controller.ts index 893134cd..be1a0f9e 100644 --- a/src/components-lib/icon-controller.ts +++ b/src/components-lib/icon-controller.ts @@ -1,29 +1,18 @@ import type { HassEntity } from 'home-assistant-js-websocket'; -import frigateSVG from '../camera-manager/frigate/assets/frigate.svg'; -import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg'; -import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg'; -import tplinkSVG from '../camera-manager/tplink/assets/tplink.svg'; import type { Icon } from '../config/schema/common/icon'; +import { CUSTOM_ICON_NAMES, CUSTOM_ICONSET_PREFIX } from '../ha/custom-icons'; import type { HomeAssistant } from '../ha/types'; -import irisSVG from '../images/iris.svg'; export class IconController { - public getCustomIcon(icon?: Icon): string | null { - switch (icon?.icon) { - case 'frigate': - return frigateSVG; - case 'motioneye': - return motioneyeSVG; - case 'reolink': - return reolinkSVG; - case 'tplink': - return tplinkSVG; - case 'iris': - return irisSVG; - default: - return null; - } + public getIconName(icon?: Icon): string | null { + const name = icon?.icon ?? null; + + // Bare legacy names (`frigate`) resolve to the same icon as the iconset + // form (`advanced-camera-card:frigate`). + return name && CUSTOM_ICON_NAMES.includes(name) + ? `${CUSTOM_ICONSET_PREFIX}:${name}` + : name; } public createStateObjectForStateBadge( diff --git a/src/components-lib/key-assigner-controller.ts b/src/components-lib/key-assigner-controller.ts index 341ecee6..9607c4a6 100644 --- a/src/components-lib/key-assigner-controller.ts +++ b/src/components-lib/key-assigner-controller.ts @@ -13,6 +13,23 @@ export class KeyAssignerController implements ReactiveController { this._host.addController(this); } + /** + * Show the shortcut the assigner has been given. Nothing is reported back: + * the value came from outside, and reporting it would have the owner store a + * shortcut the user never assigned. + * @param value The shortcut, or null when nothing is assigned. + */ + public showValue(value: KeyboardShortcut | null): void { + if (!isEqual(value, this._value)) { + this._value = value; + this._host.requestUpdate(); + } + } + + /** + * Assign a shortcut, as the user, and report it. + * @param value The shortcut, or null to unassign. + */ public setValue(value: KeyboardShortcut | null): void { if (!isEqual(value, this._value)) { this._value = value; diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 1b1ea84f..0b86c66f 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -144,7 +144,7 @@ export class MenuButtonController { private _getIrisButton(config: AdvancedCameraCardConfig): MenuItem { return { - icon: 'iris', + icon: 'advanced-camera-card:iris', ...config.menu.buttons.iris, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.menu.buttons.iris'), diff --git a/src/components-lib/timeline/controller.ts b/src/components-lib/timeline/controller.ts index c4d5d75a..d7eb79fc 100644 --- a/src/components-lib/timeline/controller.ts +++ b/src/components-lib/timeline/controller.ts @@ -25,7 +25,7 @@ import type { CameraConfig } from '../../config/schema/cameras'; import type { AdvancedCameraCardView } from '../../config/schema/common/const'; import type { ThumbnailsControlBaseConfig } from '../../config/schema/common/controls/thumbnails'; import type { - TimelineCoreConfig, + TimelineCoreComponentConfig, TimelinePanMode, } from '../../config/schema/common/controls/timeline'; import { configDefaults } from '../../config/schema/types'; @@ -67,7 +67,7 @@ interface TimelineControllerOptions { conditionStateManager?: ConditionStateManagerReadonlyInterface; foldersManager?: FoldersManager; viewItemManager?: ViewItemManager; - timelineConfig?: TimelineCoreConfig; + timelineConfig?: TimelineCoreComponentConfig; mini?: boolean; thumbnailConfig?: ThumbnailsControlBaseConfig; query?: UnifiedQuery; @@ -89,7 +89,7 @@ export class TimelineController { private _viewItemManager: ViewItemManager | null = null; private _viewManagerEpoch: ViewManagerEpoch | null = null; - private _timelineConfig: TimelineCoreConfig | null = null; + private _timelineConfig: TimelineCoreComponentConfig | null = null; private _mini = false; diff --git a/src/components/editor/cameras.ts b/src/components/editor/cameras.ts new file mode 100644 index 00000000..0bba5293 --- /dev/null +++ b/src/components/editor/cameras.ts @@ -0,0 +1,237 @@ +import { + html, + LitElement, + nothing, + unsafeCSS, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import type { FormsInput } from '../../components-lib/editor/forms-controller'; +import { ListFormsController } from '../../components-lib/editor/list-forms-controller'; +import { ListPagesController } from '../../components-lib/editor/list-pages-controller'; +import { + getEditorCameraTitle, + getEditorTriggerEventTitle, +} from '../../components-lib/editor/titles'; +import type { ConfigPath } from '../../components-lib/editor/types'; +import { CONF_CAMERAS } from '../../config/const'; +import type { HomeAssistant } from '../../ha/types'; +import { localize } from '../../localize/localize'; +import editorExpanderBodyStyle from '../../scss/editor-expander-body.scss'; +import { renderDocumentation } from './doc-link'; +import { renderForms } from './form'; + +import './../icon'; +import './list'; +import './page'; + +const CAMERAS_PATH: ConfigPath = [CONF_CAMERAS]; + +// The lists the user can drill into, named so a page holding more than one can +// tell which was entered. +const CAMERAS_LIST = 'cameras'; +const EVENTS_LIST = 'events'; + +// The cameras section's content: the list of cameras, and the editor for +// whichever camera (or trigger event within it) the user opened. One level is +// shown at a time, as Home Assistant's own editors do for the items of a list. +@customElement('advanced-camera-card-editor-cameras') +export class AdvancedCameraCardEditorCameras extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public input?: FormsInput; + + private _pagesController = new ListPagesController(this); + private _formsController = new ListFormsController(this, (path) => + renderDocumentation(path), + ); + + protected willUpdate(): void { + if (this.input) { + this._formsController.setInput(this.input); + } + } + + protected render(): TemplateResult { + const cameras = this._formsController.getList(CAMERAS_PATH); + const [camera, subpage] = this._pagesController.getPath(); + + // A page naming a camera that no longer exists (the configuration was + // edited elsewhere) shows the list instead. + if (camera === undefined || camera.index >= cameras.length) { + return this._renderList(cameras); + } + // A step below the camera names the sub-list it entered; today the only one + // is the events. The camera page stays mounted (hidden) while an event is + // edited, so its expanded panels are still expanded when the user comes + // back to it, rather than being rebuilt collapsed. + const event = subpage?.list === EVENTS_LIST ? subpage.index : null; + return html` + ${this._renderCamera(camera.index, event !== null)} + ${event !== null ? this._renderEvent(camera.index, event) : nothing} + `; + } + + private _renderList(cameras: readonly unknown[]): TemplateResult { + return html` + ({ + title: getEditorCameraTitle(index, camera, this.hass), + }))} + @advanced-camera-card:editor:list:item-edit=${( + ev: CustomEvent<{ index: number }>, + ) => this._pagesController.open(CAMERAS_LIST, ev.detail.index)} + @advanced-camera-card:editor:list:item-add=${() => { + this._formsController.addItem(CAMERAS_PATH, {}); + this._pagesController.open(CAMERAS_LIST, cameras.length); + }} + @advanced-camera-card:editor:list:item-move=${( + ev: CustomEvent<{ from: number; to: number }>, + ) => this._formsController.moveItem(CAMERAS_PATH, ev.detail.from, ev.detail.to)} + @advanced-camera-card:editor:list:item-delete=${( + ev: CustomEvent<{ index: number }>, + ) => this._formsController.deleteItem(CAMERAS_PATH, ev.detail.index)} + > + `; + } + + private _renderCamera(index: number, hidden: boolean): TemplateResult { + const camera = this._formsController.getList(CAMERAS_PATH)[index]; + const eventsPath: ConfigPath = [CONF_CAMERAS, index, 'triggers', 'events']; + const events = this._formsController.getList(eventsPath); + + return html` + this._pagesController.back()} + > + ${renderForms( + this.hass, + this._formsController.getFormContexts({ kind: 'camera', index }), + )} + + + ${this._renderContained(html` + ${renderDocumentation([CONF_CAMERAS, 'triggers'])} + ${renderForms( + this.hass, + this._formsController.getFormContexts({ + kind: 'camera-triggers', + cameraIndex: index, + }), + )} + ${this._renderEvents(eventsPath, events)} + `)} + + + `; + } + + // The Home Assistant events the triggers watch for, a group of their own + // within the triggers panel: they are one kind of trigger among the others, + // not a sibling of the whole trigger set. + private _renderEvents( + eventsPath: ConfigPath, + events: readonly unknown[], + ): TemplateResult { + return html` + + + ${this._renderContained(html` + ({ + title: getEditorTriggerEventTitle(eventIndex, event), + }))} + @advanced-camera-card:editor:list:item-edit=${( + ev: CustomEvent<{ index: number }>, + ) => this._pagesController.open(EVENTS_LIST, ev.detail.index)} + @advanced-camera-card:editor:list:item-add=${() => { + // An event filter must name an event type. The empty name the new + // item starts with matches nothing until the user fills it in. + this._formsController.addItem(eventsPath, { event_type: '' }); + this._pagesController.open(EVENTS_LIST, events.length); + }} + @advanced-camera-card:editor:list:item-move=${( + ev: CustomEvent<{ from: number; to: number }>, + ) => + this._formsController.moveItem(eventsPath, ev.detail.from, ev.detail.to)} + @advanced-camera-card:editor:list:item-delete=${( + ev: CustomEvent<{ index: number }>, + ) => this._formsController.deleteItem(eventsPath, ev.detail.index)} + > + `)} + + `; + } + + // A nested panel's expansion and transition events would otherwise reach the + // panel above and disturb the height it animates to, or collapse it. + private _renderContained(content: TemplateResult): TemplateResult { + return html` +
+ ${content} +
+ `; + } + + private _stopPropagation(ev: Event): void { + ev.stopPropagation(); + } + + private _renderEvent(cameraIndex: number, eventIndex: number): TemplateResult { + const eventsPath: ConfigPath = [CONF_CAMERAS, cameraIndex, 'triggers', 'events']; + const event = this._formsController.getList(eventsPath)[eventIndex]; + + return html` + this._pagesController.back()} + > + ${renderForms( + this.hass, + this._formsController.getFormContexts({ + kind: 'camera-event', + cameraIndex, + eventIndex, + }), + )} + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(editorExpanderBodyStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-editor-cameras': AdvancedCameraCardEditorCameras; + } +} diff --git a/src/components/editor/doc-link.ts b/src/components/editor/doc-link.ts new file mode 100644 index 00000000..b7e90b56 --- /dev/null +++ b/src/components/editor/doc-link.ts @@ -0,0 +1,66 @@ +import { + html, + LitElement, + unsafeCSS, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { getDocURL } from '../../components-lib/editor/doc-links'; +import { localize } from '../../localize/localize'; +import editorDocLinkStyle from '../../scss/editor-doc-link.scss'; + +// A self-styled documentation link row. Carries its own styles so it renders +// correctly when passed into the shadow DOM of Home Assistant elements (e.g. as +// an `ha-form` expandable description), where the editor's stylesheet cannot +// reach. +@customElement('advanced-camera-card-editor-doc-link') +export class AdvancedCameraCardEditorDocLink extends LitElement { + @property({ attribute: false }) + public url?: string; + + protected render(): TemplateResult | void { + if (!this.url) { + return; + } + return html` + + +
${localize('editor.docs')}
+ +
+ `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(editorDocLinkStyle); + } +} + +/** + * Render the documentation link for a configuration path. + * @param path The configuration path. + * @returns A rendered template, or null when the path has no documentation. + */ +export const renderDocumentation = ( + path: (string | number)[], +): TemplateResult | null => { + const url = getDocURL(path); + return url + ? html`` + : null; +}; + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-editor-doc-link': AdvancedCameraCardEditorDocLink; + } +} diff --git a/src/components/editor/folders.ts b/src/components/editor/folders.ts new file mode 100644 index 00000000..5203e676 --- /dev/null +++ b/src/components/editor/folders.ts @@ -0,0 +1,109 @@ +import { + html, + LitElement, + unsafeCSS, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import type { FormsInput } from '../../components-lib/editor/forms-controller'; +import { ListFormsController } from '../../components-lib/editor/list-forms-controller'; +import { ListPagesController } from '../../components-lib/editor/list-pages-controller'; +import { getEditorFolderTitle } from '../../components-lib/editor/titles'; +import type { ConfigPath } from '../../components-lib/editor/types'; +import { CONF_FOLDERS } from '../../config/const'; +import type { HomeAssistant } from '../../ha/types'; +import { localize } from '../../localize/localize'; +import editorExpanderBodyStyle from '../../scss/editor-expander-body.scss'; +import { renderDocumentation } from './doc-link'; +import { renderForms } from './form'; + +import './../icon'; +import './list'; +import './page'; + +const FOLDERS_PATH: ConfigPath = [CONF_FOLDERS]; + +// The single list this section drills into. +const FOLDERS_LIST = 'folders'; + +// The folders section's content: the list of folders, each with its own form. +@customElement('advanced-camera-card-editor-folders') +export class AdvancedCameraCardEditorFolders extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public input?: FormsInput; + + private _pages = new ListPagesController(this); + + private _controller = new ListFormsController(this, (path) => + renderDocumentation(path), + ); + + protected willUpdate(): void { + if (this.input) { + this._controller.setInput(this.input); + } + } + + protected render(): TemplateResult { + const folders = this._controller.getList(FOLDERS_PATH); + const [folder] = this._pages.getPath(); + + // A page naming a folder that no longer exists (the configuration was + // edited elsewhere) shows the list instead. + if (folder !== undefined && folder.index < folders.length) { + const index = folder.index; + return html` + this._pages.back()} + > + ${renderForms( + this.hass, + this._controller.getFormContexts({ kind: 'folder', index }), + )} + + ${localize('config.folders.ha.path_info')} + + + `; + } + + return html` + ({ + title: getEditorFolderTitle(folderIndex, folder), + }))} + @advanced-camera-card:editor:list:item-edit=${( + ev: CustomEvent<{ index: number }>, + ) => this._pages.open(FOLDERS_LIST, ev.detail.index)} + @advanced-camera-card:editor:list:item-add=${() => { + this._controller.addItem(FOLDERS_PATH, {}); + this._pages.open(FOLDERS_LIST, folders.length); + }} + @advanced-camera-card:editor:list:item-move=${( + ev: CustomEvent<{ from: number; to: number }>, + ) => this._controller.moveItem(FOLDERS_PATH, ev.detail.from, ev.detail.to)} + @advanced-camera-card:editor:list:item-delete=${( + ev: CustomEvent<{ index: number }>, + ) => this._controller.deleteItem(FOLDERS_PATH, ev.detail.index)} + > + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(editorExpanderBodyStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-editor-folders': AdvancedCameraCardEditorFolders; + } +} diff --git a/src/components/editor/form.ts b/src/components/editor/form.ts new file mode 100644 index 00000000..11b60411 --- /dev/null +++ b/src/components/editor/form.ts @@ -0,0 +1,30 @@ +import { html, type TemplateResult } from 'lit'; + +import type { FormContext } from '../../components-lib/editor/forms-controller'; +import type { HomeAssistant } from '../../ha/types'; + +/** + * Render the `ha-form`s of a set of form contexts. Each context is passed + * through as it stands: `ha-form` compares its inputs by identity, so + * substituting an equivalent object for any of them re-renders every field. + * @param hass The HomeAssistant object. + * @param contexts The contexts to render. + * @returns A rendered template. + */ +export const renderForms = ( + hass: HomeAssistant | undefined, + contexts: FormContext[], +): TemplateResult => { + return html`${contexts.map( + (context) => html` + + `, + )}`; +}; diff --git a/src/components/editor/keyboard-shortcuts.ts b/src/components/editor/keyboard-shortcuts.ts new file mode 100644 index 00000000..af2459f5 --- /dev/null +++ b/src/components/editor/keyboard-shortcuts.ts @@ -0,0 +1,86 @@ +import { + html, + LitElement, + unsafeCSS, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { getLocalizationKeyForPath } from '../../components-lib/editor/form-labels.js'; +import type { FormsInput } from '../../components-lib/editor/forms-controller.js'; +import { KeyboardShortcutsController } from '../../components-lib/editor/keyboard-shortcuts-controller.js'; +import { CONF_VIEW_KEYBOARD_SHORTCUTS } from '../../config/const.js'; +import type { KeyboardShortcut } from '../../config/schema/view.js'; +import type { HomeAssistant } from '../../ha/types.js'; +import { localize } from '../../localize/localize.js'; +import editorKeyboardShortcutsStyle from '../../scss/editor-keyboard-shortcuts.scss'; +import { renderDocumentation } from './doc-link.js'; +import { renderForms } from './form.js'; + +import './../icon'; +import './../key-assigner'; + +// The keyboard shortcuts panel. Hand-built rather than an `ha-form` expandable +// because a shortcut is assigned by pressing a key, which no selector can +// express. +@customElement('advanced-camera-card-editor-keyboard-shortcuts') +export class AdvancedCameraCardEditorKeyboardShortcuts extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public input?: FormsInput; + + private _controller = new KeyboardShortcutsController(this, (path) => + renderDocumentation(path), + ); + + protected willUpdate(): void { + if (this.input) { + this._controller.setInput(this.input); + } + } + + protected render(): TemplateResult { + return html` + + + ${localize('config.view.keyboard_shortcuts.editor_label')} +
ev.stopPropagation()}> + ${renderDocumentation(CONF_VIEW_KEYBOARD_SHORTCUTS.split('.'))} + ${renderForms(this.hass, this._controller.getContexts())} + ${Object.keys(this._controller.getShortcuts()).map((name) => + this._renderKeyAssigner(name), + )} +
+
+ `; + } + + private _renderKeyAssigner(name: string): TemplateResult { + return html`) => + this._controller.setShortcut(name, ev.detail.value)} + >`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(editorKeyboardShortcutsStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-editor-keyboard-shortcuts': AdvancedCameraCardEditorKeyboardShortcuts; + } +} diff --git a/src/components/editor/list.ts b/src/components/editor/list.ts new file mode 100644 index 00000000..0debd902 --- /dev/null +++ b/src/components/editor/list.ts @@ -0,0 +1,136 @@ +import { + html, + LitElement, + nothing, + unsafeCSS, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { localize } from '../../localize/localize'; +import editorListStyle from '../../scss/editor-list.scss'; +import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event'; + +import './../icon'; + +// One item of the list: what to call it, and a line below that naming what it +// refers to where the title alone does not say. +export interface EditorListItem { + title: string; + description?: string; +} + +// A list of configuration items, rendered as the rows Home Assistant uses for +// the editable lists in its own editors. An item is opened with the row's edit +// control, which reports `item-edit` for the owner to show that item's editor +// in place of the list. Reordering is done by dragging a row's handle. +// +// The events do not bubble beyond the owner: they are composed only as far as +// the list's own host, since the editor above interprets nothing between here +// and itself. +@customElement('advanced-camera-card-editor-list') +export class AdvancedCameraCardEditorList extends LitElement { + // The items, in list order. Their configuration is not passed: the owner + // renders each item's editor when asked. + @property({ attribute: false }) + public items: EditorListItem[] = []; + + @property() + public itemIcon?: string; + + @property() + public addLabel?: string; + + protected render(): TemplateResult { + return html` + ) => { + ev.stopPropagation(); + this._fire('item-move', { from: ev.detail.oldIndex, to: ev.detail.newIndex }); + }} + > + + ${this.items.map((item, index) => this._renderItem(item, index))} + + + ${this._renderAdd()} + `; + } + + private _renderItem(item: EditorListItem, index: number): TemplateResult { + return html` + + + +
${item.title}
+ ${item.description + ? html`
+ ${item.description} +
` + : nothing} + ${this._renderControl('mdi:pencil', localize('editor.edit'), () => + this._fire('item-edit', { index }), + )} + ${this._renderControl('mdi:delete', localize('editor.delete'), () => + this._fire('item-delete', { index }), + )} +
+ `; + } + + private _renderAdd(): TemplateResult { + return html` + this._fire('item-add', {})} + > + + ${this.addLabel} + + `; + } + + private _renderControl( + icon: string, + label: string, + action: () => void, + ): TemplateResult { + return html` + + + + `; + } + + private _fire(name: string, detail: Record): void { + fireAdvancedCameraCardEvent(this, `editor:list:${name}`, detail, { + bubbles: true, + composed: false, + }); + } + + static get styles(): CSSResultGroup { + return unsafeCSS(editorListStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-editor-list': AdvancedCameraCardEditorList; + } +} diff --git a/src/components/editor/page.ts b/src/components/editor/page.ts new file mode 100644 index 00000000..04614154 --- /dev/null +++ b/src/components/editor/page.ts @@ -0,0 +1,60 @@ +import { + html, + LitElement, + unsafeCSS, + type CSSResultGroup, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { localize } from '../../localize/localize'; +import editorPageStyle from '../../scss/editor-page.scss'; +import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event'; + +// The editor for one item of a list, shown in place of the list itself: a +// heading naming the item, a control to go back to the list, and the item's +// own content. +@customElement('advanced-camera-card-editor-page') +export class AdvancedCameraCardEditorPage extends LitElement { + @property() + public heading?: string; + + protected updated(changedProps: PropertyValues): void { + // Opening an item replaces a list the user may have scrolled down into, so + // the new page can begin above the viewport. Bring its top into view. The + // heading changes on the first render and on each further drill-in, which + // is exactly when a fresh page has been shown. The scroll waits a frame so + // the replaced content has been laid out and the target position is final. + if (changedProps.has('heading')) { + requestAnimationFrame(() => this.scrollIntoView({ block: 'start' })); + } + } + + protected render(): TemplateResult { + return html` +
+ + fireAdvancedCameraCardEvent(this, 'editor:page:back', undefined, { + bubbles: true, + composed: false, + })} + > + ${this.heading} +
+ + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(editorPageStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-editor-page': AdvancedCameraCardEditorPage; + } +} diff --git a/src/components/editor/section.ts b/src/components/editor/section.ts new file mode 100644 index 00000000..78c8bf65 --- /dev/null +++ b/src/components/editor/section.ts @@ -0,0 +1,123 @@ +import { + html, + LitElement, + nothing, + unsafeCSS, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import type { + FormContext, + FormsInput, +} from '../../components-lib/editor/forms-controller'; +import type { FormRequest } from '../../components-lib/editor/schema/registry'; +import { SectionController } from '../../components-lib/editor/section-controller'; +import type { HomeAssistant } from '../../ha/types'; +import editorSectionStyle from '../../scss/editor-section.scss'; +import { renderDocumentation } from './doc-link'; +import { renderForms } from './form'; + +import './../icon'; + +// One top-level section of the editor: a panel that shows the forms of its part +// of the configuration, and optionally extra custom content. +@customElement('advanced-camera-card-editor-section') +export class AdvancedCameraCardEditorSection extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public request?: FormRequest; + + @property({ attribute: false }) + public input?: FormsInput; + + @property() + public icon?: string; + + @property() + public heading?: string; + + // The line shown under the heading, describing what the section covers. + @property() + public description?: string; + + // The path whose documentation the section links to; the section's own forms + // supply the links for everything within them. + @property({ attribute: false }) + public documentationPath?: (string | number)[]; + + // Content shown after the section's schema forms, of which there may be + // none. Called only once the section has been opened, and in the render that + // opens it, so that the panel measures a height that includes it. + @property({ attribute: false }) + public renderCustomContent?: () => TemplateResult; + + private _controller = new SectionController(this, (path) => renderDocumentation(path)); + + protected willUpdate(): void { + if (this.request && this.input) { + this._controller.setInput(this.request, this.input); + } + } + + protected render(): TemplateResult { + return html` + ) => { + // Panels nested in the body emit the same event, and it crosses + // shadow boundaries; only this panel's own toggles count. + if (ev.target === ev.currentTarget) { + this._controller.setOpen(ev.detail.expanded); + } + }} + > + + ${this._controller.wasEverOpened() ? this._renderBody() : nothing} + + `; + } + + private _renderBody(): TemplateResult { + // Nested panels' expansion and transition events would otherwise reach the + // panel above and disturb the height it animates to, or collapse it. + return html` +
+ ${this.documentationPath ? renderDocumentation(this.documentationPath) : nothing} + ${renderForms(this.hass, this._controller.getContexts())} + ${this.renderCustomContent?.() ?? nothing} +
+ `; + } + + private _stopPropagation(ev: Event): void { + ev.stopPropagation(); + } + + public getContexts(): FormContext[] { + return this._controller.getContexts(); + } + + static get styles(): CSSResultGroup { + return unsafeCSS(editorSectionStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-editor-section': AdvancedCameraCardEditorSection; + } +} diff --git a/src/components/effects/check.ts b/src/components/effects/check.ts index ea066424..11ceea66 100644 --- a/src/components/effects/check.ts +++ b/src/components/effects/check.ts @@ -1,8 +1,7 @@ import { html, unsafeCSS, type CSSResultGroup, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -import checkCircleSVG from '../../images/check-circle.svg'; +import checkCircleIcon from '../../images/check-circle.svg'; import checkStyle from '../../scss/check.scss'; import { BaseEffectComponent } from './base'; @@ -10,7 +9,11 @@ import { BaseEffectComponent } from './base'; export class AdvancedCameraCardEffectCheck extends BaseEffectComponent { protected render(): TemplateResult { // Using inline SVG to avoid ha-icon lazy-loading delay on first use. - return html`${unsafeHTML(checkCircleSVG)}`; + return html` + + + + `; } static get styles(): CSSResultGroup { diff --git a/src/components/icon.ts b/src/components/icon.ts index 5803d911..733e202b 100644 --- a/src/components/icon.ts +++ b/src/components/icon.ts @@ -3,7 +3,6 @@ import { LitElement, unsafeCSS, type CSSResultGroup, - type PropertyValues, type TemplateResult, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @@ -31,27 +30,9 @@ export class AdvancedCameraCardIcon extends LitElement { public allowOverrideNonActiveStyles = false; private _controller = new IconController(); - private _svg: HTMLElement | null = null; - - protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('icon')) { - const customIcon = this._controller.getCustomIcon(this.icon); - if (customIcon) { - const svgElement = document.createElement('svg'); - svgElement.innerHTML = customIcon; - this._svg = svgElement; - } else { - this._svg = null; - } - } - } protected render(): TemplateResult { - if (this._svg) { - // Use SVG objects (rather than ) to ensure styling applies - // correctly. - return html`${this._svg}`; - } + const iconName = this._controller.getIconName(this.icon); if (this.hass && this.icon?.entity) { const stateObj = this._controller.createStateObjectForStateBadge( this.hass, @@ -65,12 +46,12 @@ export class AdvancedCameraCardIcon extends LitElement { .stateColor=${this.icon.stateColor ?? true} .hass=${this.hass} .stateObj=${stateObj} - .overrideIcon=${this.icon.icon} + .overrideIcon=${iconName ?? undefined} >`; } } - if (this.icon?.icon) { - return html``; + if (iconName) { + return html``; } if (this.icon?.fallback) { return html``; diff --git a/src/components/key-assigner.ts b/src/components/key-assigner.ts index 7b3c7364..765834fc 100644 --- a/src/components/key-assigner.ts +++ b/src/components/key-assigner.ts @@ -27,7 +27,7 @@ export class AdvancedCameraCardKeyAssigner extends LitElement { protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('value')) { - this._controller.setValue(this.value ?? null); + this._controller.showValue(this.value ?? null); } } @@ -44,6 +44,7 @@ export class AdvancedCameraCardKeyAssigner extends LitElement { return html`
${this.label}
+
` : '' } +
${this.value?.ctrl ? renderKey(localize('key_assigner.modifiers.ctrl')) : ''} ${this.value?.shift ? renderKey(localize('key_assigner.modifiers.shift')) : ''} diff --git a/src/components/loading.ts b/src/components/loading.ts index 06883cce..75ecda9e 100644 --- a/src/components/loading.ts +++ b/src/components/loading.ts @@ -81,7 +81,7 @@ export class AdvancedCameraCardLoading extends LitElement { protected render(): TemplateResult { return html`${getReleaseVersion()}`; } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 1f4aa7f4..a2f0cb78 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -22,7 +22,7 @@ import { } from '../components-lib/timeline/types'; import type { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types'; import type { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails'; -import type { TimelineCoreConfig } from '../config/schema/common/controls/timeline'; +import type { TimelineCoreComponentConfig } from '../config/schema/common/controls/timeline'; import type { CardWideConfig } from '../config/schema/types'; import type { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize'; @@ -115,7 +115,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement { public viewManagerEpoch?: ViewManagerEpoch; @property({ attribute: false, hasChanged: contentsChanged }) - public timelineConfig?: TimelineCoreConfig; + public timelineConfig?: TimelineCoreComponentConfig; @property({ attribute: false }) public thumbnailConfig?: ThumbnailsControlBaseConfig; diff --git a/src/config/const.ts b/src/config/const.ts new file mode 100644 index 00000000..87403bcc --- /dev/null +++ b/src/config/const.ts @@ -0,0 +1,159 @@ +// A single namespace shadowing the main config: anything the upgrade cannot +// faithfully convert is recorded here as a failure, intact, at the same path it +// came from (e.g. top-level `automations` -> `__UPGRADE_FAILURE__.automations`) +// for the user to migrate by hand. +export const CONF_UPGRADE_FAILURE = '__UPGRADE_FAILURE__' as const; + +// =========================================================================== +// Configuration path constants +// =========================================================================== + +export const CONF_AUTOMATIONS = 'automations' as const; + +export const CONF_CAMERAS = 'cameras' as const; + +const CONF_CAMERAS_GLOBAL = 'cameras_global' as const; +export const CONF_CAMERAS_GLOBAL_LIVE_PROVIDER = + `${CONF_CAMERAS_GLOBAL}.live_provider` as const; +export const CONF_CAMERAS_GLOBAL_TRIGGERS_DOORBELL = + `${CONF_CAMERAS_GLOBAL}.triggers.doorbell` as const; +export const CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS = + `${CONF_CAMERAS_GLOBAL}.image.refresh_seconds` as const; +export const CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT = + `${CONF_CAMERAS_GLOBAL}.dimensions.layout` as const; +export const CONF_CAMERAS_GLOBAL_PTZ = `${CONF_CAMERAS_GLOBAL}.ptz` as const; + +export const CONF_ELEMENTS = 'elements' as const; + +export const CONF_FOLDERS = 'folders' as const; + +const CONF_VIEW = 'view' as const; +export const CONF_VIEW_DIM = `${CONF_VIEW}.dim` as const; +export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const; +export const CONF_VIEW_DEFAULT_CYCLE_CAMERA = + `${CONF_VIEW}.default_cycle_camera` as const; +const CONF_VIEW_DEFAULT_RESET = `${CONF_VIEW}.default_reset` as const; +export const CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE = + `${CONF_VIEW_DEFAULT_RESET}.interaction_mode` as const; +export const CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS = + `${CONF_VIEW_DEFAULT_RESET}.every_seconds` as const; +export const CONF_VIEW_DEFAULT_RESET_ENTITIES = + `${CONF_VIEW_DEFAULT_RESET}.entities` as const; +export const CONF_VIEW_KEYBOARD_SHORTCUTS = `${CONF_VIEW}.keyboard_shortcuts` as const; +export const CONF_VIEW_TRIGGERS = `${CONF_VIEW}.triggers` as const; +export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS = + `${CONF_VIEW_TRIGGERS}.show_trigger_status` as const; +export const CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA = + `${CONF_VIEW_TRIGGERS}.filter_selected_camera` as const; +export const CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS = + `${CONF_VIEW_TRIGGERS}.untrigger_delay_seconds` as const; +const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const; +export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE = + `${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const; +export const CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER = + `${CONF_VIEW_TRIGGERS_ACTIONS}.trigger` as const; +export const CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER = + `${CONF_VIEW_TRIGGERS_ACTIONS}.untrigger` as const; + +const CONF_MEDIA_GALLERY = 'media_gallery' as const; +export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = + `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_details` as const; +export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = + `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_favorite_control` as const; + +const CONF_MEDIA_VIEWER = 'media_viewer' as const; +export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const; +export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const; +export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const; +export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const; +export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP = + `${CONF_MEDIA_VIEWER}.snapshot_click_plays_clip` as const; +export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT = + `${CONF_MEDIA_VIEWER}.transition_effect` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_BUILTIN = + `${CONF_MEDIA_VIEWER}.controls.builtin` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE = + `${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const; + +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE = + `${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE = + `${CONF_MEDIA_VIEWER}.controls.timeline.pan_mode` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS = + `${CONF_MEDIA_VIEWER}.controls.timeline.show_recordings` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE = + `${CONF_MEDIA_VIEWER}.controls.timeline.style` as const; + +const CONF_LIVE = 'live' as const; +export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const; +export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const; +export const CONF_LIVE_CONTROLS_BUILTIN = `${CONF_LIVE}.controls.builtin` as const; + +export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE = + `${CONF_LIVE}.controls.thumbnails.mode` as const; +export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS = + `${CONF_LIVE}.controls.thumbnails.show_details` as const; +export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = + `${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_MODE = + `${CONF_LIVE}.controls.timeline.mode` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE = + `${CONF_LIVE}.controls.timeline.pan_mode` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS = + `${CONF_LIVE}.controls.timeline.show_recordings` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_STYLE = + `${CONF_LIVE}.controls.timeline.style` as const; +export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const; +export const CONF_LIVE_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const; +export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const; +export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD = + `${CONF_LIVE}.show_image_during_load` as const; + +const CONF_TIMELINE = 'timeline' as const; +export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = + `${CONF_TIMELINE}.controls.thumbnails.mode` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS = + `${CONF_TIMELINE}.controls.thumbnails.show_details` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = + `${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const; + +const CONF_MENU = 'menu' as const; +export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const; +const CONF_MENU_BUTTONS = `${CONF_MENU}.buttons` as const; + +export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU_BUTTONS}.fullscreen` as const; +export const CONF_MENU_BUTTONS_IRIS = `${CONF_MENU_BUTTONS}.iris` as const; +export const CONF_MENU_BUTTONS_PLAY = `${CONF_MENU_BUTTONS}.play` as const; +export const CONF_MENU_BUTTONS_MUTE = `${CONF_MENU_BUTTONS}.mute` as const; +export const CONF_MENU_BUTTONS_MEDIA_PLAYER = + `${CONF_MENU_BUTTONS}.media_player` as const; +export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU_BUTTONS}.timeline` as const; + +export const CONF_STATUS_BAR = 'status_bar' as const; +export const CONF_STATUS_BAR_STYLE = `${CONF_STATUS_BAR}.style` as const; + +const CONF_DIMENSIONS = 'dimensions' as const; +export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const; +export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = + `${CONF_DIMENSIONS}.aspect_ratio_mode` as const; +export const CONF_DIMENSIONS_HEIGHT = `${CONF_DIMENSIONS}.height` as const; + +export const CONF_OVERRIDES = 'overrides' as const; + +const CONF_PERFORMANCE = 'performance' as const; +export const CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR = `${CONF_PERFORMANCE}.features.animated_progress_indicator`; +export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS = `${CONF_PERFORMANCE}.features.card_loading_effects`; +export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR = `${CONF_PERFORMANCE}.features.card_loading_indicator`; +export const CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE = `${CONF_PERFORMANCE}.features.media_chunk_size`; +export const CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS = `${CONF_PERFORMANCE}.features.max_simultaneous_engine_requests`; +export const CONF_PERFORMANCE_STYLE_BOX_SHADOW = `${CONF_PERFORMANCE}.style.box_shadow`; +export const CONF_PERFORMANCE_STYLE_BORDER_RADIUS = `${CONF_PERFORMANCE}.style.border_radius`; + +export const CONF_PROFILES = 'profiles' as const; diff --git a/src/config/management.ts b/src/config/management.ts index a515719e..44d595d7 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -1,5 +1,6 @@ import { cloneDeep, get, isEqual, set, unset } from 'lodash-es'; +import { arrayify, arrayMove, isRecord } from '../utils/basic'; import { CONF_AUTOMATIONS, CONF_CAMERAS, @@ -22,8 +23,7 @@ import { CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, -} from '../const'; -import { arrayify, isRecord } from '../utils/basic'; +} from './const'; import type { Condition } from './schema/condition-trigger/conditions/types'; import type { RawAdvancedCameraCardConfig, @@ -70,7 +70,7 @@ export const getConfigValue = ( */ export const deleteConfigValue = ( obj: RawAdvancedCameraCardConfig, - path: string, + path: string | (string | number)[], ): void => { unset(obj, path); }; @@ -84,16 +84,89 @@ export const copyConfig = (obj: T): T => { return cloneDeep(obj); }; -/** - * Given an array path, return a true path. - * @param path The array path (should have a '#'). - * @param index The numeric array index to use. - * @returns The true config path. - */ -export const getArrayConfigPath = (path: string, index: number): string => { - return path.replace('#', `[${index.toString()}]`); +// Apply an in-place mutation to the array at `arrayPath`, returning whether the +// mutation reported a change. +const mutateConfigArray = ( + obj: RawAdvancedCameraCardConfig, + arrayPath: string | (string | number)[], + mutator: (array: unknown[]) => boolean, +): boolean => { + const array = getConfigValue(obj, arrayPath); + return Array.isArray(array) && mutator(array); }; +/** + * Append an item to a configuration array. The array is created when the + * configuration does not have one at that path yet. + * @param obj The configuration to modify. + * @param arrayPath The configuration path of the array. + * @param item The item to append. + * @returns `true` if the configuration was modified. + */ +export const addConfigArrayItem = ( + obj: RawAdvancedCameraCardConfig, + arrayPath: string | (string | number)[], + item: unknown, +): boolean => { + const array = getConfigValue(obj, arrayPath); + if (!Array.isArray(array)) { + setConfigValue(obj, arrayPath, [item]); + return true; + } + array.push(item); + return true; +}; + +/** + * Move an item within a configuration array. + * @param obj The configuration to modify. + * @param arrayPath The configuration path of the array. + * @param fromIndex The index of the item to move. + * @param toIndex The index to move the item to. + * @returns `true` if the configuration was modified. + */ +export const moveConfigArrayItem = ( + obj: RawAdvancedCameraCardConfig, + arrayPath: string | (string | number)[], + fromIndex: number, + toIndex: number, +): boolean => + mutateConfigArray(obj, arrayPath, (array) => { + if ( + !Number.isInteger(fromIndex) || + !Number.isInteger(toIndex) || + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= array.length || + toIndex >= array.length + ) { + return false; + } + arrayMove(array, fromIndex, toIndex); + return true; + }); + +/** + * Delete an item from a configuration array. + * @param obj The configuration to modify. + * @param arrayPath The configuration path of the array. + * @param index The index of the item to delete. + * @returns `true` if the configuration was modified. + */ +export const deleteConfigArrayItem = ( + obj: RawAdvancedCameraCardConfig, + arrayPath: string | (string | number)[], + index: number, +): boolean => + mutateConfigArray(obj, arrayPath, (array) => { + if (!Number.isInteger(index) || index < 0 || index >= array.length) { + return false; + } + array.splice(index, 1); + return true; + }); + // ************************************************************************* // Upgrade Related Functions // ************************************************************************* @@ -1424,16 +1497,24 @@ const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown = // Legacy `triggers.events: string[]` (Frigate engine media-availability filter) // was renamed to `triggers.media_events` to free up `triggers.events` for the // new HA-bus-event trigger list (object shape). Distinguish old from new by -// element type: an all-string array is legacy; any non-string element marks -// the new shape and must not be touched. If `media_events` already exists we -// refuse to overwrite it -- but we still drop the legacy `events` (otherwise -// it would fail the new schema, which expects objects). +// element type: an array holding at least one string is legacy; any non-string +// element marks the new shape and must not be touched. An empty list says +// nothing about which of the two it is, and is valid under the new schema, so +// it is left alone -- a user who deletes their last event trigger is not +// offered an upgrade that would rename the key underneath them. If +// `media_events` already exists we refuse to overwrite it -- but we still drop +// the legacy `events` (otherwise it would fail the new schema, which expects +// objects). const triggersEventsToMediaEventsTransform = (triggers: unknown): unknown => { if (!isRecord(triggers)) { return undefined; } const events = triggers['events']; - if (!Array.isArray(events) || events.some((x) => typeof x !== 'string')) { + if ( + !Array.isArray(events) || + !events.length || + events.some((x) => typeof x !== 'string') + ) { return undefined; } const result = { ...triggers }; diff --git a/src/config/profiles/casting.ts b/src/config/profiles/casting.ts index 6eaaa396..70f1f5d9 100644 --- a/src/config/profiles/casting.ts +++ b/src/config/profiles/casting.ts @@ -10,7 +10,7 @@ import { CONF_MENU_BUTTONS_MEDIA_PLAYER, CONF_MENU_BUTTONS_MUTE, CONF_MENU_BUTTONS_PLAY, -} from '../../const.js'; +} from '../const.js'; export const CASTING_PROFILE = { [CONF_LIVE_CONTROLS_BUILTIN]: false, diff --git a/src/config/profiles/doorbell.ts b/src/config/profiles/doorbell.ts index 3f546ddf..b81d960b 100644 --- a/src/config/profiles/doorbell.ts +++ b/src/config/profiles/doorbell.ts @@ -4,7 +4,7 @@ import { CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, -} from '../../const.js'; +} from '../const.js'; export const DOORBELL_PROFILE = { // Auto-discover HA `event.*` entities with `device_class: doorbell` on each diff --git a/src/config/profiles/low-performance.ts b/src/config/profiles/low-performance.ts index 61040162..7eb6d04c 100644 --- a/src/config/profiles/low-performance.ts +++ b/src/config/profiles/low-performance.ts @@ -40,7 +40,7 @@ import { CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_TIMELINE_SHOW_RECORDINGS, CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, -} from '../../const.js'; +} from '../const.js'; export const LOW_PERFORMANCE_PROFILE = { // Disable thumbnail carousels. diff --git a/src/config/profiles/scrubbing.ts b/src/config/profiles/scrubbing.ts index b66fe0f7..9508732b 100644 --- a/src/config/profiles/scrubbing.ts +++ b/src/config/profiles/scrubbing.ts @@ -5,7 +5,7 @@ import { CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, -} from '../../const.js'; +} from '../const.js'; export const SCRUBBING_PROFILE = { [CONF_LIVE_CONTROLS_TIMELINE_MODE]: 'below' as const, diff --git a/src/config/schema/common/controls/timeline.ts b/src/config/schema/common/controls/timeline.ts index d7377991..26d90a02 100644 --- a/src/config/schema/common/controls/timeline.ts +++ b/src/config/schema/common/controls/timeline.ts @@ -5,7 +5,6 @@ export const timelineCoreConfigDefault = { window_seconds: 60 * 60, show_recordings: true, style: 'stack' as const, - pan_mode: 'pan' as const, format: { '24h': true, }, @@ -34,21 +33,31 @@ export const timelineCoreConfigSchema = z.object({ .optional() .default(timelineCoreConfigDefault.show_recordings), style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style), - pan_mode: timelinePanModeSchema.optional().default(timelineCoreConfigDefault.pan_mode), format: timelineFormatSchema.optional().default(timelineCoreConfigDefault.format), }); -export type TimelineCoreConfig = z.infer; +type TimelineCoreConfig = z.infer; export const miniTimelineConfigDefault = { ...timelineCoreConfigDefault, mode: 'none' as const, + // The non-`pan` modes seek within playing media, so `pan_mode` only applies + // to a mini timeline shown alongside media. + pan_mode: 'pan' as const, + // Mini-timeline defaults to ribbon style. style: 'ribbon' as const, }; export const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({ mode: z.enum(['none', 'above', 'below']).default(miniTimelineConfigDefault.mode), + pan_mode: timelinePanModeSchema.optional().default(miniTimelineConfigDefault.pan_mode), style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style), }); export type MiniTimelineControlConfig = z.infer; + +// The config consumed by the shared `timeline-core` component: the core fields, +// plus the mini-only extras (`pan_mode` etc.) made optional since the full +// timeline omits them. +export type TimelineCoreComponentConfig = TimelineCoreConfig & + Partial; diff --git a/src/config/schema/performance.ts b/src/config/schema/performance.ts index 67314208..988716fd 100644 --- a/src/config/schema/performance.ts +++ b/src/config/schema/performance.ts @@ -1,6 +1,9 @@ import { z } from 'zod'; -import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../../const'; +import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const'; + +// The largest media fetch chunk size the configuration accepts. +export const MEDIA_CHUNK_SIZE_MAX = 1000; export const performanceConfigDefault = { features: { diff --git a/src/config/schema/view.ts b/src/config/schema/view.ts index 1437e02f..b6f36c48 100644 --- a/src/config/schema/view.ts +++ b/src/config/schema/view.ts @@ -37,14 +37,17 @@ const keyboardShortcutsSchema = z.object({ }); export type KeyboardShortcuts = z.infer; -export type PTZKeyboardShortcutName = - | 'ptz_down' - | 'ptz_home' - | 'ptz_left' - | 'ptz_right' - | 'ptz_up' - | 'ptz_zoom_in' - | 'ptz_zoom_out'; +export type PTZKeyboardShortcutName = Exclude; + +export const PTZ_KEYBOARD_SHORTCUTS: readonly PTZKeyboardShortcutName[] = [ + 'ptz_left', + 'ptz_right', + 'ptz_up', + 'ptz_down', + 'ptz_zoom_in', + 'ptz_zoom_out', + 'ptz_home', +]; const interactionModeDefault = 'inactive' as const; diff --git a/src/const.ts b/src/const.ts index 1795ef48..5fdd846b 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,5 +1,10 @@ +// =========================================================================== +// Top level Advanced Camera Card Constants +// =========================================================================== + export const REPO_URL = 'https://github.com/dermotduffy/advanced-camera-card' as const; export const DOCS_URL = 'https://card.camera' as const; + export const TROUBLESHOOTING_URL = `${DOCS_URL}/#/troubleshooting` as const; export const TROUBLESHOOTING_CONFIG_UPGRADE_URL = `${TROUBLESHOOTING_URL}?id=configuration-upgrade-available` as const; @@ -10,487 +15,11 @@ export const TROUBLESHOOTING_LEGACY_RESOURCE_URL = export const TROUBLESHOOTING_MEDIA_URL = `${TROUBLESHOOTING_URL}?id=media-unavailable` as const; -export const CONF_AUTOMATIONS = 'automations' as const; - -export const CONF_CAMERAS = 'cameras' as const; - -export const CONF_CAMERAS_ARRAY_ALWAYS_ERROR_IF_ENTITY_UNAVAILABLE = - `${CONF_CAMERAS}.#.always_error_if_entity_unavailable` as const; -export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY = - `${CONF_CAMERAS}.#.camera_entity` as const; -export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME = - `${CONF_CAMERAS}.#.frigate.camera_name` as const; -export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE = - `${CONF_CAMERAS}.#.capabilities.disable` as const; -export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT = - `${CONF_CAMERAS}.#.capabilities.disable_except` as const; -export const CONF_CAMERAS_ARRAY_CAPABILITIES_FORCE = - `${CONF_CAMERAS}.#.capabilities.force` as const; -export const CONF_CAMERAS_ARRAY_CAST_METHOD = `${CONF_CAMERAS}.#.cast.method` as const; -export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH = - `${CONF_CAMERAS}.#.cast.dashboard.dashboard_path` as const; -export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH = - `${CONF_CAMERAS}.#.cast.dashboard.view_path` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO = - `${CONF_CAMERAS}.#.dimensions.aspect_ratio` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_ROTATION = - `${CONF_CAMERAS}.#.dimensions.rotation` as const; -export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID = - `${CONF_CAMERAS}.#.frigate.client_id` as const; -export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS = - `${CONF_CAMERAS}.#.frigate.labels` as const; -export const CONF_CAMERAS_ARRAY_FRIGATE_URL = `${CONF_CAMERAS}.#.frigate.url` as const; -export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES = - `${CONF_CAMERAS}.#.frigate.zones` as const; -export const CONF_CAMERAS_ARRAY_GO2RTC_METADATA_FETCH_TIMEOUT_SECONDS = - `${CONF_CAMERAS}.#.go2rtc.metadata_fetch_timeout_seconds` as const; -export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const; -export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM = - `${CONF_CAMERAS}.#.go2rtc.stream` as const; -export const CONF_CAMERAS_ARRAY_GO2RTC_URL = `${CONF_CAMERAS}.#.go2rtc.url` as const; -export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const; -export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const; -export const CONF_CAMERAS_ARRAY_IMAGE_ENTITY = `${CONF_CAMERAS}.#.image.entity` as const; -export const CONF_CAMERAS_ARRAY_IMAGE_ENTITY_PARAMETERS = - `${CONF_CAMERAS}.#.image.entity_parameters` as const; -export const CONF_CAMERAS_ARRAY_IMAGE_MODE = `${CONF_CAMERAS}.#.image.mode` as const; -export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS = - `${CONF_CAMERAS}.#.image.refresh_seconds` as const; -export const CONF_CAMERAS_ARRAY_IMAGE_URL = `${CONF_CAMERAS}.#.image.url` as const; -export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN = - `${CONF_CAMERAS}.#.motioneye.images.directory_pattern` as const; -export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN = - `${CONF_CAMERAS}.#.motioneye.images.file_pattern` as const; -export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN = - `${CONF_CAMERAS}.#.motioneye.movies.directory_pattern` as const; -export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN = - `${CONF_CAMERAS}.#.motioneye.movies.file_pattern` as const; -export const CONF_CAMERAS_ARRAY_MOTIONEYE_URL = - `${CONF_CAMERAS}.#.motioneye.url` as const; -export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const; -export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY = - `${CONF_CAMERAS}.#.webrtc_card.entity` as const; -export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = - `${CONF_CAMERAS}.#.webrtc_card.url` as const; -export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER = - `${CONF_CAMERAS}.#.live_provider` as const; -export const CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS = - `${CONF_CAMERAS}.#.dependencies.cameras` as const; -export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS = - `${CONF_CAMERAS}.#.dependencies.all_cameras` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT = - `${CONF_CAMERAS}.#.dimensions.layout.fit` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X = - `${CONF_CAMERAS}.#.dimensions.layout.pan.x` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y = - `${CONF_CAMERAS}.#.dimensions.layout.pan.y` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X = - `${CONF_CAMERAS}.#.dimensions.layout.position.x` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y = - `${CONF_CAMERAS}.#.dimensions.layout.position.y` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP = - `${CONF_CAMERAS}.#.dimensions.layout.view_box.top` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM = - `${CONF_CAMERAS}.#.dimensions.layout.view_box.bottom` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT = - `${CONF_CAMERAS}.#.dimensions.layout.view_box.left` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT = - `${CONF_CAMERAS}.#.dimensions.layout.view_box.right` as const; -export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR = - `${CONF_CAMERAS}.#.dimensions.layout.zoom` as const; -export const CONF_CAMERAS_ARRAY_PROXY_DYNAMIC = - `${CONF_CAMERAS}.#.proxy.dynamic` as const; -export const CONF_CAMERAS_ARRAY_PROXY_LIVE = `${CONF_CAMERAS}.#.proxy.live` as const; -export const CONF_CAMERAS_ARRAY_PROXY_MEDIA = `${CONF_CAMERAS}.#.proxy.media` as const; -export const CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS = - `${CONF_CAMERAS}.#.proxy.ssl_ciphers` as const; -export const CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION = - `${CONF_CAMERAS}.#.proxy.ssl_verification` as const; -export const CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION = - `${CONF_CAMERAS}.#.reolink.media_resolution` as const; -export const CONF_CAMERAS_ARRAY_REOLINK_URL = `${CONF_CAMERAS}.#.reolink.url` as const; -export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION = - `${CONF_CAMERAS}.#.triggers.motion` as const; -export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY = - `${CONF_CAMERAS}.#.triggers.occupancy` as const; -export const CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL = - `${CONF_CAMERAS}.#.triggers.doorbell` as const; -export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES = - `${CONF_CAMERAS}.#.triggers.entities` as const; -export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS = - `${CONF_CAMERAS}.#.triggers.events` as const; -export const CONF_CAMERAS_ARRAY_TRIGGERS_MEDIA_EVENTS = - `${CONF_CAMERAS}.#.triggers.media_events` as const; -export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES = - `${CONF_CAMERAS}.#.triggers.reviews.severities` as const; -export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION = - `${CONF_CAMERAS}.#.triggers.reviews.description` as const; -export const CONF_CAMERAS_ARRAY_MEDIA_TYPE = `${CONF_CAMERAS}.#.media.type` as const; -export const CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE = - `${CONF_CAMERAS}.#.media.events_type` as const; -export const CONF_CAMERAS_ARRAY_MEDIA_REVIEWED = - `${CONF_CAMERAS}.#.media.reviewed` as const; -export const CONF_CAMERAS_ARRAY_MEDIA_FOLDERS = - `${CONF_CAMERAS}.#.media.folders` as const; - -const CONF_CAMERAS_GLOBAL = 'cameras_global' as const; -export const CONF_CAMERAS_GLOBAL_LIVE_PROVIDER = - `${CONF_CAMERAS_GLOBAL}.live_provider` as const; -export const CONF_CAMERAS_GLOBAL_TRIGGERS_DOORBELL = - `${CONF_CAMERAS_GLOBAL}.triggers.doorbell` as const; -export const CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS = - `${CONF_CAMERAS_GLOBAL}.image.refresh_seconds` as const; -export const CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT = - `${CONF_CAMERAS_GLOBAL}.dimensions.layout` as const; -export const CONF_CAMERAS_GLOBAL_PTZ = `${CONF_CAMERAS_GLOBAL}.ptz` as const; - -export const CONF_ELEMENTS = 'elements' as const; - -export const CONF_FOLDERS = 'folders' as const; -export const CONF_FOLDERS_ARRAY_HA_URL = `${CONF_FOLDERS}.#.ha.url` as const; -export const CONF_FOLDERS_ARRAY_ICON = `${CONF_FOLDERS}.#.icon` as const; -export const CONF_FOLDERS_ARRAY_ID = `${CONF_FOLDERS}.#.id` as const; -export const CONF_FOLDERS_ARRAY_TITLE = `${CONF_FOLDERS}.#.title` as const; -export const CONF_FOLDERS_ARRAY_TYPE = `${CONF_FOLDERS}.#.type` as const; - -const CONF_VIEW = 'view' as const; -export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; -export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; -export const CONF_VIEW_DIM = `${CONF_VIEW}.dim` as const; -export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const; -export const CONF_VIEW_ISSUES = `${CONF_VIEW}.issues` as const; -export const CONF_VIEW_ISSUES_INTERACTION_MODE = - `${CONF_VIEW_ISSUES}.interaction_mode` as const; -export const CONF_VIEW_ISSUES_RETRY_SECONDS = - `${CONF_VIEW_ISSUES}.retry_seconds` as const; -export const CONF_VIEW_KEYBOARD_SHORTCUTS = `${CONF_VIEW}.keyboard_shortcuts` as const; -export const CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED = - `${CONF_VIEW}.keyboard_shortcuts.enabled` as const; -export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_LEFT = - `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_left` as const; -export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_RIGHT = - `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_right` as const; -export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP = - `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_up` as const; -export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN = - `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_down` as const; -export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN = - `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_in` as const; -export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT = - `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_out` as const; -export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME = - `${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_home` as const; -export const CONF_VIEW_DEFAULT_CYCLE_CAMERA = - `${CONF_VIEW}.default_cycle_camera` as const; -export const CONF_VIEW_DEFAULT_RESET = `${CONF_VIEW}.default_reset` as const; -export const CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE = - `${CONF_VIEW_DEFAULT_RESET}.interaction_mode` as const; -export const CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS = - `${CONF_VIEW_DEFAULT_RESET}.every_seconds` as const; -export const CONF_VIEW_DEFAULT_RESET_ENTITIES = - `${CONF_VIEW_DEFAULT_RESET}.entities` as const; -export const CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION = - `${CONF_VIEW_DEFAULT_RESET}.after_interaction` as const; -export const CONF_VIEW_THEME_THEMES = `${CONF_VIEW}.theme.themes` as const; -export const CONF_VIEW_TRIGGERS = `${CONF_VIEW}.triggers` as const; -export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS = - `${CONF_VIEW_TRIGGERS}.show_trigger_status` as const; -export const CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA = - `${CONF_VIEW_TRIGGERS}.filter_selected_camera` as const; -export const CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS = - `${CONF_VIEW_TRIGGERS}.untrigger_delay_seconds` as const; -export const CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS = - `${CONF_VIEW_TRIGGERS}.untrigger_force_seconds` as const; -export const CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS = - `${CONF_VIEW_TRIGGERS}.event_hold_seconds` as const; -export const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const; -export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE = - `${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const; -export const CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER = - `${CONF_VIEW_TRIGGERS_ACTIONS}.trigger` as const; -export const CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER = - `${CONF_VIEW_TRIGGERS_ACTIONS}.untrigger` as const; - -const CONF_MEDIA_GALLERY = 'media_gallery' as const; -export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE = - `${CONF_MEDIA_GALLERY}.controls.filter.mode` as const; -export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = - `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_details` as const; -export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL = - `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_download_control` as const; -export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = - `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_favorite_control` as const; -export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = - `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_timeline_control` as const; -export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL = - `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_review_control` as const; -export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL = - `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_info_control` as const; -export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE = - `${CONF_MEDIA_GALLERY}.controls.thumbnails.size` as const; - -const CONF_MEDIA_VIEWER = 'media_viewer' as const; -export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const; -export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const; -export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const; -export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const; -export const CONF_MEDIA_VIEWER_DISPLAY_MODE = - `${CONF_MEDIA_VIEWER}.display.mode` as const; -export const CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS = - `${CONF_MEDIA_VIEWER}.display.grid_columns` as const; -export const CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS = - `${CONF_MEDIA_VIEWER}.display.grid_max_columns` as const; -export const CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_POSITION = - `${CONF_MEDIA_VIEWER}.display.grid_selected_position` as const; -export const CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR = - `${CONF_MEDIA_VIEWER}.display.grid_selected_width_factor` as const; -export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const; -export const CONF_MEDIA_VIEWER_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const; -export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP = - `${CONF_MEDIA_VIEWER}.snapshot_click_plays_clip` as const; -export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT = - `${CONF_MEDIA_VIEWER}.transition_effect` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_BUILTIN = - `${CONF_MEDIA_VIEWER}.controls.builtin` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE = - `${CONF_MEDIA_VIEWER}.controls.next_previous.auto_hide` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE = - `${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE = - `${CONF_MEDIA_VIEWER}.controls.next_previous.size` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE = - `${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS = - `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL = - `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_download_control` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = - `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = - `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL = - `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_review_control` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL = - `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_info_control` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE = - `${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD = - `${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const; - -export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H = - `${CONF_MEDIA_VIEWER}.controls.timeline.format.24h` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE = - `${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE = - `${CONF_MEDIA_VIEWER}.controls.timeline.pan_mode` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS = - `${CONF_MEDIA_VIEWER}.controls.timeline.show_recordings` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE = - `${CONF_MEDIA_VIEWER}.controls.timeline.style` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS = - `${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const; -export const CONF_MEDIA_VIEWER_CONTROLS_WHEEL = - `${CONF_MEDIA_VIEWER}.controls.wheel` as const; -export const CONF_MEDIA_VIEWER_ZOOMABLE = `${CONF_MEDIA_VIEWER}.zoomable` as const; - -const CONF_LIVE = 'live' as const; -export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const; -export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const; -export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const; -export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const; -export const CONF_LIVE_CONTROLS_BUILTIN = `${CONF_LIVE}.controls.builtin` as const; -export const CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE = - `${CONF_LIVE}.controls.call.button_size` as const; -export const CONF_LIVE_CONTROLS_CALL_LOCK = `${CONF_LIVE}.controls.call.lock` as const; -export const CONF_LIVE_CONTROLS_CALL_RINGTONE_REPEAT = - `${CONF_LIVE}.controls.call.ringtone.repeat` as const; -export const CONF_LIVE_CONTROLS_CALL_RINGTONE_TYPE = - `${CONF_LIVE}.controls.call.ringtone.type` as const; -export const CONF_LIVE_CONTROLS_CALL_RINGTONE_URL = - `${CONF_LIVE}.controls.call.ringtone.url` as const; -export const CONF_LIVE_CONTROLS_CALL_UNANSWERED_TIMEOUT_SECONDS = - `${CONF_LIVE}.controls.call.unanswered_timeout_seconds` as const; -export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE = - `${CONF_LIVE}.controls.next_previous.auto_hide` as const; -export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE = - `${CONF_LIVE}.controls.next_previous.style` as const; -export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE = - `${CONF_LIVE}.controls.next_previous.size` as const; -export const CONF_LIVE_CONTROLS_PTZ_HIDE_HOME = - `${CONF_LIVE}.controls.ptz.hide_home` as const; -export const CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT = - `${CONF_LIVE}.controls.ptz.hide_pan_tilt` as const; -export const CONF_LIVE_CONTROLS_PTZ_HIDE_TYPE = - `${CONF_LIVE}.controls.ptz.hide_type` as const; -export const CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM = - `${CONF_LIVE}.controls.ptz.hide_zoom` as const; -export const CONF_LIVE_CONTROLS_PTZ_MODE = `${CONF_LIVE}.controls.ptz.mode` as const; -export const CONF_LIVE_CONTROLS_PTZ_ORIENTATION = - `${CONF_LIVE}.controls.ptz.orientation` as const; -export const CONF_LIVE_CONTROLS_PTZ_POSITION = - `${CONF_LIVE}.controls.ptz.position` as const; -export const CONF_LIVE_CONTROLS_PTZ_TYPE = `${CONF_LIVE}.controls.ptz.type` as const; -export const CONF_LIVE_CONTROLS_WHEEL = `${CONF_LIVE}.controls.wheel` as const; - -export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE = - `${CONF_LIVE}.controls.thumbnails.mode` as const; -export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE = - `${CONF_LIVE}.controls.thumbnails.size` as const; -export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS = - `${CONF_LIVE}.controls.thumbnails.show_details` as const; -export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL = - `${CONF_LIVE}.controls.thumbnails.show_download_control` as const; -export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = - `${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const; -export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = - `${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const; -export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL = - `${CONF_LIVE}.controls.thumbnails.show_review_control` as const; -export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL = - `${CONF_LIVE}.controls.thumbnails.show_info_control` as const; -export const CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD = - `${CONF_LIVE}.controls.timeline.clustering_threshold` as const; -export const CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H = - `${CONF_LIVE}.controls.timeline.format.24h` as const; -export const CONF_LIVE_CONTROLS_TIMELINE_MODE = - `${CONF_LIVE}.controls.timeline.mode` as const; -export const CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE = - `${CONF_LIVE}.controls.timeline.pan_mode` as const; -export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS = - `${CONF_LIVE}.controls.timeline.show_recordings` as const; -export const CONF_LIVE_CONTROLS_TIMELINE_STYLE = - `${CONF_LIVE}.controls.timeline.style` as const; -export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS = - `${CONF_LIVE}.controls.timeline.window_seconds` as const; -export const CONF_LIVE_DISPLAY_MODE = `${CONF_LIVE}.display.mode` as const; -export const CONF_LIVE_DISPLAY_GRID_COLUMNS = - `${CONF_LIVE}.display.grid_columns` as const; -export const CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS = - `${CONF_LIVE}.display.grid_max_columns` as const; -export const CONF_LIVE_DISPLAY_GRID_SELECTED_POSITION = - `${CONF_LIVE}.display.grid_selected_position` as const; -export const CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR = - `${CONF_LIVE}.display.grid_selected_width_factor` as const; -export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const; -export const CONF_LIVE_LAZY_LOAD = `${CONF_LIVE}.lazy_load` as const; -export const CONF_LIVE_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const; -export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const; -export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const; -export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD = - `${CONF_LIVE}.show_image_during_load` as const; -export const CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED = - `${CONF_LIVE}.microphone.always_connected` as const; -export const CONF_LIVE_MICROPHONE_AUTO_MUTE = - `${CONF_LIVE}.microphone.auto_mute` as const; -export const CONF_LIVE_MICROPHONE_AUTO_UNMUTE = - `${CONF_LIVE}.microphone.auto_unmute` as const; -export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS = - `${CONF_LIVE}.microphone.disconnect_seconds` as const; -export const CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS = - `${CONF_LIVE}.microphone.mute_after_microphone_mute_seconds` as const; -export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const; - -const CONF_IMAGE = 'image' as const; -export const CONF_IMAGE_ENTITY = `${CONF_IMAGE}.entity` as const; -export const CONF_IMAGE_ENTITY_PARAMETERS = `${CONF_IMAGE}.entity_parameters` as const; -export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const; -export const CONF_IMAGE_PROXY_DYNAMIC = `${CONF_IMAGE}.proxy.dynamic` as const; -export const CONF_IMAGE_PROXY_ENABLED = `${CONF_IMAGE}.proxy.enabled` as const; -export const CONF_IMAGE_PROXY_SSL_CIPHERS = `${CONF_IMAGE}.proxy.ssl_ciphers` as const; -export const CONF_IMAGE_PROXY_SSL_VERIFICATION = - `${CONF_IMAGE}.proxy.ssl_verification` as const; -export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const; -export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const; - -const CONF_TIMELINE = 'timeline' as const; -export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const; -export const CONF_TIMELINE_CLUSTERING_THRESHOLD = - `${CONF_TIMELINE}.clustering_threshold` as const; -export const CONF_TIMELINE_FORMAT_24H = `${CONF_TIMELINE}.format.24h` as const; -export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const; -export const CONF_TIMELINE_STYLE = `${CONF_TIMELINE}.style` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = - `${CONF_TIMELINE}.controls.thumbnails.mode` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = - `${CONF_TIMELINE}.controls.thumbnails.size` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS = - `${CONF_TIMELINE}.controls.thumbnails.show_details` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL = - `${CONF_TIMELINE}.controls.thumbnails.show_download_control` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = - `${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = - `${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL = - `${CONF_TIMELINE}.controls.thumbnails.show_review_control` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL = - `${CONF_TIMELINE}.controls.thumbnails.show_info_control` as const; - -const CONF_MENU = 'menu' as const; -export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const; -export const CONF_MENU_AUTO_HIDE = `${CONF_MENU}.auto_hide` as const; -export const CONF_MENU_POSITION = `${CONF_MENU}.position` as const; -export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const; -export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const; -export const CONF_MENU_BUTTONS = `${CONF_MENU}.buttons` as const; - -export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU_BUTTONS}.fullscreen` as const; -export const CONF_MENU_BUTTONS_IRIS = `${CONF_MENU_BUTTONS}.iris` as const; -export const CONF_MENU_BUTTONS_PLAY = `${CONF_MENU_BUTTONS}.play` as const; -export const CONF_MENU_BUTTONS_MUTE = `${CONF_MENU_BUTTONS}.mute` as const; -export const CONF_MENU_BUTTONS_MEDIA_PLAYER = - `${CONF_MENU_BUTTONS}.media_player` as const; -export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU_BUTTONS}.timeline` as const; - -export const CONF_STATUS_BAR = 'status_bar' as const; -export const CONF_STATUS_BAR_AUTO_HIDE = `${CONF_STATUS_BAR}.auto_hide` as const; -export const CONF_STATUS_BAR_POSITION = `${CONF_STATUS_BAR}.position` as const; -export const CONF_STATUS_BAR_STYLE = `${CONF_STATUS_BAR}.style` as const; -export const CONF_STATUS_BAR_POPUP_SECONDS = `${CONF_STATUS_BAR}.popup_seconds` as const; -export const CONF_STATUS_BAR_HEIGHT = `${CONF_STATUS_BAR}.height` as const; - -export const CONF_STATUS_BAR_ITEMS = `${CONF_STATUS_BAR}.items` as const; - -// A single namespace shadowing the main config: anything the upgrade cannot -// faithfully convert is recorded here as a failure, intact, at the same path it -// came from (e.g. top-level `automations` -> `__UPGRADE_FAILURE__.automations`) -// for the user to migrate by hand. -export const CONF_UPGRADE_FAILURE = '__UPGRADE_FAILURE__' as const; - -const CONF_DIMENSIONS = 'dimensions' as const; -export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const; -export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = - `${CONF_DIMENSIONS}.aspect_ratio_mode` as const; -export const CONF_DIMENSIONS_HEIGHT = `${CONF_DIMENSIONS}.height` as const; - -export const CONF_OVERRIDES = 'overrides' as const; - -const CONF_PERFORMANCE = 'performance' as const; -export const CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR = `${CONF_PERFORMANCE}.features.animated_progress_indicator`; -export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS = `${CONF_PERFORMANCE}.features.card_loading_effects`; -export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR = `${CONF_PERFORMANCE}.features.card_loading_indicator`; -export const CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE = `${CONF_PERFORMANCE}.features.media_chunk_size`; -export const CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS = `${CONF_PERFORMANCE}.features.max_simultaneous_engine_requests`; -export const CONF_PERFORMANCE_PROFILE = `${CONF_PERFORMANCE}.profile`; -export const CONF_PERFORMANCE_STYLE_BOX_SHADOW = `${CONF_PERFORMANCE}.style.box_shadow`; -export const CONF_PERFORMANCE_STYLE_BORDER_RADIUS = `${CONF_PERFORMANCE}.style.border_radius`; - -export const CONF_PROFILES = 'profiles' as const; - -const CONF_REMOTE_CONTROL = 'remote_control' as const; -export const CONF_REMOTE_CONTROL_ENTITIES_CAMERA = - `${CONF_REMOTE_CONTROL}.entities.camera` as const; - -// Taken from https://github.com/home-assistant/frontend/blob/a759767d794f02527d127802831e68d3caf0cb7a/src/data/media-player.ts#L82 -export const MEDIA_PLAYER_SUPPORT_TURN_OFF = 256; -export const MEDIA_PLAYER_SUPPORT_STOP = 4096; -export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; +// =========================================================================== +// Media Constants +// =========================================================================== // The number of media items to fetch at a time (for clips/snapshot views, and // gallery chunks). Smaller values will cause more frequent smaller fetches, but // improved rendering performance. export const MEDIA_CHUNK_SIZE_DEFAULT = 50; -export const MEDIA_CHUNK_SIZE_MAX = 1000; - -// The name of the exit keyframe defined in `scss/pop-animation.scss`. -export const POP_OUT_ANIMATION_NAME = 'pop-out'; diff --git a/src/declarations.d.ts b/src/declarations.d.ts index da645057..502b69fe 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -1,5 +1,8 @@ declare module '*.scss'; -declare module '*.svg'; +declare module '*.svg' { + const icon: { path: string; viewBox: string }; + export default icon; +} declare module '*.jpg'; declare module 'view' { // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/editor.ts b/src/editor.ts index 04c188c7..df082eb9 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -3,4088 +3,274 @@ import { LitElement, unsafeCSS, type CSSResultGroup, + type PropertyValues, type TemplateResult, } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; +import { customElement, property } from 'lit/decorators.js'; +import './components/editor/cameras.js'; +import './components/editor/doc-link.js'; +import './components/editor/folders.js'; +import './components/editor/keyboard-shortcuts.js'; +import './components/editor/section.js'; import './components/icon.js'; -import './components/key-assigner.js'; -import { renderNotificationBlock } from './components/notification/block.js'; -import { - copyConfig, - deleteConfigValue, - getArrayConfigPath, - getConfigValue, - isConfigUpgradeable, - setConfigValue, - upgradeConfig, -} from './config/management.js'; -import { setProfiles } from './config/profiles/set-profiles.js'; -import { - BUTTON_SIZE_MIN, - MENU_PRIORITY_MAX, - STATUS_BAR_PRIORITY_MAX, -} from './config/schema/common/const.js'; -import { - THUMBNAIL_WIDTH_MAX, - THUMBNAIL_WIDTH_MIN, -} from './config/schema/common/controls/thumbnails.js'; -import { ZOOM_MAX, ZOOM_MIN } from './config/schema/common/zoom.js'; -import { profilesSchema } from './config/schema/profiles.js'; -import { STATUS_BAR_HEIGHT_MIN } from './config/schema/status-bar.js'; -import { configDefaults, type AdvancedCameraCardConfig } from './config/schema/types.js'; -import type { KeyboardShortcut } from './config/schema/view.js'; -import type { - RawAdvancedCameraCardConfig, - RawAdvancedCameraCardConfigArray, -} from './config/types.js'; -import { - CONF_CAMERAS, - CONF_CAMERAS_ARRAY_ALWAYS_ERROR_IF_ENTITY_UNAVAILABLE, - CONF_CAMERAS_ARRAY_CAMERA_ENTITY, - CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE, - CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT, - CONF_CAMERAS_ARRAY_CAPABILITIES_FORCE, - CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH, - CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH, - CONF_CAMERAS_ARRAY_CAST_METHOD, - CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS, - CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS, - CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO, - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT, - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X, - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y, - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X, - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y, - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM, - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT, - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT, - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP, - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR, - CONF_CAMERAS_ARRAY_DIMENSIONS_ROTATION, - CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME, - CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID, - CONF_CAMERAS_ARRAY_FRIGATE_LABELS, - CONF_CAMERAS_ARRAY_FRIGATE_URL, - CONF_CAMERAS_ARRAY_FRIGATE_ZONES, - CONF_CAMERAS_ARRAY_GO2RTC_METADATA_FETCH_TIMEOUT_SECONDS, - CONF_CAMERAS_ARRAY_GO2RTC_MODES, - CONF_CAMERAS_ARRAY_GO2RTC_STREAM, - CONF_CAMERAS_ARRAY_GO2RTC_URL, - CONF_CAMERAS_ARRAY_ICON, - CONF_CAMERAS_ARRAY_ID, - CONF_CAMERAS_ARRAY_IMAGE_ENTITY, - CONF_CAMERAS_ARRAY_IMAGE_ENTITY_PARAMETERS, - CONF_CAMERAS_ARRAY_IMAGE_MODE, - CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS, - CONF_CAMERAS_ARRAY_IMAGE_URL, - CONF_CAMERAS_ARRAY_LIVE_PROVIDER, - CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE, - CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, - CONF_CAMERAS_ARRAY_MEDIA_REVIEWED, - CONF_CAMERAS_ARRAY_MEDIA_TYPE, - CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN, - CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN, - CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN, - CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN, - CONF_CAMERAS_ARRAY_MOTIONEYE_URL, - CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, - CONF_CAMERAS_ARRAY_PROXY_LIVE, - CONF_CAMERAS_ARRAY_PROXY_MEDIA, - CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS, - CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION, - CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION, - CONF_CAMERAS_ARRAY_REOLINK_URL, - CONF_CAMERAS_ARRAY_TITLE, - CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL, - CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, - CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS, - CONF_CAMERAS_ARRAY_TRIGGERS_MEDIA_EVENTS, - CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, - CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, - CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION, - CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES, - CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, - CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, - CONF_DIMENSIONS_ASPECT_RATIO, - CONF_DIMENSIONS_ASPECT_RATIO_MODE, - CONF_DIMENSIONS_HEIGHT, - CONF_FOLDERS, - CONF_FOLDERS_ARRAY_HA_URL, - CONF_FOLDERS_ARRAY_ICON, - CONF_FOLDERS_ARRAY_ID, - CONF_FOLDERS_ARRAY_TITLE, - CONF_FOLDERS_ARRAY_TYPE, - CONF_IMAGE_ENTITY, - CONF_IMAGE_ENTITY_PARAMETERS, - CONF_IMAGE_MODE, - CONF_IMAGE_PROXY_DYNAMIC, - CONF_IMAGE_PROXY_ENABLED, - CONF_IMAGE_PROXY_SSL_CIPHERS, - CONF_IMAGE_PROXY_SSL_VERIFICATION, - CONF_IMAGE_REFRESH_SECONDS, - CONF_IMAGE_URL, - CONF_LIVE_AUTO_MUTE, - CONF_LIVE_AUTO_PAUSE, - CONF_LIVE_AUTO_PLAY, - CONF_LIVE_AUTO_UNMUTE, - CONF_LIVE_CONTROLS_BUILTIN, - CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE, - CONF_LIVE_CONTROLS_CALL_LOCK, - CONF_LIVE_CONTROLS_CALL_RINGTONE_REPEAT, - CONF_LIVE_CONTROLS_CALL_RINGTONE_TYPE, - CONF_LIVE_CONTROLS_CALL_RINGTONE_URL, - CONF_LIVE_CONTROLS_CALL_UNANSWERED_TIMEOUT_SECONDS, - CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE, - CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, - CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, - CONF_LIVE_CONTROLS_PTZ_HIDE_HOME, - CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT, - CONF_LIVE_CONTROLS_PTZ_HIDE_TYPE, - CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM, - CONF_LIVE_CONTROLS_PTZ_MODE, - CONF_LIVE_CONTROLS_PTZ_ORIENTATION, - CONF_LIVE_CONTROLS_PTZ_POSITION, - CONF_LIVE_CONTROLS_PTZ_TYPE, - CONF_LIVE_CONTROLS_THUMBNAILS_MODE, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, - CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, - CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, - CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H, - CONF_LIVE_CONTROLS_TIMELINE_MODE, - CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE, - CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, - CONF_LIVE_CONTROLS_TIMELINE_STYLE, - CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, - CONF_LIVE_CONTROLS_WHEEL, - CONF_LIVE_DISPLAY_GRID_COLUMNS, - CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS, - CONF_LIVE_DISPLAY_GRID_SELECTED_POSITION, - CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, - CONF_LIVE_DISPLAY_MODE, - CONF_LIVE_DRAGGABLE, - CONF_LIVE_LAZY_LOAD, - CONF_LIVE_LAZY_UNLOAD, - CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED, - CONF_LIVE_MICROPHONE_AUTO_MUTE, - CONF_LIVE_MICROPHONE_AUTO_UNMUTE, - CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS, - CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS, - CONF_LIVE_PRELOAD, - CONF_LIVE_SHOW_IMAGE_DURING_LOAD, - CONF_LIVE_TRANSITION_EFFECT, - CONF_LIVE_ZOOMABLE, - CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE, - CONF_MEDIA_VIEWER_AUTO_MUTE, - CONF_MEDIA_VIEWER_AUTO_PAUSE, - CONF_MEDIA_VIEWER_AUTO_PLAY, - CONF_MEDIA_VIEWER_AUTO_UNMUTE, - CONF_MEDIA_VIEWER_CONTROLS_BUILTIN, - CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE, - CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, - CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, - CONF_MEDIA_VIEWER_CONTROLS_WHEEL, - CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS, - CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS, - CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_POSITION, - CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, - CONF_MEDIA_VIEWER_DISPLAY_MODE, - CONF_MEDIA_VIEWER_DRAGGABLE, - CONF_MEDIA_VIEWER_LAZY_LOAD, - CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, - CONF_MEDIA_VIEWER_TRANSITION_EFFECT, - CONF_MEDIA_VIEWER_ZOOMABLE, - CONF_MENU_ALIGNMENT, - CONF_MENU_AUTO_HIDE, - CONF_MENU_BUTTON_SIZE, - CONF_MENU_BUTTONS, - CONF_MENU_POSITION, - CONF_MENU_STYLE, - CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, - CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS, - CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR, - CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS, - CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE, - CONF_PERFORMANCE_PROFILE, - CONF_PERFORMANCE_STYLE_BORDER_RADIUS, - CONF_PERFORMANCE_STYLE_BOX_SHADOW, - CONF_PROFILES, - CONF_REMOTE_CONTROL_ENTITIES_CAMERA, - CONF_STATUS_BAR_AUTO_HIDE, - CONF_STATUS_BAR_HEIGHT, - CONF_STATUS_BAR_ITEMS, - CONF_STATUS_BAR_POPUP_SECONDS, - CONF_STATUS_BAR_POSITION, - CONF_STATUS_BAR_STYLE, - CONF_TIMELINE_CLUSTERING_THRESHOLD, - CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE, - CONF_TIMELINE_FORMAT_24H, - CONF_TIMELINE_SHOW_RECORDINGS, - CONF_TIMELINE_STYLE, - CONF_TIMELINE_WINDOW_SECONDS, - CONF_VIEW_CAMERA_SELECT, - CONF_VIEW_DEFAULT, - CONF_VIEW_DEFAULT_CYCLE_CAMERA, - CONF_VIEW_DEFAULT_RESET, - CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION, - CONF_VIEW_DEFAULT_RESET_ENTITIES, - CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, - CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, - CONF_VIEW_DIM, - CONF_VIEW_INTERACTION_SECONDS, - CONF_VIEW_ISSUES, - CONF_VIEW_ISSUES_INTERACTION_MODE, - CONF_VIEW_ISSUES_RETRY_SECONDS, - CONF_VIEW_KEYBOARD_SHORTCUTS, - CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED, - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN, - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME, - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_LEFT, - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_RIGHT, - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP, - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN, - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT, - CONF_VIEW_THEME_THEMES, - CONF_VIEW_TRIGGERS, - CONF_VIEW_TRIGGERS_ACTIONS, - CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE, - CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, - CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, - CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS, - CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, - CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, - CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, - CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS, - DOCS_URL, - MEDIA_CHUNK_SIZE_MAX, -} from './const.js'; -import { fireHASSEvent } from './ha/fire-hass-event.js'; -import { getEntitiesFromHASS } from './ha/get-entities.js'; -import { getEntityTitle } from './ha/get-entity-title.js'; -import { sideLoadHomeAssistantElements } from './ha/side-load-ha-elements.js'; -import type { HomeAssistant, LovelaceCardEditor } from './ha/types.js'; +import { EditorController } from './components-lib/editor/controller.js'; +import type { FormsInput } from './components-lib/editor/forms-controller.js'; +import type { EditorIntent } from './components-lib/editor/intents.js'; +import type { FormRequest } from './components-lib/editor/schema/registry.js'; +import type { RawAdvancedCameraCardConfig } from './config/types.js'; +import type { HAFormSchema, HomeAssistant, LovelaceCardEditor } from './ha/types.js'; import { localize } from './localize/localize.js'; import editorStyle from './scss/editor.scss'; -import type { CapabilityKey } from './types.js'; -import { arrayMove, errorToConsole, prettifyTitle } from './utils/basic.js'; -import { getCameraID } from './utils/camera.js'; import { fireAdvancedCameraCardEvent } from './utils/fire-advanced-camera-card-event.js'; -import { getFolderID } from './utils/folder.js'; -const MENU_CAMERAS = 'cameras'; -const MENU_CAMERAS_CAPABILITIES = 'cameras.capabilities'; -const MENU_CAMERAS_CAST = 'cameras.cast'; -const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies'; -const MENU_CAMERAS_DIMENSIONS = 'cameras.dimensions'; -const MENU_CAMERAS_DIMENSIONS_LAYOUT = 'cameras.dimensions.layout'; -const MENU_CAMERAS_ENGINE = 'cameras.engine'; -const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; -const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc'; -const MENU_CAMERAS_IMAGE = 'cameras.image'; -const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider'; -const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye'; -const MENU_CAMERAS_PROXY = 'cameras.proxy'; -const MENU_CAMERAS_REOLINK = 'cameras.reolink'; -const MENU_CAMERAS_TRIGGERS = 'cameras.triggers'; -const MENU_CAMERAS_TRIGGERS_EVENT = 'cameras.triggers.event'; -const MENU_CAMERAS_TRIGGERS_EVENTS = 'cameras.triggers.events'; -const MENU_CAMERAS_TRIGGERS_REVIEWS = 'cameras.triggers.reviews'; -const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card'; -const MENU_CAMERAS_MEDIA = 'cameras.media'; -const MENU_FOLDERS = 'folders'; -const MENU_FOLDERS_HA = 'folders.ha'; -const MENU_LIVE_CONTROLS = 'live.controls'; -const MENU_LIVE_CONTROLS_CALL = 'live.controls.call'; -const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous'; -const MENU_LIVE_CONTROLS_PTZ = 'live.controls.ptz'; -const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails'; -const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline'; -const MENU_LIVE_CONTROLS_TIMELINE_FORMAT = 'live.controls.timeline.format'; -const MENU_LIVE_DISPLAY = 'live.display'; -const MENU_LIVE_MICROPHONE = 'live.microphone'; -const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter'; -const MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS = 'media_gallery.controls.thumbnails'; -const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls'; -const MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS = 'media_viewer.controls.next_previous'; -const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails'; -const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline'; -const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT = - 'media_viewer.controls.timeline.format'; -const MENU_MEDIA_VIEWER_DISPLAY = 'media_viewer.display'; -const MENU_IMAGE_PROXY = 'image.proxy'; -const MENU_MENU_BUTTONS = 'menu.buttons'; -const MENU_OPTIONS = 'options'; -const MENU_PERFORMANCE_FEATURES = 'performance.features'; -const MENU_PERFORMANCE_STYLE = 'performance.style'; -const MENU_REMOTE_CONTROL_ENTITIES = 'remote_control.entities'; -const MENU_STATUS_BAR_ITEMS = 'status_bar.items'; -const MENU_TIMELINE_FORMAT = 'timeline.format'; -const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails'; -const MENU_VIEW_DEFAULT_RESET = 'view.default_reset'; -const MENU_VIEW_ISSUES = 'view.issues'; -const MENU_VIEW_KEYBOARD_SHORTCUTS = 'view.keyboard_shortcuts'; -const MENU_VIEW_TRIGGERS = 'view.triggers'; -const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions'; - -const SECTION_DOC_LINKS: Record = { - cameras: 'configuration/cameras/README', - dimensions: 'configuration/dimensions', - view: 'configuration/view', - menu: 'configuration/menu', - status_bar: 'configuration/status-bar', - live: 'configuration/live', - folders: 'configuration/folders', - media_viewer: 'configuration/media-viewer', - media_gallery: 'configuration/media-gallery', - image: 'configuration/image', - timeline: 'configuration/timeline', - performance: 'configuration/performance', - profiles: 'configuration/profiles', - remote_control: 'configuration/remote-control', -}; - -const SUBMENU_DOC_LINKS: Record = { - [MENU_VIEW_DEFAULT_RESET]: 'configuration/view?id=default_reset', - [MENU_VIEW_ISSUES]: 'configuration/view?id=issues', - [MENU_VIEW_TRIGGERS]: 'configuration/view?id=triggers', - [MENU_VIEW_TRIGGERS_ACTIONS]: 'configuration/view?id=trigger-action-configuration', - [MENU_VIEW_KEYBOARD_SHORTCUTS]: 'configuration/view?id=keyboard_shortcuts', - [MENU_CAMERAS]: 'configuration/cameras/README', - [MENU_CAMERAS_CAPABILITIES]: 'configuration/cameras/README?id=capabilities', - [MENU_CAMERAS_CAST]: 'configuration/cameras/README?id=cast', - [MENU_CAMERAS_DEPENDENCIES]: 'configuration/cameras/README?id=dependencies', - [MENU_CAMERAS_DIMENSIONS]: 'configuration/cameras/README?id=dimensions', - [MENU_CAMERAS_DIMENSIONS_LAYOUT]: - 'configuration/cameras/README?id=layout-configuration', - [MENU_CAMERAS_ENGINE]: 'configuration/cameras/engine', - [MENU_CAMERAS_FRIGATE]: 'configuration/cameras/engine?id=frigate', - [MENU_CAMERAS_GO2RTC]: 'configuration/cameras/live-provider?id=go2rtc', - [MENU_CAMERAS_IMAGE]: 'configuration/cameras/live-provider?id=image', - [MENU_CAMERAS_LIVE_PROVIDER]: 'configuration/cameras/live-provider', - [MENU_CAMERAS_MOTIONEYE]: 'configuration/cameras/engine?id=motioneye', - [MENU_CAMERAS_PROXY]: 'configuration/cameras/README?id=proxy', - [MENU_CAMERAS_REOLINK]: 'configuration/cameras/engine?id=reolink', - [MENU_CAMERAS_TRIGGERS]: 'configuration/cameras/README?id=triggers', - [MENU_CAMERAS_TRIGGERS_REVIEWS]: 'configuration/cameras/README?id=reviews', - [MENU_CAMERAS_WEBRTC_CARD]: 'configuration/cameras/live-provider?id=webrtc_card', - [MENU_CAMERAS_MEDIA]: 'configuration/cameras/README?id=media', - [MENU_FOLDERS]: 'configuration/folders', - [MENU_FOLDERS_HA]: 'configuration/folders?id=ha', - [MENU_LIVE_CONTROLS]: 'configuration/live?id=controls', - [MENU_LIVE_CONTROLS_CALL]: 'configuration/live?id=call', - [MENU_LIVE_CONTROLS_NEXT_PREVIOUS]: 'configuration/live?id=next_previous', - [MENU_LIVE_CONTROLS_PTZ]: 'configuration/live?id=ptz', - [MENU_LIVE_CONTROLS_THUMBNAILS]: 'configuration/live?id=thumbnails', - [MENU_LIVE_CONTROLS_TIMELINE]: 'configuration/live?id=timeline', - [MENU_LIVE_CONTROLS_TIMELINE_FORMAT]: 'configuration/live?id=format', - [MENU_LIVE_DISPLAY]: 'configuration/live?id=display', - [MENU_LIVE_MICROPHONE]: 'configuration/live?id=microphone', - [MENU_MEDIA_GALLERY_CONTROLS_FILTER]: 'configuration/media-gallery?id=filter', - [MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS]: 'configuration/media-gallery?id=thumbnails', - [MENU_MEDIA_VIEWER_CONTROLS]: 'configuration/media-viewer?id=controls', - [MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS]: - 'configuration/media-viewer?id=next_previous', - [MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS]: 'configuration/media-viewer?id=thumbnails', - [MENU_MEDIA_VIEWER_CONTROLS_TIMELINE]: 'configuration/media-viewer?id=timeline', - [MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT]: 'configuration/media-viewer?id=format', - [MENU_MEDIA_VIEWER_DISPLAY]: 'configuration/media-viewer?id=display', - [MENU_IMAGE_PROXY]: 'configuration/image?id=proxy', - [MENU_MENU_BUTTONS]: 'configuration/menu?id=buttons', - [MENU_OPTIONS]: 'configuration/README', - [MENU_PERFORMANCE_FEATURES]: 'configuration/performance?id=features', - [MENU_PERFORMANCE_STYLE]: 'configuration/performance?id=style', - [MENU_REMOTE_CONTROL_ENTITIES]: 'configuration/remote-control?id=entities', - [MENU_STATUS_BAR_ITEMS]: 'configuration/status-bar?id=items', - [MENU_TIMELINE_FORMAT]: 'configuration/timeline?id=format', - [MENU_TIMELINE_CONTROLS_THUMBNAILS]: 'configuration/timeline?id=thumbnails', -}; - -interface EditorOptionsSet { +interface EditorSection { icon: string; name: string; - secondary: string; -} -interface EditorOptions { - [setName: string]: EditorOptionsSet; + description: string; + + // The part of the section a schema cannot express: a list the user adds to + // and reorders, or a key assigned by pressing it. A section whose whole + // configuration is such a list has no schema forms, and shows only this + // content. + renderCustomContent?: (hass: HomeAssistant, input: FormsInput) => TemplateResult; } -interface EditorSelectOption { - value: unknown; - label: string; -} - -interface EditorMenuTarget { - domain: string; - key: string | number; -} - -const options: EditorOptions = { +// Ordered as rendered: cameras first, then alphabetical by displayed name. +const SECTIONS: Record = { cameras: { icon: 'video', name: localize('editor.cameras'), - secondary: localize('editor.cameras_secondary'), - }, - view: { - icon: 'eye', - name: localize('editor.view'), - secondary: localize('editor.view_secondary'), - }, - menu: { - icon: 'menu', - name: localize('editor.menu'), - secondary: localize('editor.menu_secondary'), - }, - status_bar: { - icon: 'sign-text', - name: localize('editor.status_bar'), - secondary: localize('editor.status_bar_secondary'), - }, - live: { - icon: 'cctv', - name: localize('editor.live'), - secondary: localize('editor.live_secondary'), - }, - folders: { - icon: 'folder-multiple', - name: localize('editor.folders'), - secondary: localize('editor.folders_secondary'), - }, - media_gallery: { - icon: 'play-box-multiple', - name: localize('editor.media_gallery'), - secondary: localize('editor.media_gallery_secondary'), - }, - media_viewer: { - icon: 'filmstrip', - name: localize('editor.media_viewer'), - secondary: localize('editor.media_viewer_secondary'), - }, - image: { - icon: 'image', - name: localize('editor.image'), - secondary: localize('editor.image_secondary'), - }, - timeline: { - icon: 'chart-gantt', - name: localize('editor.timeline'), - secondary: localize('editor.timeline_secondary'), + description: localize('editor.cameras_secondary'), + renderCustomContent: (hass, input) => html` + + `, }, dimensions: { icon: 'aspect-ratio', name: localize('editor.dimensions'), - secondary: localize('editor.dimensions_secondary'), + description: localize('editor.dimensions_secondary'), + }, + folders: { + icon: 'folder-multiple', + name: localize('editor.folders'), + description: localize('editor.folders_secondary'), + renderCustomContent: (hass, input) => html` + + `, + }, + image: { + icon: 'image', + name: localize('editor.image'), + description: localize('editor.image_secondary'), + }, + live: { + icon: 'cctv', + name: localize('editor.live'), + description: localize('editor.live_secondary'), + }, + media_gallery: { + icon: 'play-box-multiple', + name: localize('editor.media_gallery'), + description: localize('editor.media_gallery_secondary'), + }, + media_viewer: { + icon: 'filmstrip', + name: localize('editor.media_viewer'), + description: localize('editor.media_viewer_secondary'), + }, + menu: { + icon: 'menu', + name: localize('editor.menu'), + description: localize('editor.menu_secondary'), }, performance: { icon: 'speedometer', name: localize('editor.performance'), - secondary: localize('editor.performance_secondary'), + description: localize('editor.performance_secondary'), }, profiles: { icon: 'folder-wrench-outline', name: localize('editor.profiles'), - secondary: localize('editor.profiles_secondary'), + description: localize('editor.profiles_secondary'), }, remote_control: { icon: 'remote', name: localize('editor.remote_control'), - secondary: localize('editor.remote_control_secondary'), + description: localize('editor.remote_control_secondary'), }, - overrides: { - icon: 'file-replace', - name: localize('editor.overrides'), - secondary: localize('editor.overrides_secondary'), + status_bar: { + icon: 'sign-text', + name: localize('editor.status_bar'), + description: localize('editor.status_bar_secondary'), + }, + timeline: { + icon: 'chart-gantt', + name: localize('editor.timeline'), + description: localize('editor.timeline_secondary'), + }, + view: { + icon: 'eye', + name: localize('editor.view'), + description: localize('editor.view_secondary'), + + // The keyboard shortcuts are a panel of their own within the view section: + // a shortcut is assigned by pressing a key rather than by filling in a + // field, so no selector can express one. + renderCustomContent: (hass, input) => html` + + `, }, }; +/** + * Used to side-load lazily-loaded selectors the editor will use. + * See {@link AdvancedCameraCardEditor._renderSelectorSideload}. + */ +const SELECTOR_SIDELOAD_SCHEMA: HAFormSchema[] = [ + { name: 'text', selector: { text: {} } }, + { name: 'select', selector: { select: { options: [] } } }, + { name: 'number', selector: { number: {} } }, + { name: 'boolean', selector: { boolean: {} } }, + { name: 'icon', selector: { icon: {} } }, + { name: 'entity', selector: { entity: {} } }, + { name: 'object', selector: { object: {} } }, + { name: 'sideload', type: 'expandable', title: '', schema: [] }, +]; +const SELECTOR_SIDELOAD_DATA = {}; + @customElement('advanced-camera-card-editor') export class AdvancedCameraCardEditor extends LitElement implements LovelaceCardEditor { - @property({ attribute: false }) public hass?: HomeAssistant; - @state() private _config?: RawAdvancedCameraCardConfig; - @state() private _defaults = copyConfig(configDefaults); + @property({ attribute: false }) + public hass?: HomeAssistant; - private _initialized = false; - private _configUpgradeable = false; - - @state() - private _expandedMenus: Record = {}; - - private _viewModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'auto', label: localize('config.view.views.auto') }, - { value: 'clip', label: localize('config.view.views.clip') }, - { value: 'clips', label: localize('config.view.views.clips') }, - { value: 'folder', label: localize('config.view.views.folder') }, - { value: 'folders', label: localize('config.view.views.folders') }, - { value: 'gallery', label: localize('config.view.views.gallery') }, - { value: 'image', label: localize('config.view.views.image') }, - { value: 'live', label: localize('config.view.views.live') }, - { value: 'media', label: localize('config.view.views.media') }, - { value: 'recording', label: localize('config.view.views.recording') }, - { value: 'recordings', label: localize('config.view.views.recordings') }, - { value: 'review', label: localize('config.view.views.review') }, - { value: 'reviews', label: localize('config.view.views.reviews') }, - { value: 'snapshot', label: localize('config.view.views.snapshot') }, - { value: 'snapshots', label: localize('config.view.views.snapshots') }, - { value: 'timeline', label: localize('config.view.views.timeline') }, - ]; - - private _cameraSelectViewModes: EditorSelectOption[] = [ - ...this._viewModes, - { value: 'current', label: localize('config.view.views.current') }, - ]; - - private _filterModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'none', - label: localize('config.common.controls.filter.modes.none'), - }, - { - value: 'left', - label: localize('config.common.controls.filter.modes.left'), - }, - { - value: 'right', - label: localize('config.common.controls.filter.modes.right'), - }, - ]; - - private _menuStyles: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'none', label: localize('config.menu.styles.none') }, - { value: 'hidden', label: localize('config.menu.styles.hidden') }, - { value: 'overlay', label: localize('config.menu.styles.overlay') }, - { value: 'hover', label: localize('config.menu.styles.hover') }, - { value: 'hover-card', label: localize('config.menu.styles.hover-card') }, - { value: 'outside', label: localize('config.menu.styles.outside') }, - ]; - - private _menuPositions: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'left', label: localize('config.menu.positions.left') }, - { value: 'right', label: localize('config.menu.positions.right') }, - { value: 'top', label: localize('config.menu.positions.top') }, - { value: 'bottom', label: localize('config.menu.positions.bottom') }, - ]; - - private _menuAlignments: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'left', label: localize('config.menu.alignments.left') }, - { value: 'right', label: localize('config.menu.alignments.right') }, - { value: 'top', label: localize('config.menu.alignments.top') }, - { value: 'bottom', label: localize('config.menu.alignments.bottom') }, - ]; - - private _nextPreviousControlStyles: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'chevrons', - label: localize('config.common.controls.next_previous.styles.chevrons'), - }, - { - value: 'icons', - label: localize('config.common.controls.next_previous.styles.icons'), - }, - { - value: 'none', - label: localize('config.common.controls.next_previous.styles.none'), - }, - { - value: 'thumbnails', - label: localize('config.common.controls.next_previous.styles.thumbnails'), - }, - ]; - - private _aspectRatioModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'dynamic', - label: localize('config.dimensions.aspect_ratio_modes.dynamic'), - }, - { value: 'static', label: localize('config.dimensions.aspect_ratio_modes.static') }, - { - value: 'unconstrained', - label: localize('config.dimensions.aspect_ratio_modes.unconstrained'), - }, - ]; - - private _thumbnailModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'none', - label: localize('config.common.controls.thumbnails.modes.none'), - }, - { - value: 'above', - label: localize('config.common.controls.thumbnails.modes.above'), - }, - { - value: 'below', - label: localize('config.common.controls.thumbnails.modes.below'), - }, - { - value: 'left', - label: localize('config.common.controls.thumbnails.modes.left'), - }, - { - value: 'right', - label: localize('config.common.controls.thumbnails.modes.right'), - }, - ]; - - private _thumbnailMediaTypes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'auto', - label: localize('config.common.media_types.auto'), - }, - { - value: 'events', - label: localize('config.common.media_types.events'), - }, - { - value: 'recordings', - label: localize('config.common.media_types.recordings'), - }, - { - value: 'reviews', - label: localize('config.common.media_types.reviews'), - }, - ]; - - private _timelineThumbnailMediaTypes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'auto', - label: localize('config.common.media_types.auto'), - }, - { - value: 'events', - label: localize('config.common.media_types.events'), - }, - { - value: 'reviews', - label: localize('config.common.media_types.reviews'), - }, - ]; - - private _thumbnailEventsMediaTypes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'all', - label: localize('config.common.events_media_types.all'), - }, - { - value: 'clips', - label: localize('config.common.events_media_types.clips'), - }, - { - value: 'snapshots', - label: localize('config.common.events_media_types.snapshots'), - }, - ]; - - private _cameraMediaReviewedOptions: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'unreviewed', - label: localize('config.cameras.media.revieweds.unreviewed'), - }, - { - value: 'reviewed', - label: localize('config.cameras.media.revieweds.reviewed'), - }, - { - value: 'all', - label: localize('config.cameras.media.revieweds.all'), - }, - ]; - - private _transitionEffects: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'none', label: localize('config.media_viewer.transition_effects.none') }, - { value: 'slide', label: localize('config.media_viewer.transition_effects.slide') }, - ]; - - private _imageModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'camera', label: localize('config.common.image.modes.camera') }, - { value: 'default', label: localize('config.common.image.modes.default') }, - { value: 'entity', label: localize('config.common.image.modes.entity') }, - { - value: 'screensaver', - label: localize('config.common.image.modes.screensaver'), - }, - { value: 'url', label: localize('config.common.image.modes.url') }, - ]; - - private _timelineEventsMediaTypes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'all', label: localize('config.common.events_media_types.all') }, - { - value: 'clips', - label: localize('config.common.events_media_types.clips'), - }, - { - value: 'snapshots', - label: localize('config.common.events_media_types.snapshots'), - }, - ]; - - private _timelineStyleTypes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'ribbon', label: localize('config.common.timeline.styles.ribbon') }, - { value: 'stack', label: localize('config.common.timeline.styles.stack') }, - ]; - - private _mediaActionNegativeConditions: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'unselected', - label: localize('config.common.media_action_conditions.unselected'), - }, - { value: 'hidden', label: localize('config.common.media_action_conditions.hidden') }, - ]; - - private _mediaActionPositiveConditions: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'selected', - label: localize('config.common.media_action_conditions.selected'), - }, - { - value: 'visible', - label: localize('config.common.media_action_conditions.visible'), - }, - ]; - - private _callMuteCondition: EditorSelectOption = { - value: 'call', - label: localize('config.common.media_action_conditions.call_mute'), - }; - - private _callUnmuteCondition: EditorSelectOption = { - value: 'call', - label: localize('config.common.media_action_conditions.call_unmute'), - }; - - private _microphoneMuteConditions: EditorSelectOption[] = [ - ...this._mediaActionNegativeConditions, - this._callMuteCondition, - ]; - - private _microphoneUnmuteConditions: EditorSelectOption[] = [ - ...this._mediaActionPositiveConditions, - this._callUnmuteCondition, - ]; - - private _mediaLiveUnmuteConditions: EditorSelectOption[] = [ - ...this._mediaActionPositiveConditions, - { - value: 'microphone', - label: localize('config.common.media_action_conditions.microphone_unmute'), - }, - this._callUnmuteCondition, - ]; - - private _mediaLiveMuteConditions: EditorSelectOption[] = [ - ...this._mediaActionNegativeConditions, - { - value: 'microphone', - label: localize('config.common.media_action_conditions.microphone_mute'), - }, - this._callMuteCondition, - ]; - - private _autoHideConditions: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'call', label: localize('config.common.auto_hide_conditions.call') }, - { - value: 'casting', - label: localize('config.common.auto_hide_conditions.casting'), - }, - ]; - - private _layoutFits: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'contain', - label: localize('config.cameras.dimensions.layout.fits.contain'), - }, - { value: 'cover', label: localize('config.cameras.dimensions.layout.fits.cover') }, - { value: 'fill', label: localize('config.cameras.dimensions.layout.fits.fill') }, - ]; - - private _miniTimelineModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'none', label: localize('config.common.controls.timeline.modes.none') }, - { value: 'above', label: localize('config.common.controls.timeline.modes.above') }, - { value: 'below', label: localize('config.common.controls.timeline.modes.below') }, - ]; - - private _profiles: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'casting', label: localize('config.profiles.casting') }, - { value: 'doorbell', label: localize('config.profiles.doorbell') }, - { value: 'low-performance', label: localize('config.profiles.low-performance') }, - { value: 'scrubbing', label: localize('config.profiles.scrubbing') }, - ]; - - private _go2rtcModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'mse', label: localize('config.cameras.go2rtc.modes.mse') }, - { value: 'webrtc', label: localize('config.cameras.go2rtc.modes.webrtc') }, - { value: 'mp4', label: localize('config.cameras.go2rtc.modes.mp4') }, - { value: 'mjpeg', label: localize('config.cameras.go2rtc.modes.mjpeg') }, - ]; - - private _microphoneButtonTypes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'momentary', label: localize('config.menu.buttons.types.momentary') }, - { value: 'toggle', label: localize('config.menu.buttons.types.toggle') }, - ]; - - private _displayModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'single', label: localize('display_modes.single') }, - { value: 'grid', label: localize('display_modes.grid') }, - ]; - - private _gridSelectPositions: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'default', - label: localize('config.common.display.grid_selected_positions.default'), - }, - { - value: 'first', - label: localize('config.common.display.grid_selected_positions.first'), - }, - { - value: 'last', - label: localize('config.common.display.grid_selected_positions.last'), - }, - ]; - - private _castMethods: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'standard', label: localize('config.cameras.cast.methods.standard') }, - { value: 'dashboard', label: localize('config.cameras.cast.methods.dashboard') }, - ]; - - private _cameraMediaTypes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'auto', label: localize('config.common.media_types.auto') }, - { value: 'events', label: localize('config.common.media_types.events') }, - { value: 'recordings', label: localize('config.common.media_types.recordings') }, - { value: 'reviews', label: localize('config.common.media_types.reviews') }, - { value: 'folder', label: localize('config.common.media_types.folder') }, - ]; - - private _ptzModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'on', label: localize('config.live.controls.ptz.modes.on') }, - { value: 'off', label: localize('config.live.controls.ptz.modes.off') }, - ]; - - private _ptzOrientations: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'vertical', - label: localize('config.live.controls.ptz.orientations.vertical'), - }, - { - value: 'horizontal', - label: localize('config.live.controls.ptz.orientations.horizontal'), - }, - ]; - - private _ptzTypes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'buttons', - label: localize('config.live.controls.ptz.types.buttons'), - }, - { - value: 'gestures', - label: localize('config.live.controls.ptz.types.gestures'), - }, - ]; - - private _ptzPositions: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'top-left', - label: localize('config.live.controls.ptz.positions.top-left'), - }, - { - value: 'top-right', - label: localize('config.live.controls.ptz.positions.top-right'), - }, - { - value: 'bottom-left', - label: localize('config.live.controls.ptz.positions.bottom-left'), - }, - { - value: 'bottom-right', - label: localize('config.live.controls.ptz.positions.bottom-right'), - }, - ]; - - private _interactionModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'all', - label: localize('config.common.interaction_modes.all'), - }, - { - value: 'inactive', - label: localize('config.common.interaction_modes.inactive'), - }, - { - value: 'active', - label: localize('config.common.interaction_modes.active'), - }, - ]; - - private _triggersActionsTrigger: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'call', - label: localize('config.view.triggers.actions.triggers.call'), - }, - { - value: 'default', - label: localize('config.view.triggers.actions.triggers.default'), - }, - { - value: 'live', - label: localize('config.view.triggers.actions.triggers.live'), - }, - { - value: 'media', - label: localize('config.view.triggers.actions.triggers.media'), - }, - { - value: 'none', - label: localize('config.view.triggers.actions.triggers.none'), - }, - ]; - - private _triggersActionsUntrigger: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'call', - label: localize('config.view.triggers.actions.untriggers.call'), - }, - { - value: 'default', - label: localize('config.view.triggers.actions.untriggers.default'), - }, - { - value: 'none', - label: localize('config.view.triggers.actions.untriggers.none'), - }, - ]; - - private _callRingtoneTypes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'none', - label: localize('config.live.controls.call.ringtone.types.none'), - }, - { - value: 'chime', - label: localize('config.live.controls.call.ringtone.types.chime'), - }, - { - value: 'westminster', - label: localize('config.live.controls.call.ringtone.types.westminster'), - }, - { - value: 'arpeggio', - label: localize('config.live.controls.call.ringtone.types.arpeggio'), - }, - { - value: 'melody', - label: localize('config.live.controls.call.ringtone.types.melody'), - }, - { - value: 'custom', - label: localize('config.live.controls.call.ringtone.types.custom'), - }, - ]; - - private _triggersMediaEvents: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'events', - label: localize('config.cameras.triggers.media_events.events'), - }, - { - value: 'clips', - label: localize('config.cameras.triggers.media_events.clips'), - }, - { - value: 'snapshots', - label: localize('config.cameras.triggers.media_events.snapshots'), - }, - ]; - - private _severities: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'high', - label: localize('common.severities.high'), - }, - { - value: 'medium', - label: localize('common.severities.medium'), - }, - { - value: 'low', - label: localize('common.severities.low'), - }, - ]; - - private _timelinePanModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'pan', - label: localize('config.common.controls.timeline.pan_modes.pan'), - }, - { - value: 'seek', - label: localize('config.common.controls.timeline.pan_modes.seek'), - }, - { - value: 'seek-in-media', - label: localize('config.common.controls.timeline.pan_modes.seek-in-media'), - }, - { - value: 'seek-in-camera', - label: localize('config.common.controls.timeline.pan_modes.seek-in-camera'), - }, - ]; - - private _capabilities: EditorSelectOption[] = (() => { - // 'satisfies' enforces all CapabilityKey values are present at compile time. - const labels = { - '2-way-audio': localize('config.cameras.capabilities.capabilities.2-way-audio'), - clips: localize('config.cameras.capabilities.capabilities.clips'), - 'favorite-events': localize( - 'config.cameras.capabilities.capabilities.favorite-events', - ), - 'favorite-recordings': localize( - 'config.cameras.capabilities.capabilities.favorite-recordings', - ), - live: localize('config.cameras.capabilities.capabilities.live'), - menu: localize('config.cameras.capabilities.capabilities.menu'), - ptz: localize('config.cameras.capabilities.capabilities.ptz'), - recordings: localize('config.cameras.capabilities.capabilities.recordings'), - 'remote-control-entity': localize( - 'config.cameras.capabilities.capabilities.remote-control-entity', - ), - reviews: localize('config.cameras.capabilities.capabilities.reviews'), - seek: localize('config.cameras.capabilities.capabilities.seek'), - snapshots: localize('config.cameras.capabilities.capabilities.snapshots'), - substream: localize('config.cameras.capabilities.capabilities.substream'), - trigger: localize('config.cameras.capabilities.capabilities.trigger'), - } satisfies Record; - - return [ - { value: '', label: '' }, - ...Object.entries(labels).map(([value, label]) => ({ value, label })), - ]; - })(); - - private _forceableCapabilities: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: '2-way-audio', - label: localize('config.cameras.capabilities.capabilities.2-way-audio'), - }, - ]; - - private _proxyModes: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'auto', - label: localize('config.common.proxy.modes.auto'), - }, - { - value: true, - label: localize('config.common.proxy.modes.true'), - }, - { - value: false, - label: localize('config.common.proxy.modes.false'), - }, - ]; - - private _proxySSLCiphers: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'auto', - label: localize('config.common.proxy.ssl_ciphers.auto'), - }, - { - value: 'default', - label: localize('config.common.proxy.ssl_ciphers.default'), - }, - { - value: 'insecure', - label: localize('config.common.proxy.ssl_ciphers.insecure'), - }, - { - value: 'intermediate', - label: localize('config.common.proxy.ssl_ciphers.intermediate'), - }, - { - value: 'modern', - label: localize('config.common.proxy.ssl_ciphers.modern'), - }, - ]; - - private _proxySSLVerification: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'auto', - label: localize('config.common.proxy.ssl_verification.auto'), - }, - { - value: true, - label: localize('config.common.proxy.ssl_verification.true'), - }, - { - value: false, - label: localize('config.common.proxy.ssl_verification.false'), - }, - ]; - - private _reolinkMediaResolution: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'high', - label: localize('config.cameras.reolink.media_resolution.high'), - }, - { - value: 'low', - label: localize('config.cameras.reolink.media_resolution.low'), - }, - ]; - - private _statusBarStyles: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'hover', label: localize('config.status_bar.styles.hover') }, - { value: 'hover-card', label: localize('config.status_bar.styles.hover-card') }, - { value: 'none', label: localize('config.status_bar.styles.none') }, - { value: 'outside', label: localize('config.status_bar.styles.outside') }, - { value: 'overlay', label: localize('config.status_bar.styles.overlay') }, - { value: 'popup', label: localize('config.status_bar.styles.popup') }, - ]; - - private _statusBarPositions: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'top', label: localize('config.status_bar.positions.top') }, - { value: 'bottom', label: localize('config.status_bar.positions.bottom') }, - ]; - - private _themes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'ha', label: localize('config.view.theme.themes.ha') }, - { value: 'dark', label: localize('config.view.theme.themes.dark') }, - { value: 'light', label: localize('config.view.theme.themes.light') }, - { value: 'traditional', label: localize('config.view.theme.themes.traditional') }, - ]; - - private _rotations: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 0, label: localize('config.cameras.dimensions.rotations.0') }, - { value: 90, label: localize('config.cameras.dimensions.rotations.90') }, - { value: 180, label: localize('config.cameras.dimensions.rotations.180') }, - { value: 270, label: localize('config.cameras.dimensions.rotations.270') }, - ]; + private _controller = new EditorController(this); public setConfig(config: RawAdvancedCameraCardConfig): void { - // Note: This does not use Zod to parse the full configuration, so it may be - // partially or completely invalid. It's more useful to have a partially - // valid configuration here, to allow the user to fix the broken parts. As - // such, RawAdvancedCameraCardConfig is used as the type. - this._config = config; - this._configUpgradeable = isConfigUpgradeable(config); + this._controller.setConfig(config); + } - const profiles = profilesSchema.safeParse( - (this._config as AdvancedCameraCardConfig).profiles, - ); + protected willUpdate(changedProps: PropertyValues): void { + this._controller.initialize(); - if (profiles.success) { - const defaults = copyConfig(configDefaults); - setProfiles(this._config, defaults, profiles.data); - this._defaults = defaults; + if (changedProps.has('hass') && this.hass) { + this._controller.setHASS(this.hass); } } - protected willUpdate(): void { - if (!this._initialized) { - sideLoadHomeAssistantElements() - .then(() => { - this._initialized = true; - }) - // A failure leaves the editor with degraded HA form elements and is - // retried on the next update; log it so the cause is at least visible. - .catch((e) => errorToConsole(e)); - } - } - - /** - * Render an option set header - * @param optionSetName The name of the EditorOptionsSet. - * @returns A rendered template. - */ - private _renderOptionSetHeader( - optionSetName: string, - titleClass?: string, - ): TemplateResult { - const optionSet = options[optionSetName]; - - return html` -
-
- -
${optionSet.name}
-
-
${optionSet.secondary}
-
- `; - } - - private _renderDocLinkRow(docPath: string): TemplateResult { - return html` - - -
${localize('editor.docs')}
- -
- `; - } - - /** - * Get a localized help label for a given config path. - * @param configPath The config path. - * @returns A localized label. - */ - private _getLabel(configPath: string): string { - // Strip out array indices from the path. - const path = configPath - .split('.') - .filter((e) => !e.match(/^\[[0-9]+\]$/)) - .join('.'); - return localize(`config.${path}`); - } - - /** - * Render an entity selector. - * @param configPath The configuration path to set/read. - * @param domain Only entities from this domain will be shown. - * @returns A rendered template. - */ - private _renderEntitySelector( - configPath: string, - domain: string, - ): TemplateResult | void { - if (!this._config) { - return; - } - - return html` - this._valueChangedHandler(configPath, ev)} - > - - `; - } - - /** - * Render an option/"select" selector. - * @param configPath The configuration path to set/read. - * @param options The options to show in the selector. - * @param params Option parameters to control the selector. - * @returns A rendered template. - */ - private _renderOptionSelector( - configPath: string, - options: string[] | { value: unknown; label: string }[] = [], - params?: { - multiple?: boolean; - label?: string; - }, - ): TemplateResult | void { - if (!this._config) { - return; - } - - return html` - this._valueChangedHandler(configPath, ev)} - > - - `; - } - - /** - * Render an icon selector. - * @param configPath The configuration path to set/read. - * @param params Optional parameters to control the selector. - * @returns A rendered template. - */ - private _renderIconSelector( - configPath: string, - params?: { - label?: string; - }, - ): TemplateResult | void { - if (!this._config) { - return; - } - - return html` - this._valueChangedHandler(configPath, ev)} - > - - `; - } - - /** - * Render a number slider. - * @param configPath Configuration path of the variable. - * @param params Optional parameters to control the selector. - * @returns A rendered template. - */ - private _renderNumberInput( - configPath: string, - params?: { - min?: number; - max?: number; - label?: string; - default?: number; - step?: number; - }, - ): TemplateResult | void { - if (!this._config) { - return; - } - const value = getConfigValue(this._config, configPath); - const mode = params?.max === undefined ? 'box' : 'slider'; - - return html` - this._valueChangedHandler(configPath, ev)} - > - - `; - } - - /** - * Render a simple text info box. - * @param info The string to display. - * @returns A rendered template. - */ - private _renderInfo(info: string): TemplateResult { - return html` ${info}`; - } - - /** - * Get an editor title for the camera. - * @param cameraIndex The index of the camera in the cameras array. - * @param cameraConfig The raw camera configuration object. - * @returns A string title. - */ - private _getEditorCameraTitle( - cameraIndex: number, - cameraConfig: RawAdvancedCameraCardConfig, - ): string { - // Attempt to render a recognizable name for the camera, starting with the - // most likely to be useful and working our ways towards the least useful. - // This is only used for the editor since the card itself can use the - // cameraManager. - return ( - (typeof cameraConfig?.title === 'string' && cameraConfig.title) || - (typeof cameraConfig?.camera_entity === 'string' - ? getEntityTitle(this.hass, cameraConfig.camera_entity) - : '') || - (typeof cameraConfig?.webrtc_card === 'object' && - cameraConfig.webrtc_card && - typeof cameraConfig.webrtc_card['entity'] === 'string' && - cameraConfig.webrtc_card['entity']) || - // Usage of engine specific logic here is allowed as an exception, since - // the camera manager cannot be started with an unparsed and unloaded - // config. - (typeof cameraConfig?.frigate === 'object' && - cameraConfig.frigate && - typeof cameraConfig?.frigate['camera_name'] === 'string' && - cameraConfig.frigate['camera_name'] - ? prettifyTitle(cameraConfig.frigate['camera_name']) - : '') || - (typeof cameraConfig?.id === 'string' && cameraConfig.id) || - localize('editor.camera') + ' #' + cameraIndex - ); - } - - /** - * Get an editor title for the camera. - * @param cameraIndex The index of the camera in the cameras array. - * @param cameraConfig The raw camera configuration object. - * @returns A string title. - */ - private _getEditorFolderTitle( - folderIndex: number, - folderConfig: RawAdvancedCameraCardConfig, - ): string { - // Attempt to render a recognizable name for the camera, starting with the - // most likely to be useful and working our ways towards the least useful. - // This is only used for the editor since the card itself can use the - // cameraManager. - return ( - (typeof folderConfig?.title === 'string' && folderConfig.title) || - (typeof folderConfig?.id === 'string' && folderConfig.id) || - localize('common.folder') + ' #' + folderIndex - ); - } - - private _getEditorTriggerEventTitle( - eventIndex: number, - eventConfig: RawAdvancedCameraCardConfig, - ): string { - return ( - (typeof eventConfig?.event_type === 'string' && eventConfig.event_type) || - localize('common.event') + ' #' + eventIndex - ); - } - - private _renderViewDefaultResetMenu(): TemplateResult { - return this._putInSubmenu( - MENU_VIEW_DEFAULT_RESET, - true, - `config.${CONF_VIEW_DEFAULT_RESET}.editor_label`, - 'mdi:restart', - html` - ${this._renderSwitch( - CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION, - this._defaults.view.default_reset.after_interaction, - )} - ${this._renderNumberInput(CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS)} - ${this._renderOptionSelector( - CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, - this._interactionModes, - { - label: localize('config.view.default_reset.interaction_mode'), - }, - )}, - ${this._renderOptionSelector( - CONF_VIEW_DEFAULT_RESET_ENTITIES, - this.hass ? getEntitiesFromHASS(this.hass) : [], - { - multiple: true, - }, - )} - `, - ); - } - - private _renderViewIssuesMenu(): TemplateResult { - return this._putInSubmenu( - MENU_VIEW_ISSUES, - true, - `config.${CONF_VIEW_ISSUES}.editor_label`, - 'mdi:alert-circle-outline', - html` - ${this._renderOptionSelector( - CONF_VIEW_ISSUES_INTERACTION_MODE, - this._interactionModes, - { - label: localize('config.view.issues.interaction_mode'), - }, - )} - ${this._renderNumberInput(CONF_VIEW_ISSUES_RETRY_SECONDS, { min: 0 })} - `, - ); - } - - private _renderViewTriggersMenu(): TemplateResult { - return this._putInSubmenu( - MENU_VIEW_TRIGGERS, - true, - `config.${CONF_VIEW_TRIGGERS}.editor_label`, - 'mdi:target-account', - html` - ${this._renderSwitch( - CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, - this._defaults.view.triggers.filter_selected_camera, - { - label: localize(`config.${CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA}`), - }, - )} - ${this._renderSwitch( - CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, - this._defaults.view.triggers.show_trigger_status, - { - label: localize(`config.${CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS}`), - }, - )} - ${this._renderNumberInput(CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, { - default: this._defaults.view.triggers.untrigger_delay_seconds, - })} - ${this._renderNumberInput(CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS, { - default: this._defaults.view.triggers.untrigger_force_seconds, - })} - ${this._renderNumberInput(CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS, { - default: this._defaults.view.triggers.event_hold_seconds, - })} - ${this._putInSubmenu( - MENU_VIEW_TRIGGERS_ACTIONS, - true, - `config.${CONF_VIEW_TRIGGERS_ACTIONS}.editor_label`, - 'mdi:cogs', - html` ${this._renderOptionSelector( - CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, - this._triggersActionsTrigger, - { - label: localize('config.view.triggers.actions.trigger'), - }, - )} - ${this._renderOptionSelector( - CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, - this._triggersActionsUntrigger, - { - label: localize('config.view.triggers.actions.untrigger'), - }, - )} - ${this._renderOptionSelector( - CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE, - this._interactionModes, - { - label: localize('config.view.triggers.actions.interaction_mode'), - }, - )}`, - )} - `, - ); - } - - private _renderKeyAssigner( - configPath: string, - defaultValue: KeyboardShortcut, - ): TemplateResult { - return html` this._valueChangedHandler(configPath, ev)} - >`; - } - - private _renderViewKeyboardShortcutMenu(): TemplateResult { - return this._putInSubmenu( - MENU_VIEW_KEYBOARD_SHORTCUTS, - true, - `config.${CONF_VIEW_KEYBOARD_SHORTCUTS}.editor_label`, - 'mdi:keyboard', - html` - ${this._renderSwitch( - CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED, - this._defaults.view.keyboard_shortcuts.enabled, - { - label: localize(`config.${CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED}`), - }, - )} - ${this._renderKeyAssigner( - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_LEFT, - this._defaults.view.keyboard_shortcuts.ptz_left, - )} - ${this._renderKeyAssigner( - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_RIGHT, - this._defaults.view.keyboard_shortcuts.ptz_right, - )} - ${this._renderKeyAssigner( - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP, - this._defaults.view.keyboard_shortcuts.ptz_up, - )} - ${this._renderKeyAssigner( - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN, - this._defaults.view.keyboard_shortcuts.ptz_down, - )} - ${this._renderKeyAssigner( - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN, - this._defaults.view.keyboard_shortcuts.ptz_zoom_in, - )} - ${this._renderKeyAssigner( - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT, - this._defaults.view.keyboard_shortcuts.ptz_zoom_out, - )} - ${this._renderKeyAssigner( - CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME, - this._defaults.view.keyboard_shortcuts.ptz_home, - )} - `, - ); - } - - private _renderStatusBarItem(item: string): TemplateResult { - return html` ${this._putInSubmenu( - MENU_STATUS_BAR_ITEMS, - item, - `config.status_bar.items.${item}`, - 'mdi:feature-search', - html` - ${this._renderSwitch( - `${CONF_STATUS_BAR_ITEMS}.${item}.enabled`, - this._defaults.status_bar.items[item]?.enabled ?? true, - { - label: localize('config.status_bar.items.enabled'), - }, - )} - ${this._renderNumberInput(`${CONF_STATUS_BAR_ITEMS}.${item}.priority`, { - max: STATUS_BAR_PRIORITY_MAX, - default: this._defaults.status_bar.items[item]?.priority, - label: localize('config.status_bar.items.priority'), - })} - `, - )}`; - } - - private _renderMenuButton( - button: string, - additionalOptions?: TemplateResult, - ): TemplateResult { - const menuButtonAlignments: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'matching', label: localize('config.menu.buttons.alignments.matching') }, - { value: 'opposing', label: localize('config.menu.buttons.alignments.opposing') }, - ]; - - return html` ${this._putInSubmenu( - MENU_MENU_BUTTONS, - button, - `config.menu.buttons.${button}`, - 'mdi:gesture-tap-button', - html` - ${this._renderSwitch( - `${CONF_MENU_BUTTONS}.${button}.enabled`, - this._defaults.menu.buttons[button]?.enabled ?? true, - { - label: localize('config.menu.buttons.enabled'), - }, - )} - ${this._renderOptionSelector( - `${CONF_MENU_BUTTONS}.${button}.alignment`, - menuButtonAlignments, - { - label: localize('config.menu.buttons.alignment'), - }, - )} - ${this._renderSwitch( - `${CONF_MENU_BUTTONS}.${button}.permanent`, - this._defaults.menu.buttons[button]?.permanent ?? false, - { - label: localize('config.menu.buttons.permanent'), - }, - )} - ${this._renderNumberInput(`${CONF_MENU_BUTTONS}.${button}.priority`, { - max: MENU_PRIORITY_MAX, - default: this._defaults.menu.buttons[button]?.priority, - label: localize('config.menu.buttons.priority'), - })} - ${this._renderIconSelector(`${CONF_MENU_BUTTONS}.${button}.icon`, { - label: localize('config.menu.buttons.icon'), - })} - ${additionalOptions} - `, - )}`; - } - - /** - * Put a given rendered template into a submenu. - * @param domain The submenu domain. - * @param key The submenu key. - * @param icon The icon for the submenu. - * @param labelPath The path to the label to localize. - * @param template The template to put in the submenu. - * @returns - */ - private _putInSubmenu( - domain: string, - key: unknown, - labelPath: string, - icon: string, - template: TemplateResult, - ): TemplateResult { - const selected = this._expandedMenus[domain] === key; - const submenuClasses = { - submenu: true, - selected: selected, - }; - - const docPath = SUBMENU_DOC_LINKS[domain as string]; - - return html`
- - ${selected - ? html`
- ${docPath ? this._renderDocLinkRow(docPath) : ''} ${template} -
` - : ''} -
`; - } - - /** - * Render a media layout section. - * @param domain The submenu domain. - * @param labelPath The path to the label. - * @param configPathFit The path to the fit config. - * @param configPathPositionX The path to the position.x config. - * @param configPathPositionY The path to the position.y config. - * @returns A rendered template. - */ - private _renderMediaLayout( - domain: string, - labelPath: string, - configPathFit: string, - configPathPositionX: string, - configPathPositionY: string, - configPathViewBoxTop: string, - configPathViewBoxBottom: string, - configPathViewBoxLeft: string, - configPathViewBoxRight: string, - configPathZoom: string, - configPathPanX: string, - configPathPanY: string, - ): TemplateResult | void { - return this._putInSubmenu( - domain, - true, - labelPath, - 'mdi:page-layout-body', - html` - ${this._renderNumberInput(configPathZoom, { - min: ZOOM_MIN, - max: ZOOM_MAX, - label: localize('config.cameras.dimensions.layout.zoom'), - step: 0.1, - })} - ${this._renderNumberInput(configPathPanX, { - min: 0, - max: 100, - label: localize('config.cameras.dimensions.layout.pan.x'), - })} - ${this._renderNumberInput(configPathPanY, { - min: 0, - max: 100, - label: localize('config.cameras.dimensions.layout.pan.y'), - })} - ${this._renderOptionSelector(configPathFit, this._layoutFits, { - label: localize('config.cameras.dimensions.layout.fit'), - })} - ${this._putInSubmenu( - `${domain}.position`, - true, - 'config.cameras.dimensions.layout.position.editor_label', - 'mdi:aspect-ratio', - html` ${this._renderNumberInput(configPathPositionX, { - min: 0, - max: 100, - label: localize('config.cameras.dimensions.layout.position.x'), - })} - ${this._renderNumberInput(configPathPositionY, { - min: 0, - max: 100, - label: localize('config.cameras.dimensions.layout.position.y'), - })}`, - )} - ${this._putInSubmenu( - `${domain}.view_box`, - true, - 'config.cameras.dimensions.layout.view_box.editor_label', - 'mdi:crop', - html` - ${this._renderNumberInput(configPathViewBoxTop, { - min: 0, - max: 100, - label: localize('config.cameras.dimensions.layout.view_box.top'), - })} - ${this._renderNumberInput(configPathViewBoxBottom, { - min: 0, - max: 100, - label: localize('config.cameras.dimensions.layout.view_box.bottom'), - })} - ${this._renderNumberInput(configPathViewBoxLeft, { - min: 0, - max: 100, - label: localize('config.cameras.dimensions.layout.view_box.left'), - })} - ${this._renderNumberInput(configPathViewBoxRight, { - min: 0, - max: 100, - label: localize('config.cameras.dimensions.layout.view_box.right'), - })} - `, - )} - `, - ); - } - - /** - * Render the core timeline controls (mini or full timeline), - * @param configPathStyle Timeline style config path. - * @param configPathWindowSeconds Timeline window config path. - * @param configPathClusteringThreshold Clustering threshold config path. - * @param configPathShowRecordings Show recordings config path. - * @param defaultShowRecordings Default value of show_recordings. - * @returns A rendered template. - */ - private _renderTimelineCoreControls( - formatDomain: string, - configPathStyle: string, - configPathWindowSeconds: string, - configPathClusteringThreshold: string, - configPathShowRecordings: string, - configPathFormat24h: string, - defaultShowRecordings: boolean, - defaultFormat24h: boolean, - configPathPanMode?: string, - ): TemplateResult { - return html` - ${this._renderOptionSelector(configPathStyle, this._timelineStyleTypes, { - label: localize(`config.common.${CONF_TIMELINE_STYLE}`), - })} - ${configPathPanMode - ? this._renderOptionSelector(configPathPanMode, this._timelinePanModes, { - label: localize(`config.common.controls.timeline.pan_mode`), - }) - : ``} - ${this._renderNumberInput(configPathWindowSeconds, { - label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`), - })} - ${this._renderNumberInput(configPathClusteringThreshold, { - label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`), - })} - ${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, { - label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`), - })} - ${this._putInSubmenu( - formatDomain, - true, - 'config.common.controls.timeline.format.editor_label', - 'mdi:clock-edit', - html` - ${this._renderSwitch(configPathFormat24h, defaultFormat24h, { - label: localize('config.common.controls.timeline.format.24h'), - })} - `, - )} - `; - } - - /** - * Render the mini timeline controls. - * @param domain The submenu domain. - * @param configPathWindowSeconds Timeline window config path. - * @param configPathClusteringThreshold Clustering threshold config path. - - * @param configPathShowRecordings Show recordings config path. - * @returns A rendered template. - */ - private _renderMiniTimeline( - domain: string, - formatDomain: string, - configPathMode: string, - configPathStyle: string, - configPathWindowSeconds: string, - configPathClusteringThreshold: string, - configPathShowRecordings: string, - configPathFormat24h: string, - defaultShowRecordings: boolean, - defaultFormat24h: boolean, - configPathPanMode: string, - ): TemplateResult | void { - return this._putInSubmenu( - domain, - true, - 'config.common.controls.timeline.editor_label', - 'mdi:chart-gantt', - html` ${this._renderOptionSelector(configPathMode, this._miniTimelineModes, { - label: localize('config.common.controls.timeline.mode'), - })} - ${this._renderTimelineCoreControls( - formatDomain, - configPathStyle, - configPathWindowSeconds, - configPathClusteringThreshold, - configPathShowRecordings, - configPathFormat24h, - defaultShowRecordings, - defaultFormat24h, - configPathPanMode, - )}`, - ); - } - - /** - * Render the next & previous controls. - * @param domain The submenu domain. - * @param configPathStyle Next previous style config path. - * @param configPathSize Next previous size config path. - * @returns A rendered template. - */ - private _renderViewDisplay( - domain: string, - configPathMode: string, - configPathSelectedPosition: string, - configPathSelectedWidthFactor: string, - configPathColumns: string, - configPathMaxColumns: string, - ): TemplateResult | void { - return this._putInSubmenu( - domain, - true, - 'config.common.display.editor_label', - 'mdi:palette-swatch', - html` - ${this._renderOptionSelector(configPathMode, this._displayModes, { - label: localize('config.common.display.mode'), - })} - ${this._renderOptionSelector( - configPathSelectedPosition, - this._gridSelectPositions, - { - label: localize('config.common.display.grid_selected_position'), - }, - )} - ${this._renderNumberInput(configPathSelectedWidthFactor, { - min: 0, - label: localize('config.common.display.grid_selected_width_factor'), - })} - ${this._renderNumberInput(configPathColumns, { - min: 0, - label: localize('config.common.display.grid_columns'), - })} - ${this._renderNumberInput(configPathMaxColumns, { - min: 0, - label: localize('config.common.display.grid_max_columns'), - })} - `, - ); - } - - /** - * Render the next & previous controls. - * @param domain The submenu domain. - * @param configPathStyle Next previous style config path. - * @param configPathSize Next previous size config path. - * @returns A rendered template. - */ - private _renderNextPreviousControls( - domain: string, - configPathStyle: string, - configPathSize: string, - configPathAutoHide: string, - options?: { - allowIcons?: boolean; - allowThumbnails?: boolean; - allowCall?: boolean; - }, - ): TemplateResult | void { - return this._putInSubmenu( - domain, - true, - 'config.common.controls.next_previous.editor_label', - 'mdi:arrow-right-bold-circle', - html` - ${this._renderOptionSelector( - configPathStyle, - this._nextPreviousControlStyles.filter( - (item) => - (!!options?.allowThumbnails || item.value !== 'thumbnails') && - (!!options?.allowIcons || item.value !== 'icons'), - ), - { - label: localize('config.common.controls.next_previous.style'), - }, - )} - ${this._renderNumberInput(configPathSize, { - min: BUTTON_SIZE_MIN, - label: localize('config.common.controls.next_previous.size'), - })} - ${this._renderOptionSelector( - configPathAutoHide, - this._autoHideConditions.filter( - (item) => !!options?.allowCall || item.value !== 'call', - ), - { - multiple: true, - label: localize('config.common.controls.next_previous.auto_hide'), - }, - )} - `, - ); - } - - /** - * Render the thumbnails controls. - * @param domain The submenu domain. - * @param configPathMode Thumbnails mode config path. - * @param configPathSize Thumbnails size config path. - * @param configPathShowDetails Thumbnails show details config path. - * @param configPathShowFavoriteControl Thumbnails show favorite control config path. - * @param configPathShowTimelineControl Thumbnails show timeline control config path, - * @param options An optional config path to media selection and mini-timeline mode. - * @returns A rendered template. - */ - private _renderThumbnailsControls( - domain: string, - configPathSize: string, - configPathShowDetails: string, - configPathShowFavoriteControl: string, - configPathShowTimelineControl: string, - configPathShowDownloadControl: string, - configPathShowReviewControl: string, - configPathShowInfoControl: string, - defaults: { - show_details: boolean; - show_favorite_control: boolean; - show_timeline_control: boolean; - show_download_control: boolean; - show_review_control: boolean; - show_info_control: boolean; - }, - options?: { - configPathMode?: string; - }, - ): TemplateResult | void { - return this._putInSubmenu( - domain, - true, - 'config.common.controls.thumbnails.editor_label', - 'mdi:image-text', - html` - ${options?.configPathMode - ? html`${this._renderOptionSelector( - options.configPathMode, - this._thumbnailModes, - { - label: localize('config.common.controls.thumbnails.mode'), - }, - )}` - : html``} - ${this._renderNumberInput(configPathSize, { - min: THUMBNAIL_WIDTH_MIN, - max: THUMBNAIL_WIDTH_MAX, - label: localize('config.common.controls.thumbnails.size'), - })} - ${this._renderSwitch(configPathShowDetails, defaults.show_details, { - label: localize('config.common.controls.thumbnails.show_details'), - })} - ${this._renderSwitch( - configPathShowFavoriteControl, - defaults.show_favorite_control, - { - label: localize('config.common.controls.thumbnails.show_favorite_control'), - }, - )} - ${this._renderSwitch( - configPathShowTimelineControl, - defaults.show_timeline_control, - { - label: localize('config.common.controls.thumbnails.show_timeline_control'), - }, - )} - ${this._renderSwitch( - configPathShowDownloadControl, - defaults.show_download_control, - { - label: localize('config.common.controls.thumbnails.show_download_control'), - }, - )} - ${this._renderSwitch(configPathShowReviewControl, defaults.show_review_control, { - label: localize('config.common.controls.thumbnails.show_review_control'), - })} - ${this._renderSwitch(configPathShowInfoControl, defaults.show_info_control, { - label: localize('config.common.controls.thumbnails.show_info_control'), - })} - `, - ); - } - - /** - * Render the thumbnails controls. - * @param domain The submenu domain. - * @param configPathMode Filter mode config path. - * @returns A rendered template. - */ - private _renderFilterControls( - domain: string, - configPathMode: string, - ): TemplateResult | void { - return this._putInSubmenu( - domain, - true, - 'config.common.controls.filter.editor_label', - 'mdi:filter-cog', - html` - ${configPathMode - ? html`${this._renderOptionSelector(configPathMode, this._filterModes, { - label: localize('config.common.controls.filter.mode'), - })}` - : html``} - `, - ); - } - - private _renderImageOptions( - configPathMode: string, - configPathUrl: string, - configPathEntity: string, - configPathEntityParameters: string, - configPathRefreshSeconds: string, - options?: { - proxyMenu?: TemplateResult; - }, - ): TemplateResult { - return html` - ${this._renderOptionSelector(configPathMode, this._imageModes, { - label: localize('config.common.image.mode'), - })} - ${this._renderStringInput(configPathUrl, { - label: localize('config.common.image.url'), - })} - ${this._renderOptionSelector( - configPathEntity, - this.hass ? getEntitiesFromHASS(this.hass) : [], - { - label: localize('config.common.image.entity'), - }, - )} - ${this._renderStringInput(configPathEntityParameters, { - label: localize('config.common.image.entity_parameters'), - })} - ${this._renderNumberInput(configPathRefreshSeconds, { - label: localize('config.common.image.refresh_seconds'), - })} - ${options?.proxyMenu ?? html``} - `; - } - - private _renderProxySubmenu( - domain: string, - key: unknown, - labelPath: string, - configPathDynamic: string, - dynamicDefault: boolean, - configPathSSLCiphers: string, - configPathSSLVerification: string, - options?: { - configPathEnabled?: string; - configPathLive?: string; - configPathMedia?: string; - enabledDefault?: boolean; - }, - ): TemplateResult { - return this._putInSubmenu( - domain, - key, - labelPath, - 'mdi:arrow-decision', - html` - ${options?.configPathEnabled !== undefined && - options.enabledDefault !== undefined - ? this._renderSwitch(options.configPathEnabled, options.enabledDefault, { - label: localize('config.common.proxy.modes.true'), - }) - : html``} - ${options?.configPathLive - ? this._renderOptionSelector(options.configPathLive, this._proxyModes, { - label: localize('config.cameras.proxy.live'), - }) - : html``} - ${options?.configPathMedia - ? this._renderOptionSelector(options.configPathMedia, this._proxyModes, { - label: localize('config.cameras.proxy.media'), - }) - : html``} - ${this._renderSwitch(configPathDynamic, dynamicDefault, { - label: localize('config.common.proxy.dynamic'), - })} - ${this._renderOptionSelector( - configPathSSLVerification, - this._proxySSLVerification, - { - label: localize('config.common.proxy.ssl_verification.editor_label'), - }, - )} - ${this._renderOptionSelector(configPathSSLCiphers, this._proxySSLCiphers, { - label: localize('config.common.proxy.ssl_ciphers.editor_label'), - })} - `, - ); - } - - private _modifyConfig(func: (config: RawAdvancedCameraCardConfig) => boolean): void { - if (this._config) { - const newConfig = copyConfig(this._config); - if (func(newConfig)) { - this._updateConfig(newConfig); - } - } - } - - private _renderArrayManagementControls( - configPathArray: string, - index: number, - menu: string, - add?: boolean, - ): TemplateResult | void { - const array = this._config ? getConfigValue(this._config, configPathArray) : null; - - return html` -
- - !add && - this._modifyConfig((config: RawAdvancedCameraCardConfig): boolean => { - const array = getConfigValue(config, configPathArray); - if (Array.isArray(array) && index > 0) { - arrayMove(array, index, index - 1); - this._openMenu(menu, index - 1); - return true; - } - return false; - })} - > - - - = array.length - 1} - @click=${() => - !add && - this._modifyConfig((config: RawAdvancedCameraCardConfig): boolean => { - const array = getConfigValue(config, configPathArray); - if (Array.isArray(array) && index < array.length - 1) { - arrayMove(array, index, index + 1); - this._openMenu(menu, index + 1); - return true; - } - return false; - })} - > - - - { - this._modifyConfig((config: RawAdvancedCameraCardConfig): boolean => { - const array = getConfigValue(config, configPathArray); - if (Array.isArray(array)) { - array.splice(index, 1); - this._closeMenu(menu); - return true; - } - return false; - }); - }} - > - - -
- `; - } - - private _renderFolder( - folders: RawAdvancedCameraCardConfigArray, - folderIndex: number, - addNewFolder?: boolean, - ): TemplateResult | void { - const submenuClasses = { - submenu: true, - selected: this._expandedMenus[MENU_FOLDERS] === folderIndex, - }; - - const folderTypes: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'ha', label: localize('config.folders.types.ha') }, - ]; - - return html`
- - ${this._expandedMenus[MENU_FOLDERS] === folderIndex - ? html`
- ${this._renderArrayManagementControls( - CONF_FOLDERS, - folderIndex, - MENU_FOLDERS, - addNewFolder, - )} - ${this._renderOptionSelector( - getArrayConfigPath(CONF_FOLDERS_ARRAY_TYPE, folderIndex), - folderTypes, - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_FOLDERS_ARRAY_TITLE, folderIndex), - )} - ${this._renderIconSelector( - getArrayConfigPath(CONF_FOLDERS_ARRAY_ICON, folderIndex), - { - label: localize('config.folders.icon'), - }, - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_FOLDERS_ARRAY_ID, folderIndex), - )} - ${this._putInSubmenu( - MENU_FOLDERS_HA, - folderIndex, - 'config.folders.ha.editor_label', - 'mdi:home-assistant', - html` - ${this._renderStringInput( - getArrayConfigPath(CONF_FOLDERS_ARRAY_HA_URL, folderIndex), - )} - ${renderNotificationBlock({ - body: { - text: localize('config.folders.ha.path_info'), - icon: 'mdi:information-outline', - }, - })} - `, - )} -
` - : ''} -
`; - } - - private _renderTriggerEvent( - cameraIndex: number, - events: RawAdvancedCameraCardConfigArray, - eventIndex: number, - addNewEvent?: boolean, - ): TemplateResult | void { - const eventsPath = getArrayConfigPath( - CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS, - cameraIndex, - ); - - const submenuClasses = { - submenu: true, - selected: this._expandedMenus[MENU_CAMERAS_TRIGGERS_EVENT] === eventIndex, - }; - const title = this._getEditorTriggerEventTitle(eventIndex, events[eventIndex] ?? {}); - const eventTypePath = `${eventsPath}.[${eventIndex}].event_type`; - - return html`
- - ${this._expandedMenus[MENU_CAMERAS_TRIGGERS_EVENT] === eventIndex - ? html`
- ${this._renderArrayManagementControls( - eventsPath, - eventIndex, - MENU_CAMERAS_TRIGGERS_EVENT, - addNewEvent, - )} - ${this._renderStringInput(eventTypePath, { - label: localize('config.cameras.triggers.events.event_type'), - })} - ${this._renderObjectSelector(`${eventsPath}.[${eventIndex}].event_data`, { - label: localize('config.cameras.triggers.events.event_data'), - })} -
` - : ''} -
`; - } - - private _renderTriggerEvents(cameraIndex: number): TemplateResult | void { - if (!this._config) { - return; - } - const events = - (getConfigValue( - this._config, - getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS, cameraIndex), - ) as RawAdvancedCameraCardConfigArray | undefined) ?? []; - - return this._putInSubmenu( - MENU_CAMERAS_TRIGGERS_EVENTS, - cameraIndex, - 'config.cameras.triggers.events.editor_label', - 'mdi:home-assistant', - html` - ${events.map((_, index) => this._renderTriggerEvent(cameraIndex, events, index))} - ${this._renderTriggerEvent(cameraIndex, events, events.length, true)} - `, - ); - } - - /** - * Render a camera section. - * @param cameras The full array of cameras. - * @param cameraIndex The index (in the array) to render. - * @param addNewCamera Whether or not this is a section to add a new non-existent camera. - * @returns A rendered template. - */ - private _renderCamera( - cameras: RawAdvancedCameraCardConfigArray, - cameraIndex: number, - entities: string[], - folders: RawAdvancedCameraCardConfigArray, - addNewCamera?: boolean, - ): TemplateResult | void { - const liveProviders: EditorSelectOption[] = [ - { value: '', label: '' }, - { value: 'auto', label: localize('config.cameras.live_providers.auto') }, - { value: 'ha', label: localize('config.cameras.live_providers.ha') }, - { - value: 'image', - label: localize('config.cameras.live_providers.image'), - }, - { - value: 'jsmpeg', - label: localize('config.cameras.live_providers.jsmpeg'), - }, - { - value: 'go2rtc', - label: localize('config.cameras.live_providers.go2rtc'), - }, - { - value: 'go2rtc-experimental', - label: localize('config.cameras.live_providers.go2rtc-experimental'), - }, - { - value: 'webrtc-card', - label: localize('config.cameras.live_providers.webrtc-card'), - }, - ]; - - const dependentCameras: EditorSelectOption[] = []; - cameras.forEach((camera, index) => { - if (index !== cameraIndex) { - dependentCameras.push({ - value: getCameraID(camera), - label: this._getEditorCameraTitle(index, camera), - }); - } - }); - - const folderOptions: EditorSelectOption[] = []; - folders.forEach((folder, index) => { - folderOptions.push({ - value: getFolderID(folder, index), - label: this._getEditorFolderTitle(index, folder), - }); - }); - - const submenuClasses = { - submenu: true, - selected: this._expandedMenus[MENU_CAMERAS] === cameraIndex, - }; - - return html` -
- - ${this._expandedMenus[MENU_CAMERAS] === cameraIndex - ? html`
- ${this._renderArrayManagementControls( - CONF_CAMERAS, - cameraIndex, - MENU_CAMERAS, - addNewCamera, - )} - ${this._renderEntitySelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_CAMERA_ENTITY, cameraIndex), - 'camera', - )} - ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_LIVE_PROVIDER, cameraIndex), - liveProviders, - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_TITLE, cameraIndex), - )} - ${this._renderIconSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_ICON, cameraIndex), - { - label: localize('config.cameras.icon'), - }, - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_ID, cameraIndex), - )} - ${this._renderSwitch( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_ALWAYS_ERROR_IF_ENTITY_UNAVAILABLE, - cameraIndex, - ), - this._defaults.cameras.always_error_if_entity_unavailable, - )} - ${this._putInSubmenu( - MENU_CAMERAS_ENGINE, - true, - 'config.cameras.engines.editor_label', - 'mdi:engine', - html`${this._putInSubmenu( - MENU_CAMERAS_FRIGATE, - cameraIndex, - 'config.cameras.frigate.editor_label', - 'frigate', - html` - ${this._renderStringInput( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME, - cameraIndex, - ), - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_FRIGATE_URL, cameraIndex), - )} - ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_FRIGATE_LABELS, cameraIndex), - [], - { - multiple: true, - label: localize('config.cameras.frigate.labels'), - }, - )} - ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_FRIGATE_ZONES, cameraIndex), - [], - { - multiple: true, - label: localize('config.cameras.frigate.zones'), - }, - )} - ${this._renderStringInput( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID, - cameraIndex, - ), - )} - `, - )} - ${this._putInSubmenu( - MENU_CAMERAS_MOTIONEYE, - cameraIndex, - 'config.cameras.motioneye.editor_label', - 'motioneye', - html` ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_MOTIONEYE_URL, cameraIndex), - )} - ${this._renderStringInput( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN, - cameraIndex, - ), - )} - ${this._renderStringInput( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN, - cameraIndex, - ), - )} - ${this._renderStringInput( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN, - cameraIndex, - ), - )} - ${this._renderStringInput( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN, - cameraIndex, - ), - )}`, - )} - ${this._putInSubmenu( - MENU_CAMERAS_REOLINK, - cameraIndex, - 'config.cameras.reolink.editor_label', - 'reolink', - html` ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_REOLINK_URL, cameraIndex), - )} - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION, - cameraIndex, - ), - this._reolinkMediaResolution, - { - label: localize( - 'config.cameras.reolink.media_resolution.editor_label', - ), - }, - )}`, - )}`, - )} - ${this._putInSubmenu( - MENU_CAMERAS_LIVE_PROVIDER, - true, - 'config.cameras.live_provider_options.editor_label', - 'mdi:cctv', - html` ${this._putInSubmenu( - MENU_CAMERAS_GO2RTC, - cameraIndex, - 'config.cameras.go2rtc.editor_label', - 'mdi:alpha-g-circle', - html`${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_GO2RTC_MODES, cameraIndex), - this._go2rtcModes, - { - multiple: true, - label: localize('config.cameras.go2rtc.modes.editor_label'), - }, - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_GO2RTC_STREAM, cameraIndex), - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_GO2RTC_URL, cameraIndex), - )} - ${this._renderNumberInput( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_GO2RTC_METADATA_FETCH_TIMEOUT_SECONDS, - cameraIndex, - ), - { - min: 0, - default: - this._defaults.cameras.go2rtc.metadata_fetch_timeout_seconds, - }, - )} `, - )} - ${this._putInSubmenu( - MENU_CAMERAS_IMAGE, - true, - 'config.cameras.image.editor_label', - 'mdi:image', - this._renderImageOptions( - getArrayConfigPath(CONF_CAMERAS_ARRAY_IMAGE_MODE, cameraIndex), - getArrayConfigPath(CONF_CAMERAS_ARRAY_IMAGE_URL, cameraIndex), - getArrayConfigPath(CONF_CAMERAS_ARRAY_IMAGE_ENTITY, cameraIndex), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_IMAGE_ENTITY_PARAMETERS, - cameraIndex, - ), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS, - cameraIndex, - ), - ), - )} - ${this._putInSubmenu( - MENU_CAMERAS_WEBRTC_CARD, - cameraIndex, - 'config.cameras.webrtc_card.editor_label', - 'mdi:webrtc', - html`${this._renderEntitySelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, - cameraIndex, - ), - 'camera', - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, cameraIndex), - )}`, - )}`, - )} - ${this._putInSubmenu( - MENU_CAMERAS_DEPENDENCIES, - cameraIndex, - 'config.cameras.dependencies.editor_label', - 'mdi:graph', - html` ${this._renderSwitch( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS, - cameraIndex, - ), - this._defaults.cameras.dependencies.all_cameras, - )} - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS, - cameraIndex, - ), - dependentCameras, - { - multiple: true, - }, - )}`, - )} - ${this._putInSubmenu( - MENU_CAMERAS_TRIGGERS, - cameraIndex, - 'config.cameras.triggers.editor_label', - 'mdi:magnify-scan', - html` - ${this._renderSwitch( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, - cameraIndex, - ), - this._defaults.cameras.triggers.occupancy, - )} - ${this._renderSwitch( - getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex), - this._defaults.cameras.triggers.motion, - )} - ${this._renderSwitch( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL, - cameraIndex, - ), - this._defaults.cameras.triggers.doorbell, - )} - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, - cameraIndex, - ), - entities, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_TRIGGERS_MEDIA_EVENTS, - cameraIndex, - ), - this._triggersMediaEvents, - { - multiple: true, - label: localize( - 'config.cameras.triggers.media_events.editor_label', - ), - }, - )} - ${this._putInSubmenu( - MENU_CAMERAS_TRIGGERS_REVIEWS, - cameraIndex, - 'config.cameras.triggers.reviews.editor_label', - 'mdi:check-circle', - html` - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES, - cameraIndex, - ), - this._severities, - { - multiple: true, - label: localize('common.severity'), - }, - )} - ${this._renderSwitch( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION, - cameraIndex, - ), - this._defaults.cameras.triggers.reviews.description, - )} - `, - )} - ${this._renderTriggerEvents(cameraIndex)} - `, - )} - ${this._putInSubmenu( - MENU_CAMERAS_MEDIA, - cameraIndex, - 'config.cameras.media.editor_label', - 'mdi:play-box-outline', - html` - ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_TYPE, cameraIndex), - this._cameraMediaTypes, - { - label: localize('config.cameras.media.type'), - }, - )} - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE, - cameraIndex, - ), - this._thumbnailEventsMediaTypes, - { - label: localize('config.cameras.media.events_type'), - }, - )} - ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_REVIEWED, cameraIndex), - this._cameraMediaReviewedOptions, - { - label: localize('config.cameras.media.reviewed'), - }, - )} - ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_MEDIA_FOLDERS, cameraIndex), - folderOptions, - { - multiple: true, - label: localize('config.cameras.media.folders'), - }, - )} - `, - )} - ${this._putInSubmenu( - MENU_CAMERAS_CAST, - cameraIndex, - 'config.cameras.cast.editor_label', - 'mdi:cast', - html` - ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_CAST_METHOD, cameraIndex), - this._castMethods, - )} - ${this._renderStringInput( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH, - cameraIndex, - ), - )} - ${this._renderStringInput( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH, - cameraIndex, - ), - )} - `, - )} - ${this._putInSubmenu( - MENU_CAMERAS_DIMENSIONS, - cameraIndex, - 'config.cameras.dimensions.editor_label', - 'mdi:aspect-ratio', - html` - ${this._renderStringInput( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO, - cameraIndex, - ), - )} - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_ROTATION, - cameraIndex, - ), - this._rotations, - )} - ${this._renderMediaLayout( - MENU_CAMERAS_DIMENSIONS_LAYOUT, - 'config.cameras.dimensions.layout.editor_label', - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT, - cameraIndex, - ), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X, - cameraIndex, - ), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y, - cameraIndex, - ), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP, - cameraIndex, - ), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM, - cameraIndex, - ), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT, - cameraIndex, - ), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT, - cameraIndex, - ), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR, - cameraIndex, - ), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X, - cameraIndex, - ), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y, - cameraIndex, - ), - )} - `, - )} - ${this._putInSubmenu( - MENU_CAMERAS_CAPABILITIES, - cameraIndex, - 'config.cameras.capabilities.editor_label', - 'mdi:cog-stop', - html` - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE, - cameraIndex, - ), - this._capabilities, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT, - cameraIndex, - ), - this._capabilities, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - getArrayConfigPath( - CONF_CAMERAS_ARRAY_CAPABILITIES_FORCE, - cameraIndex, - ), - this._forceableCapabilities, - { - multiple: true, - }, - )} - `, - )} - ${this._renderProxySubmenu( - MENU_CAMERAS_PROXY, - cameraIndex, - 'config.cameras.proxy.editor_label', - getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, cameraIndex), - this._defaults.cameras.proxy.dynamic, - getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS, cameraIndex), - getArrayConfigPath( - CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION, - cameraIndex, - ), - { - configPathLive: getArrayConfigPath( - CONF_CAMERAS_ARRAY_PROXY_LIVE, - cameraIndex, - ), - configPathMedia: getArrayConfigPath( - CONF_CAMERAS_ARRAY_PROXY_MEDIA, - cameraIndex, - ), - }, - )} -
` - : ``} -
- `; - } - - /** - * Render a string input field. - * @param configPath The configuration path to set/read. - * @param type The allowable input - * @returns A rendered template. - */ - private _renderStringInput( - configPath: string, - params?: { - label?: string; - type?: - | 'number' - | 'text' - | 'search' - | 'tel' - | 'url' - | 'email' - | 'password' - | 'date' - | 'month' - | 'week' - | 'time' - | 'datetime-local' - | 'color'; - }, - ): TemplateResult | void { - if (!this._config) { - return; - } - - return html` - this._valueChangedHandler(configPath, ev)} - > - - `; - } - - private _renderObjectSelector( - configPath: string, - params?: { - label?: string; - }, - ): TemplateResult | void { - if (!this._config) { - return; - } - - return html` - this._valueChangedHandler(configPath, ev)} - > - - `; - } - - /** - * Render a boolean selector. - * @param configPath The configuration path to set/read. - * @param valueDefault The default switch value if unset. - * @param params Optional parameters to control the selector. - * @returns A rendered template. - */ - private _renderSwitch( - configPath: string, - valueDefault: boolean, - params?: { - label?: string; - }, - ): TemplateResult | void { - if (!this._config) { - return; - } - - return html` - this._valueChangedHandler(configPath, ev)} - > - - `; - } - - private _updateConfig(config: RawAdvancedCameraCardConfig): void { - this._config = config; - fireHASSEvent(this, 'config-changed', { config: this._config }); - } - protected render(): TemplateResult | void { - if (!this.hass || !this._config) { + const hass = this.hass; + if (!hass || !this._controller.getConfig()) { return html``; } - const entities = getEntitiesFromHASS(this.hass); - const cameras = (getConfigValue(this._config, CONF_CAMERAS) || - []) as RawAdvancedCameraCardConfigArray; - const folders = (getConfigValue(this._config, CONF_FOLDERS) || - []) as RawAdvancedCameraCardConfigArray; + // Built once and given to every section: the sections compare what they are + // given against what they last had, and an identical object makes that + // comparison a reference check. + const input = this._controller.getFormsInput(); return html` -
- ${this._renderOptionSetHeader('cameras')} - ${this._expandedMenus[MENU_OPTIONS] === 'cameras' - ? html` -
- ${SECTION_DOC_LINKS['cameras'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['cameras']) - : ''} - ${cameras.map((_, index) => - this._renderCamera(cameras, index, entities, folders), - )} - ${this._renderCamera(cameras, cameras.length, entities, folders, true)} -
- ` - : ''} - ${this._renderOptionSetHeader('profiles')} - ${this._expandedMenus[MENU_OPTIONS] === 'profiles' - ? html`
- ${this._renderOptionSelector(CONF_PROFILES, this._profiles, { - multiple: true, - label: localize('config.profiles.editor_label'), - })} -
` - : ''} - ${this._renderOptionSetHeader('view')} - ${this._expandedMenus[MENU_OPTIONS] === 'view' - ? html` -
- ${SECTION_DOC_LINKS['view'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['view']) - : ''} - ${this._renderOptionSelector(CONF_VIEW_DEFAULT, this._viewModes)} - ${this._renderOptionSelector( - CONF_VIEW_CAMERA_SELECT, - this._cameraSelectViewModes, - )} - ${this._renderSwitch(CONF_VIEW_DIM, this._defaults.view.dim)} - ${this._renderNumberInput(CONF_VIEW_INTERACTION_SECONDS)} - ${this._renderSwitch( - CONF_VIEW_DEFAULT_CYCLE_CAMERA, - this._defaults.view.default_cycle_camera, - )} - ${[ - this._renderViewDefaultResetMenu(), - this._renderViewIssuesMenu(), - this._renderViewTriggersMenu(), - this._renderViewKeyboardShortcutMenu(), - ]} - ${this._renderOptionSelector(CONF_VIEW_THEME_THEMES, this._themes, { - label: localize('config.view.theme.themes.editor_label'), - multiple: true, - })} -
- ` - : ''} - ${this._renderOptionSetHeader('menu')} - ${this._expandedMenus[MENU_OPTIONS] === 'menu' - ? html` -
- ${SECTION_DOC_LINKS['menu'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['menu']) - : ''} - ${this._renderOptionSelector(CONF_MENU_STYLE, this._menuStyles)} - ${this._renderOptionSelector(CONF_MENU_POSITION, this._menuPositions)} - ${this._renderOptionSelector(CONF_MENU_ALIGNMENT, this._menuAlignments)} - ${this._renderOptionSelector( - CONF_MENU_AUTO_HIDE, - this._autoHideConditions, - { - multiple: true, - label: localize('config.menu.auto_hide'), - }, - )} - ${this._renderNumberInput(CONF_MENU_BUTTON_SIZE, { - min: BUTTON_SIZE_MIN, - })} - ${[ - this._renderMenuButton('iris'), - this._renderMenuButton('call'), - this._renderMenuButton('camera_ui'), - this._renderMenuButton('cameras'), - this._renderMenuButton('clips'), - this._renderMenuButton('display_mode'), - this._renderMenuButton('download'), - this._renderMenuButton('expand'), - this._renderMenuButton('folders'), - this._renderMenuButton('fullscreen'), - this._renderMenuButton('gallery'), - this._renderMenuButton('image'), - this._renderMenuButton('info'), - this._renderMenuButton('live'), - this._renderMenuButton('media_player'), - this._renderMenuButton( - 'microphone', - html`${this._renderOptionSelector( - `${CONF_MENU_BUTTONS}.microphone.type`, - this._microphoneButtonTypes, - { label: localize('config.menu.buttons.type') }, - )}`, - ), - this._renderMenuButton('mute'), - this._renderMenuButton('pip'), - this._renderMenuButton('play'), - this._renderMenuButton('ptz_controls'), - this._renderMenuButton('ptz_home'), - this._renderMenuButton('recordings'), - this._renderMenuButton('reviews'), - this._renderMenuButton('screenshot'), - this._renderMenuButton('set_review'), - this._renderMenuButton('snapshots'), - this._renderMenuButton('substreams'), - this._renderMenuButton('timeline'), - ]} -
- ` - : ''} - ${this._renderOptionSetHeader('status_bar')} - ${this._expandedMenus[MENU_OPTIONS] === 'status_bar' - ? html` -
- ${SECTION_DOC_LINKS['status_bar'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['status_bar']) - : ''} - ${this._renderOptionSelector( - CONF_STATUS_BAR_STYLE, - this._statusBarStyles, - )} - ${this._renderOptionSelector( - CONF_STATUS_BAR_POSITION, - this._statusBarPositions, - )} - ${this._renderOptionSelector( - CONF_STATUS_BAR_AUTO_HIDE, - this._autoHideConditions, - { - multiple: true, - label: localize('config.status_bar.auto_hide'), - }, - )} - ${this._renderNumberInput(CONF_STATUS_BAR_HEIGHT, { - min: STATUS_BAR_HEIGHT_MIN, - label: localize('config.status_bar.height'), - })} - ${this._renderNumberInput(CONF_STATUS_BAR_POPUP_SECONDS, { - min: 0, - max: 60, - default: this._defaults.status_bar.popup_seconds, - label: localize('config.status_bar.popup_seconds'), - })} - ${[ - this._renderStatusBarItem('title'), - this._renderStatusBarItem('resolution'), - this._renderStatusBarItem('technology'), - this._renderStatusBarItem('engine'), - this._renderStatusBarItem('severity'), - this._renderStatusBarItem('issues'), - ]} -
- ` - : ''} - ${this._renderOptionSetHeader('live')} - ${this._expandedMenus[MENU_OPTIONS] === 'live' - ? html` -
- ${SECTION_DOC_LINKS['live'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['live']) - : ''} - ${this._renderSwitch(CONF_LIVE_PRELOAD, this._defaults.live.preload)} - ${this._renderSwitch(CONF_LIVE_DRAGGABLE, this._defaults.live.draggable)} - ${this._renderSwitch(CONF_LIVE_ZOOMABLE, this._defaults.live.zoomable)} - ${this._renderSwitch(CONF_LIVE_LAZY_LOAD, this._defaults.live.lazy_load)} - ${this._renderOptionSelector( - CONF_LIVE_LAZY_UNLOAD, - this._mediaActionNegativeConditions, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - CONF_LIVE_AUTO_PLAY, - this._mediaActionPositiveConditions, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - CONF_LIVE_AUTO_PAUSE, - this._mediaActionNegativeConditions, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - CONF_LIVE_AUTO_MUTE, - this._mediaLiveMuteConditions, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - CONF_LIVE_AUTO_UNMUTE, - this._mediaLiveUnmuteConditions, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - CONF_LIVE_TRANSITION_EFFECT, - this._transitionEffects, - )} - ${this._renderSwitch( - CONF_LIVE_SHOW_IMAGE_DURING_LOAD, - this._defaults.live.show_image_during_load, - )} - ${this._renderViewDisplay( - MENU_LIVE_DISPLAY, - CONF_LIVE_DISPLAY_MODE, - CONF_LIVE_DISPLAY_GRID_SELECTED_POSITION, - CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, - CONF_LIVE_DISPLAY_GRID_COLUMNS, - CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS, - )} - ${this._putInSubmenu( - MENU_LIVE_CONTROLS, - true, - 'config.live.controls.editor_label', - 'mdi:gamepad', - html` - ${this._renderSwitch( - CONF_LIVE_CONTROLS_BUILTIN, - this._defaults.live.controls.builtin, - { - label: localize('config.common.controls.builtin'), - }, - )} - ${this._renderSwitch( - CONF_LIVE_CONTROLS_WHEEL, - this._defaults.live.controls.wheel, - { - label: localize('config.common.controls.wheel'), - }, - )} - ${this._putInSubmenu( - MENU_LIVE_CONTROLS_CALL, - true, - 'config.live.controls.call.editor_label', - 'mdi:phone', - html` - ${this._renderSwitch( - CONF_LIVE_CONTROLS_CALL_LOCK, - this._defaults.live.controls.call.lock, - )} - ${this._renderOptionSelector( - CONF_LIVE_CONTROLS_CALL_RINGTONE_TYPE, - this._callRingtoneTypes, - { - label: localize('config.live.controls.call.ringtone.type'), - }, - )} - ${this._renderStringInput(CONF_LIVE_CONTROLS_CALL_RINGTONE_URL, { - label: localize('config.live.controls.call.ringtone.url'), - })} - ${this._renderNumberInput( - CONF_LIVE_CONTROLS_CALL_RINGTONE_REPEAT, - { - min: 0, - default: this._defaults.live.controls.call.ringtone.repeat, - }, - )} - ${this._renderNumberInput( - CONF_LIVE_CONTROLS_CALL_UNANSWERED_TIMEOUT_SECONDS, - { - min: 0, - default: - this._defaults.live.controls.call - .unanswered_timeout_seconds, - }, - )} - ${this._renderNumberInput(CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE, { - min: BUTTON_SIZE_MIN, - })} - `, - )} - ${this._renderNextPreviousControls( - MENU_LIVE_CONTROLS_NEXT_PREVIOUS, - CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, - CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, - CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE, - { - allowIcons: true, - allowCall: true, - }, - )} - ${this._renderThumbnailsControls( - MENU_LIVE_CONTROLS_THUMBNAILS, - CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, - CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, - this._defaults.live.controls.thumbnails, - { - configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE, - }, - )} - ${this._renderMiniTimeline( - MENU_LIVE_CONTROLS_TIMELINE, - MENU_LIVE_CONTROLS_TIMELINE_FORMAT, - CONF_LIVE_CONTROLS_TIMELINE_MODE, - CONF_LIVE_CONTROLS_TIMELINE_STYLE, - CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, - CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, - CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, - CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H, - this._defaults.live.controls.timeline.show_recordings, - this._defaults.live.controls.timeline.format['24h'], - CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE, - )} - ${this._putInSubmenu( - MENU_LIVE_CONTROLS_PTZ, - true, - 'config.live.controls.ptz.editor_label', - 'mdi:pan', - html` - ${this._renderOptionSelector( - CONF_LIVE_CONTROLS_PTZ_MODE, - this._ptzModes, - )} - ${this._renderOptionSelector( - CONF_LIVE_CONTROLS_PTZ_TYPE, - this._ptzTypes, - )} - ${this._renderOptionSelector( - CONF_LIVE_CONTROLS_PTZ_POSITION, - this._ptzPositions, - )} - ${this._renderOptionSelector( - CONF_LIVE_CONTROLS_PTZ_ORIENTATION, - this._ptzOrientations, - )} - ${this._renderSwitch( - CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT, - this._defaults.live.controls.ptz.hide_pan_tilt, - { - label: localize('config.live.controls.ptz.hide_pan_tilt'), - }, - )} - ${this._renderSwitch( - CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM, - this._defaults.live.controls.ptz.hide_pan_tilt, - { - label: localize('config.live.controls.ptz.hide_zoom'), - }, - )} - ${this._renderSwitch( - CONF_LIVE_CONTROLS_PTZ_HIDE_HOME, - this._defaults.live.controls.ptz.hide_home, - { - label: localize('config.live.controls.ptz.hide_home'), - }, - )} - ${this._renderSwitch( - CONF_LIVE_CONTROLS_PTZ_HIDE_TYPE, - this._defaults.live.controls.ptz.hide_type, - { - label: localize('config.live.controls.ptz.hide_type'), - }, - )} - `, - )} - `, - )} - ${this._putInSubmenu( - MENU_LIVE_MICROPHONE, - true, - 'config.live.microphone.editor_label', - 'mdi:microphone', - html` - ${this._renderNumberInput(CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS)} - ${this._renderSwitch( - CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED, - this._defaults.live.microphone.always_connected, - )} - ${this._renderOptionSelector( - CONF_LIVE_MICROPHONE_AUTO_MUTE, - this._microphoneMuteConditions, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - CONF_LIVE_MICROPHONE_AUTO_UNMUTE, - this._microphoneUnmuteConditions, - { - multiple: true, - }, - )} - ${this._renderNumberInput( - CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS, - )} - `, - )} -
- ` - : ''} - ${this._renderOptionSetHeader('folders')} - ${this._expandedMenus[MENU_OPTIONS] === 'folders' - ? html`
- ${SECTION_DOC_LINKS['folders'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['folders']) - : ''} - ${folders.map((_, index) => this._renderFolder(folders, index))} - ${this._renderFolder(folders, folders.length, true)} -
` - : ''} - ${this._renderOptionSetHeader('media_gallery')} - ${this._expandedMenus[MENU_OPTIONS] === 'media_gallery' - ? html`
- ${SECTION_DOC_LINKS['media_gallery'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['media_gallery']) - : ''} - ${this._renderThumbnailsControls( - MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, - CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, - this._defaults.media_gallery.controls.thumbnails, - )} - ${this._renderFilterControls( - MENU_MEDIA_GALLERY_CONTROLS_FILTER, - CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE, - )} -
` - : ''} - ${this._renderOptionSetHeader('media_viewer')} - ${this._expandedMenus[MENU_OPTIONS] === 'media_viewer' - ? html`
- ${SECTION_DOC_LINKS['media_viewer'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['media_viewer']) - : ''} - ${this._renderOptionSelector( - CONF_MEDIA_VIEWER_AUTO_PLAY, - this._mediaActionPositiveConditions, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - CONF_MEDIA_VIEWER_AUTO_PAUSE, - this._mediaActionNegativeConditions, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - CONF_MEDIA_VIEWER_AUTO_MUTE, - this._mediaActionNegativeConditions, - { - multiple: true, - }, - )} - ${this._renderOptionSelector( - CONF_MEDIA_VIEWER_AUTO_UNMUTE, - this._mediaActionPositiveConditions, - { - multiple: true, - }, - )} - ${this._renderSwitch( - CONF_MEDIA_VIEWER_DRAGGABLE, - this._defaults.media_viewer.draggable, - )} - ${this._renderSwitch( - CONF_MEDIA_VIEWER_ZOOMABLE, - this._defaults.media_viewer.zoomable, - )} - ${this._renderSwitch( - CONF_MEDIA_VIEWER_LAZY_LOAD, - this._defaults.media_viewer.lazy_load, - )} - ${this._renderOptionSelector( - CONF_MEDIA_VIEWER_TRANSITION_EFFECT, - this._transitionEffects, - )} - ${this._renderSwitch( - CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, - this._defaults.media_viewer.snapshot_click_plays_clip, - )} - ${this._renderViewDisplay( - MENU_MEDIA_VIEWER_DISPLAY, - CONF_MEDIA_VIEWER_DISPLAY_MODE, - CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_POSITION, - CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, - CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS, - CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS, - )} - ${this._putInSubmenu( - MENU_MEDIA_VIEWER_CONTROLS, - true, - 'config.media_viewer.controls.editor_label', - 'mdi:gamepad', - html` - ${this._renderSwitch( - CONF_MEDIA_VIEWER_CONTROLS_BUILTIN, - this._defaults.media_viewer.controls.builtin, - { - label: localize('config.common.controls.builtin'), - }, - )} - ${this._renderSwitch( - CONF_MEDIA_VIEWER_CONTROLS_WHEEL, - this._defaults.media_viewer.controls.wheel, - { - label: localize('config.common.controls.wheel'), - }, - )} - ${this._renderNextPreviousControls( - MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS, - CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, - CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, - CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE, - { - allowThumbnails: true, - }, - )} - ${this._renderThumbnailsControls( - MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, - CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, - this._defaults.media_viewer.controls.thumbnails, - { - configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, - }, - )} - ${this._renderMiniTimeline( - MENU_MEDIA_VIEWER_CONTROLS_TIMELINE, - MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H, - this._defaults.media_viewer.controls.timeline.show_recordings, - this._defaults.media_viewer.controls.timeline.format['24h'], - CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE, - )} - `, - )} -
` - : ''} - ${this._renderOptionSetHeader('image')} - ${this._expandedMenus[MENU_OPTIONS] === 'image' - ? html`
- ${SECTION_DOC_LINKS['image'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['image']) - : ''} - ${this._renderImageOptions( - CONF_IMAGE_MODE, - CONF_IMAGE_URL, - CONF_IMAGE_ENTITY, - CONF_IMAGE_ENTITY_PARAMETERS, - CONF_IMAGE_REFRESH_SECONDS, - { - proxyMenu: this._renderProxySubmenu( - MENU_IMAGE_PROXY, - true, - 'config.common.image.proxy.editor_label', - CONF_IMAGE_PROXY_DYNAMIC, - this._defaults.image.proxy.dynamic, - CONF_IMAGE_PROXY_SSL_CIPHERS, - CONF_IMAGE_PROXY_SSL_VERIFICATION, - { - configPathEnabled: CONF_IMAGE_PROXY_ENABLED, - enabledDefault: this._defaults.image.proxy.enabled, - }, - ), - }, - )} -
` - : ''} - ${this._renderOptionSetHeader('timeline')} - ${this._expandedMenus[MENU_OPTIONS] === 'timeline' - ? html`
- ${SECTION_DOC_LINKS['timeline'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['timeline']) - : ''} - ${this._renderTimelineCoreControls( - MENU_TIMELINE_FORMAT, - CONF_TIMELINE_STYLE, - CONF_TIMELINE_WINDOW_SECONDS, - CONF_TIMELINE_CLUSTERING_THRESHOLD, - CONF_TIMELINE_SHOW_RECORDINGS, - CONF_TIMELINE_FORMAT_24H, - this._defaults.timeline.show_recordings, - this._defaults.timeline.format['24h'], - )} - ${this._renderThumbnailsControls( - MENU_TIMELINE_CONTROLS_THUMBNAILS, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL, - CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL, - this._defaults.timeline.controls.thumbnails, - { - configPathMode: CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, - }, - )} -
` - : ''} - ${this._renderOptionSetHeader('dimensions')} - ${this._expandedMenus[MENU_OPTIONS] === 'dimensions' - ? html`
- ${SECTION_DOC_LINKS['dimensions'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['dimensions']) - : ''} - ${this._renderOptionSelector( - CONF_DIMENSIONS_ASPECT_RATIO_MODE, - this._aspectRatioModes, - )} - ${this._renderStringInput(CONF_DIMENSIONS_ASPECT_RATIO)} - ${this._renderStringInput(CONF_DIMENSIONS_HEIGHT)} -
` - : ''} - ${this._renderOptionSetHeader( - 'performance', - getConfigValue(this._config, CONF_PERFORMANCE_PROFILE) === 'low' - ? 'warning' - : undefined, - )} - ${this._expandedMenus[MENU_OPTIONS] === 'performance' - ? html`
- ${getConfigValue(this._config, CONF_PERFORMANCE_PROFILE) === 'low' - ? this._renderInfo(localize('config.performance.warning')) - : html``} - ${this._putInSubmenu( - MENU_PERFORMANCE_FEATURES, - true, - 'config.performance.features.editor_label', - 'mdi:feature-search', - html` - ${this._renderSwitch( - CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR, - this._defaults.performance.features.card_loading_indicator, - )} - ${this._renderSwitch( - CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS, - this._defaults.performance.features.card_loading_effects, - )} - ${this._renderSwitch( - CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, - this._defaults.performance.features.animated_progress_indicator, - )} - ${this._renderNumberInput(CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE, { - max: MEDIA_CHUNK_SIZE_MAX, - })} - ${this._renderNumberInput( - CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS, - { - min: 1, - }, - )} - `, - )} - ${this._putInSubmenu( - MENU_PERFORMANCE_STYLE, - true, - 'config.performance.style.editor_label', - 'mdi:palette-swatch-variant', - html` - ${this._renderSwitch( - CONF_PERFORMANCE_STYLE_BORDER_RADIUS, - this._defaults.performance.style.border_radius, - )} - ${this._renderSwitch( - CONF_PERFORMANCE_STYLE_BOX_SHADOW, - this._defaults.performance.style.box_shadow, - )} - `, - )} -
` - : ''} - ${this._renderOptionSetHeader('remote_control')} - ${this._expandedMenus[MENU_OPTIONS] === 'remote_control' - ? html`
- ${SECTION_DOC_LINKS['remote_control'] - ? this._renderDocLinkRow(SECTION_DOC_LINKS['remote_control']) - : ''} - ${this._putInSubmenu( - MENU_REMOTE_CONTROL_ENTITIES, - true, - 'config.remote_control.entities.editor_label', - 'mdi:devices', - html` - ${this._renderEntitySelector( - CONF_REMOTE_CONTROL_ENTITIES_CAMERA, - 'input_select', - )} - `, - )} -
` - : ''} - ${this._config['overrides'] !== undefined - ? html` ${this._renderOptionSetHeader('overrides')} - ${this._expandedMenus[MENU_OPTIONS] === 'overrides' - ? html`
- ${this._renderInfo(localize('config.overrides.info'))} -
` - : ''}` - : html``} -
- ${this._configUpgradeable - ? html` { - if (this._config) { - const upgradedConfig = copyConfig(this._config); - upgradeConfig(upgradedConfig); - this._updateConfig(upgradedConfig); - } - }} - > - ${localize('editor.upgrade')} - ` - : ''} - { - fireAdvancedCameraCardEvent(this, 'editor:diagnostics'); - }} - > - ${localize('editor.toggle_diagnostics')} - -
+ ${this._renderSelectorSideload()} +
) => + this._controller.applyIntent(ev.detail)} + > + ${this._renderNotices()} + ${Object.keys(SECTIONS).map((name) => this._renderSection(name, hass, input))} + ${this._renderActionButtons()}
`; } - /** - * Close the editor menu with the given domain. - * @param targetDomain The menu domain to close. - */ - private _closeMenu(targetDomain: string) { - delete this._expandedMenus[targetDomain]; - this.requestUpdate(); + private _renderSection( + name: string, + hass: HomeAssistant, + input: FormsInput, + ): TemplateResult { + const section = SECTIONS[name]; + const request: FormRequest = { kind: 'section', name }; + const renderCustomContent = section.renderCustomContent; + + return html` + renderCustomContent(hass, input) + : undefined} + > + `; } - /** - * Open an editor menu. - * @param targetDomain The menu domain to open. - * @param key The menu object key to open. - */ - private _openMenu(targetDomain: string, key: number | string) { - this._expandedMenus[targetDomain] = key; - this.requestUpdate(); + // Card-state notices rendered as banners above the sections (matching how + // native Home Assistant editors surface such notes). + private _renderNotices(): TemplateResult { + return html`${this._controller + .getNotices() + .map( + (notice) => + html`${notice.message}`, + )}`; } - /** - * Toggle an editor menu. - * @param ev An event. - */ - private _toggleMenu(ev: { target: EditorMenuTarget | null }): void { - if (ev && ev.target) { - const domain = ev.target.domain; - const key = ev.target.key; - - if (this._expandedMenus[domain] === key) { - this._closeMenu(domain); - } else { - this._openMenu(domain, key); - } - } + private _renderActionButtons(): TemplateResult { + return html` +
+ ${this._controller.isConfigUpgradeable() + ? html` this._controller.upgrade()} + > + ${localize('editor.upgrade')} + ` + : ''} + { + fireAdvancedCameraCardEvent(this, 'editor:diagnostics'); + }} + > + ${localize('editor.toggle_diagnostics')} + +
+ `; } - /** - * Handle a changed option value. - * @param ev Event triggering the change. - */ - private _valueChangedHandler(key: string, ev: CustomEvent<{ value: unknown }>): void { - if (!this._config || !this.hass) { - return; - } - - let value; - if (ev.detail && ev.detail.value !== undefined) { - value = ev.detail.value; - if (typeof value === 'string') { - value = value.trim(); - } - } - if (getConfigValue(this._config, key) === value) { - return; - } - - const newConfig = copyConfig(this._config); - if (value === '' || value === undefined) { - deleteConfigValue(newConfig, key); - } else { - setConfigValue(newConfig, key, value); - } - this._updateConfig(newConfig); + // `ha-form` and `ha-selector` lazily `import()` their per-type sub-elements + // on first use. A section rendered before those imports resolve is not laid + // out in time for its `ha-expansion-panel` to measure the correct height, so + // a hidden form covering every element type the editor uses is rendered up + // front to resolve the imports before the user opens anything. + private _renderSelectorSideload(): TemplateResult { + return html` + + `; } static get styles(): CSSResultGroup { diff --git a/src/ha/custom-icons.ts b/src/ha/custom-icons.ts new file mode 100644 index 00000000..b02d8685 --- /dev/null +++ b/src/ha/custom-icons.ts @@ -0,0 +1,64 @@ +// Registers the card's custom icons as a Home Assistant custom iconset, so +// any `ha-icon` (and the icon picker) can render `advanced-camera-card:` +// icons -- inside or outside the card. The iconset format supports a single +// monochrome SVG path per icon, so each asset is a single-path silhouette, +// with its path data extracted at build time. + +import frigateIcon from '../images/icons/frigate.svg'; +import irisIcon from '../images/icons/iris.svg'; +import motioneyeIcon from '../images/icons/motioneye.svg'; +import reolinkIcon from '../images/icons/reolink.svg'; +import tplinkIcon from '../images/icons/tplink.svg'; + +interface CustomIconsetIcon { + path: string; + viewBox?: string; +} + +interface CustomIconsetListItem { + name: string; +} + +interface CustomIconsetHelpers { + getIcon: (name: string) => Promise; + getIconList: () => Promise; +} + +declare global { + interface Window { + customIcons?: Record; + } +} + +export const CUSTOM_ICONSET_PREFIX = 'advanced-camera-card'; + +const ICONS: Record = { + frigate: frigateIcon, + iris: irisIcon, + motioneye: motioneyeIcon, + reolink: reolinkIcon, + tplink: tplinkIcon, +}; + +export const CUSTOM_ICON_NAMES = Object.keys(ICONS); + +const getIcon = async (name: string): Promise => { + const icon: CustomIconsetIcon | undefined = ICONS[name]; + if (!icon) { + throw new Error(`Unknown icon: ${CUSTOM_ICONSET_PREFIX}:${name}`); + } + return icon; +}; + +const getIconList = async (): Promise => + CUSTOM_ICON_NAMES.map((name) => ({ name })); + +/** + * Register the card's custom iconset with Home Assistant. Safe to call more + * than once; an existing registration (e.g. from a second loaded copy of the + * card) is left in place. + */ +export const registerCustomIconset = (): void => { + window.customIcons ??= {}; + window.customIcons[CUSTOM_ICONSET_PREFIX] ??= { getIcon, getIconList }; +}; diff --git a/src/ha/side-load-ha-elements.ts b/src/ha/side-load-ha-elements.ts index cd6dfcae..77664cbe 100644 --- a/src/ha/side-load-ha-elements.ts +++ b/src/ha/side-load-ha-elements.ts @@ -18,17 +18,24 @@ class HomeAssistantElementsLoadError extends AdvancedCameraCardError { */ export const sideLoadHomeAssistantElements = async (): Promise => { const neededElements = [ + 'ha-alert', 'ha-button', 'ha-camera-stream', 'ha-card', 'ha-combo-box', 'ha-dropdown-item', 'ha-dropdown', + 'ha-expansion-panel', + 'ha-form', 'ha-hls-player', + 'ha-icon-button-prev', 'ha-icon-button', 'ha-icon', + 'ha-md-list-item', + 'ha-md-list', 'ha-menu-button', 'ha-selector', + 'ha-sortable', 'ha-spinner', 'ha-state-icon', 'ha-web-rtc-player', diff --git a/src/ha/types.ts b/src/ha/types.ts index a85c4f5e..147b66f2 100644 --- a/src/ha/types.ts +++ b/src/ha/types.ts @@ -254,3 +254,111 @@ export const resolvedMediaSchema = z.object({ mime_type: z.string(), }); export type ResolvedMedia = z.infer; + +// ************************************************************************* +// Home Assistant form types. +// ************************************************************************* +// Home Assistant does not publish frontend types as a package, so the subset +// of the `ha-form` schema and `ha-selector` configuration shapes used by this +// card are declared here. They must remain structurally compatible with: +// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-form/types.ts +// https://github.com/home-assistant/frontend/blob/dev/src/data/selector.ts + +interface HAEntitySelector { + entity: { + domain?: string; + multiple?: boolean; + }; +} + +export interface HASelectSelectorOption { + value: unknown; + label: string; +} + +export interface HASelectSelector { + select: { + mode?: 'dropdown' | 'list'; + multiple?: boolean; + custom_value?: boolean; + options: string[] | HASelectSelectorOption[]; + }; +} + +interface HAStringSelector { + text: { + type?: string; + }; +} + +export interface HANumberSelector { + number: { + min?: number; + max?: number; + mode?: 'box' | 'slider'; + step?: number; + }; +} + +interface HABooleanSelector { + boolean: Record; +} + +interface HAIconSelector { + icon: Record; +} + +interface HAObjectSelector { + object: Record; +} + +type HASelector = + | HABooleanSelector + | HAEntitySelector + | HAIconSelector + | HANumberSelector + | HAObjectSelector + | HASelectSelector + | HAStringSelector; + +interface StockHAFormBaseSchema { + name: string; +} + +interface StockHAFormSelectorSchema extends StockHAFormBaseSchema { + selector: HASelector; +} + +interface StockHAFormExpandableSchema extends StockHAFormBaseSchema { + type: 'expandable'; + title?: string; + icon?: string; + schema: readonly HAFormSchema[]; +} + +// Extensions layered onto the stock shapes; not part of `ha-form` itself. +interface CardHAFormSchemaExtensions { + // Override the standard path-derived label, e.g. to point shared fields at + // `config.common.*` rather than duplicating keys under each section. + label?: string; +} + +export interface HAFormSelectorSchema + extends StockHAFormSelectorSchema, + CardHAFormSchemaExtensions {} + +export interface HAFormExpandableSchema + extends Omit, + CardHAFormSchemaExtensions { + // hass-frontend requires `name`, but `ha-form`'s runtime treats a missing name + // as a flattened, visual-only group (the `!item.name` paths in ha-form.ts). + // Relaxed to optional here for nameless grouping, e.g. an "Engine" group over + // the top-level `frigate`/`motioneye` camera keys. + name?: string; + + // Explicit documentation-link key, for a nameless group whose link cannot be + // derived from a configuration path. + docPath?: string; +} + +export type HAFormSchema = HAFormSelectorSchema | HAFormExpandableSchema; diff --git a/src/images/README.md b/src/images/README.md index 98289132..d3cbdda6 100644 --- a/src/images/README.md +++ b/src/images/README.md @@ -19,7 +19,7 @@ $ convert -strip -interlace Plane -quality 85% -scale 492x277 iris-screensaver-original.jpg iris-screensaver.jpg ``` -## iris.svg / iris-outline.svg +## icons/iris.svg / iris-background.svg **Link**: https://pictogrammers.com/library/mdi/icon/camera-iris/ @@ -27,8 +27,13 @@ $ convert -strip -interlace Plane -quality 85% -scale 492x277 iris-screensaver-o **Image Formatting Process**: -- Outline addded manually -- Opacity adjustmented manually +- `icons/iris.svg`: single-path variant for the card's custom iconset +- `iris-background.svg`: outline / opacity adjusted manually + +## icons/{frigate,motioneye,reolink,tplink}.svg + +**Description**: Vendor logos, flattened to single-path monochrome silhouettes +for the card's custom iconset. ## check-circle.svg diff --git a/src/images/icons/frigate.svg b/src/images/icons/frigate.svg new file mode 100644 index 00000000..86a024e6 --- /dev/null +++ b/src/images/icons/frigate.svg @@ -0,0 +1 @@ + diff --git a/src/images/icons/iris.svg b/src/images/icons/iris.svg new file mode 100644 index 00000000..d1b3da1c --- /dev/null +++ b/src/images/icons/iris.svg @@ -0,0 +1 @@ + diff --git a/src/images/icons/motioneye.svg b/src/images/icons/motioneye.svg new file mode 100644 index 00000000..4b321aca --- /dev/null +++ b/src/images/icons/motioneye.svg @@ -0,0 +1 @@ + diff --git a/src/images/icons/reolink.svg b/src/images/icons/reolink.svg new file mode 100644 index 00000000..88447c17 --- /dev/null +++ b/src/images/icons/reolink.svg @@ -0,0 +1 @@ + diff --git a/src/images/icons/tplink.svg b/src/images/icons/tplink.svg new file mode 100644 index 00000000..0227968b --- /dev/null +++ b/src/images/icons/tplink.svg @@ -0,0 +1 @@ + diff --git a/src/images/iris.svg b/src/images/iris.svg deleted file mode 100644 index 0616d097..00000000 --- a/src/images/iris.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index e27dabbd..b7fb9833 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -129,6 +129,29 @@ "thumbnails": "Miniatures" } }, + "ptz": { + "editor_label": "PTZ", + "hide_home": "Amaga el control de casa", + "hide_pan_tilt": "Amaga el control d'escombratge i inclinació", + "hide_zoom": "Amaga el control de zoom", + "mode": "Mode", + "modes": { + "off": "Desactivat", + "on": "Activat" + }, + "orientation": "Orientació", + "orientations": { + "horizontal": "Horitzontal", + "vertical": "Vertical" + }, + "position": "Posició", + "positions": { + "bottom-left": "Inferior esquerra", + "bottom-right": "Inferior dreta", + "top-left": "Superior left", + "top-right": "Superior dreta" + } + }, "thumbnails": { "editor_label": "Miniatures", "mode": "Mode de miniatures", @@ -204,6 +227,10 @@ "stack": "Esdeveniments apilats i agrupats" }, "window_seconds": "La durada predeterminada de la visualització de la línia de temps en segons" + }, + "transition_effects": { + "none": "Sense transició", + "slide": "Transició de diapositives" } }, "dimensions": { @@ -221,30 +248,7 @@ "auto_play": "Reprodueix automàticament les càmeres en directe", "auto_unmute": "Activa automàticament el so de les càmeres en directe", "controls": { - "editor_label": "Controls del directe", - "ptz": { - "editor_label": "PTZ", - "hide_home": "Amaga el control de casa", - "hide_pan_tilt": "Amaga el control d'escombratge i inclinació", - "hide_zoom": "Amaga el control de zoom", - "mode": "Mode", - "modes": { - "off": "Desactivat", - "on": "Activat" - }, - "orientation": "Orientació", - "orientations": { - "horizontal": "Horitzontal", - "vertical": "Vertical" - }, - "position": "Posició", - "positions": { - "bottom-left": "Inferior esquerra", - "bottom-right": "Inferior dreta", - "top-left": "Superior left", - "top-right": "Superior dreta" - } - } + "editor_label": "Controls del directe" }, "draggable": "La vista de càmeres en directe es pot arrossegar/lliscar", "lazy_load": "Les càmeres en directe es carreguen amb mandra", @@ -272,10 +276,6 @@ "lazy_load": "El contingut multimèdia del visualitzador multimèdia es carreguen amb mandra al carrusel", "snapshot_click_plays_clip": "Si feu clic a una instantània, es reprodueix un clip relacionat", "transition_effect": "Efecte de transició del visualitzador multimèdia", - "transition_effects": { - "none": "Sense transició", - "slide": "Transició de diapositives" - }, "zoomable": "El visualitzador de mitjans es pot ampliar/escombrar" }, "menu": { diff --git a/src/localize/languages/de.json b/src/localize/languages/de.json index 5a3c0df0..a9247c7d 100644 --- a/src/localize/languages/de.json +++ b/src/localize/languages/de.json @@ -97,7 +97,29 @@ "common": { "controls": { "builtin": "Eingebettete Video Bedienelemente", - "thumbnails": {}, + "ptz": { + "editor_label": "PTZ", + "hide_home": "Verstecke Start & Vorlagen Bedienung", + "hide_pan_tilt": "Verstecke Pan & Tilt Steuerung", + "hide_zoom": "Verstecke Zoom-Steuerung", + "mode": "Modus", + "modes": { + "off": "Aus", + "on": "An" + }, + "orientation": "Ausrichtung", + "orientations": { + "horizontal": "Horizontal", + "vertical": "Vertikal" + }, + "position": "Position", + "positions": { + "bottom-left": "Unten links", + "bottom-right": "Oben rechts", + "top-left": "Unten links", + "top-right": "Oben rechts" + } + }, "timeline": { "format": { "24h": "24h Uhr", @@ -191,31 +213,6 @@ } }, "live": { - "controls": { - "ptz": { - "editor_label": "PTZ", - "hide_home": "Verstecke Start & Vorlagen Bedienung", - "hide_pan_tilt": "Verstecke Pan & Tilt Steuerung", - "hide_zoom": "Verstecke Zoom-Steuerung", - "mode": "Modus", - "modes": { - "off": "Aus", - "on": "An" - }, - "orientation": "Ausrichtung", - "orientations": { - "horizontal": "Horizontal", - "vertical": "Vertikal" - }, - "position": "Position", - "positions": { - "bottom-left": "Unten links", - "bottom-right": "Oben rechts", - "top-left": "Unten links", - "top-right": "Oben rechts" - } - } - }, "microphone": { "always_connected": "Mikrofon immer verbunden lassen", "disconnect_seconds": "Sekunden nach denen das Mikrofon getrennt wird (0=niemals)", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index ff58faf9..c895bdd9 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -46,15 +46,16 @@ }, "disable": "Disable", "disable_except": "Disable except", - "editor_label": "Camera capabilities", + "editor_label": "Capabilities", "force": "Force" }, "cast": { "dashboard": { "dashboard_path": "Dashboard path", + "editor_label": "Dashboard", "view_path": "View path" }, - "editor_label": "Cast options", + "editor_label": "Casting", "method": "Cast method", "methods": { "dashboard": "Dashboard", @@ -64,11 +65,11 @@ "dependencies": { "all_cameras": "Show events for all cameras with this camera", "cameras": "Show events for specific cameras with this camera", - "editor_label": "Dependency options" + "editor_label": "Dependencies" }, "dimensions": { "aspect_ratio": "Static aspect ratio", - "editor_label": "Camera dimensions", + "editor_label": "Dimensions", "layout": { "editor_label": "Camera Layout", "fit": "Layout fit", @@ -78,6 +79,7 @@ "fill": "Media is stretched to fill the card" }, "pan": { + "editor_label": "Layout Pan", "x": "Pan horizontal percentage", "y": "Pan vertical percentage" }, @@ -103,19 +105,26 @@ "90": "90 degrees clockwise" } }, + "engine": "Camera engine", "engines": { - "editor_label": "Camera engine options" + "auto": "Automatic", + "editor_label": "Engine", + "frigate": "Frigate", + "generic": "Generic", + "motioneye": "MotionEye", + "reolink": "Reolink", + "tplink": "TP-Link" }, "frigate": { "camera_name": "Frigate camera name (autodetected from entity)", "client_id": "Frigate client id (for >1 Frigate server)", - "editor_label": "Frigate options", + "editor_label": "Frigate", "labels": "Frigate labels/object filters", "url": "Frigate server URL", "zones": "Frigate zones" }, "go2rtc": { - "editor_label": "go2rtc Options", + "editor_label": "go2rtc", "metadata_fetch_timeout_seconds": "Metadata fetch timeout in seconds", "modes": { "editor_label": "go2rtc Modes", @@ -130,13 +139,13 @@ "icon": "Icon for this camera (autodetected from entity)", "id": "Unique ID for this camera in this card", "image": { - "editor_label": "Image Options", + "editor_label": "Image", "refresh_seconds": "Seconds between live image refreshes (0=never)", "url": "Image URL to use instead of camera entity snapshot" }, "live_provider": "Live view provider for this camera", "live_provider_options": { - "editor_label": "Live provider options" + "editor_label": "Live Provider" }, "live_providers": { "auto": "Automatic", @@ -148,7 +157,7 @@ "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" }, "media": { - "editor_label": "Default Media", + "editor_label": "Media", "events_type": "Events media subtype", "folders": "Folder IDs", "reviewed": "Review status filter", @@ -160,24 +169,26 @@ "type": "Default media type" }, "motioneye": { - "editor_label": "MotionEye options", + "editor_label": "MotionEye", "images": { "directory_pattern": "Images directory pattern", + "editor_label": "Images", "file_pattern": "Images file pattern" }, "movies": { "directory_pattern": "Movies directory pattern", + "editor_label": "Movies", "file_pattern": "Movies file pattern" }, "url": "MotionEye UI URL" }, "proxy": { - "editor_label": "Camera proxying", + "editor_label": "Proxying", "live": "Live proxying", "media": "Media proxying" }, "reolink": { - "editor_label": "Reolink options", + "editor_label": "Reolink", "media_resolution": { "editor_label": "Media Resolution", "high": "High Resolution", @@ -187,15 +198,15 @@ }, "title": "Title for this camera (autodetected from entity)", "triggers": { - "editor_label": "Trigger options", + "doorbell": "Trigger by auto-detecting doorbell event entities", + "editor_label": "Triggers", "entities": "Trigger from other entities", "events": { - "add_new_event": "Add new event trigger", + "add_new_event": "Add event trigger", "editor_label": "Home Assistant Events", "event_data": "Event data filter", "event_type": "Event type" }, - "doorbell": "Trigger by auto-detecting doorbell event entities", "media_events": { "clips": "Events with new clips", "editor_label": "Trigger Media Events", @@ -210,7 +221,7 @@ } }, "webrtc_card": { - "editor_label": "WebRTC Card Options", + "editor_label": "WebRTC Card", "entity": "WebRTC Card Camera Entity", "url": "WebRTC Card Camera URL" } @@ -243,6 +254,36 @@ "thumbnails": "Thumbnails" } }, + "ptz": { + "editor_label": "PTZ", + "hide_home": "Hide home & preset controls", + "hide_pan_tilt": "Hide pan & tilt control", + "hide_type": "Hide type toggle button", + "hide_zoom": "Hide zoom control", + "mode": "Mode", + "modes": { + "auto": "Automatic", + "off": "Off", + "on": "On" + }, + "orientation": "Orientation", + "orientations": { + "horizontal": "Horizontal", + "vertical": "Vertical" + }, + "position": "Position", + "positions": { + "bottom-left": "Bottom left", + "bottom-right": "Bottom right", + "top-left": "Top left", + "top-right": "Top right" + }, + "type": "Default type", + "types": { + "buttons": "Buttons", + "gestures": "Gestures" + } + }, "thumbnails": { "editor_label": "Thumbnails", "mode": "Thumbnails mode", @@ -306,6 +347,7 @@ "entity_parameters": "Query parameters added to the entity-based picture URLs (e.g. width=1920&height=1080)", "mode": "Image mode", "modes": { + "auto": "Automatically chosen based on configuration", "camera": "Home Assistant camera snapshot of camera entity", "default": "Default embedded image", "entity": "Entity with entity_picture attribute", @@ -371,6 +413,10 @@ "stack": "Stacked & clustered events" }, "window_seconds": "Default timeline window length in seconds" + }, + "transition_effects": { + "none": "No transition", + "slide": "Slide transition" } }, "dimensions": { @@ -397,6 +443,9 @@ "ha": "Home Assistant Media Folder" } }, + "image": { + "zoomable": "Whether the image can be zoomed and panned" + }, "live": { "auto_mute": "Automatically mute live cameras", "auto_pause": "Automatically pause live cameras", @@ -413,6 +462,7 @@ "mute_microphone": "Mute microphone", "reject": "Reject call", "ringtone": { + "editor_label": "Ringtone", "repeat": "Ringtone repeats per inbound call (0=indefinite)", "type": "Ringtone for inbound calls", "types": { @@ -430,36 +480,7 @@ "unmute_audio": "Unmute audio", "unmute_microphone": "Unmute microphone" }, - "editor_label": "Live Controls", - "ptz": { - "editor_label": "PTZ", - "hide_home": "Hide home & preset controls", - "hide_pan_tilt": "Hide pan & tilt control", - "hide_type": "Hide type toggle button", - "hide_zoom": "Hide zoom control", - "mode": "Mode", - "modes": { - "off": "Off", - "on": "On" - }, - "orientation": "Orientation", - "orientations": { - "horizontal": "Horizontal", - "vertical": "Vertical" - }, - "position": "Position", - "positions": { - "bottom-left": "Bottom left", - "bottom-right": "Bottom right", - "top-left": "Top left", - "top-right": "Top right" - }, - "type": "Default type", - "types": { - "buttons": "Buttons", - "gestures": "Gestures" - } - } + "editor_label": "Live Controls" }, "draggable": "Live cameras view can be dragged/swiped", "lazy_load": "Live cameras are lazily loaded", @@ -489,10 +510,6 @@ "lazy_load": "Media Viewer media is lazily loaded in carousel", "snapshot_click_plays_clip": "Clicking on a snapshot plays a related clip", "transition_effect": "Media Viewer transition effect", - "transition_effects": { - "none": "No transition", - "slide": "Slide transition" - }, "zoomable": "Media Viewer can be zoomed/panned" }, "menu": { @@ -524,6 +541,7 @@ "gallery": "Gallery", "icon": "Icon", "image": "Image", + "inert": "Button non-interactive", "info": "Info", "iris": "Iris / Default View / Unhide menu", "live": "Live", @@ -542,6 +560,7 @@ "screenshot": "Screenshot", "set_review": "Set review status", "snapshots": "Snapshots", + "state_color": "Color button by entity state", "substreams": "Substream(s)", "timeline": "Timeline", "type": "Button type", @@ -568,7 +587,7 @@ } }, "overrides": { - "info": "This card has manually specified overrides that may override values shown in the visual editor. Consult the code editor to view or modify them" + "info": "Conditional overrides are active and may supersede values shown here. Edit them in the code editor" }, "performance": { "features": { @@ -584,7 +603,7 @@ "box_shadow": "Shadows", "editor_label": "Style Options" }, - "warning": "This card is in low profile mode, so defaults have changed to optimize performance" + "warning": "Low performance profile is active: defaults are optimized to reduce load" }, "profiles": { "casting": "Casting", @@ -596,6 +615,11 @@ "remote_control": { "entities": { "camera": "Input Select entity to control camera", + "camera_priorities": { + "card": "Card", + "entity": "Entity" + }, + "camera_priority": "Which camera selection takes priority", "editor_label": "Remote Control Entities" } }, @@ -606,6 +630,7 @@ "enabled": "Item enabled", "engine": "Camera engine", "issues": "Issues", + "permanent": "Item keeps status bar shown", "priority": "Item priority", "resolution": "Resolution", "severity": "Severity", @@ -676,7 +701,8 @@ "default": "Change to or update default view", "live": "Change to or update live view", "media": "Change to the relevant media view for new media", - "none": "No action" + "none": "No action", + "update": "Update the current view" }, "untrigger": "Untrigger action", "untriggers": { @@ -686,9 +712,9 @@ } }, "editor_label": "Trigger behavior", + "event_hold_seconds": "Seconds to hold a momentary trigger (e.g. a doorbell press, an HA bus event) visible before the post-end untrigger delay", "filter_selected_camera": "Only trigger on selected camera", "show_trigger_status": "Show pulsing border when triggered", - "event_hold_seconds": "Seconds to hold a momentary trigger (e.g. a doorbell press, an HA bus event) visible before the post-end untrigger delay", "untrigger_delay_seconds": "Seconds delay after trigger state change before untrigger", "untrigger_force_seconds": "Seconds before forced untrigger" }, @@ -697,6 +723,7 @@ "clip": "Most recent clip", "clips": "Clips gallery", "current": "Current view", + "diagnostics": "Diagnostics", "folder": "Folder media", "folders": "Folders gallery", "gallery": "Default media gallery", @@ -718,13 +745,15 @@ "single": "Show single media viewer" }, "editor": { - "add_new_camera": "Add new camera", - "add_new_folder": "Add new folder", + "add_new_camera": "Add camera", + "back": "Back", + "edit": "Edit", + "add_new_folder": "Add folder", "camera": "Camera", "cameras": "Cameras", "cameras_secondary": "What cameras to render on this card", "delete": "Delete", - "dimensions": "Card dimensions", + "dimensions": "Dimensions", "dimensions_secondary": "Card dimensions & shape options", "docs": "Documentation", "folders": "Folders", @@ -736,16 +765,14 @@ "media_gallery": "Media gallery", "media_gallery_secondary": "Media gallery options", "media_viewer": "Media viewer", - "media_viewer_secondary": "Viewer for static media (clips, snapshots or recordings)", + "media_viewer_secondary": "Viewer for static media", "menu": "Menu", "menu_secondary": "Menu look & feel options", "move_down": "Move down", "move_up": "Move up", - "overrides": "Overrides are active", - "overrides_secondary": "Dynamic configuration overrides detected", "performance": "Performance", "performance_secondary": "Card performance options", - "profiles": "Configuration profiles", + "profiles": "Profiles", "profiles_secondary": "Choose pre-configured sets of defaults", "remote_control": "Remote Control", "remote_control_secondary": "Options for remote controlling the card", @@ -775,11 +802,11 @@ "error": { "awaiting_live": "Waiting for live stream to load...", "awaiting_media": "Waiting for media to load", - "call_unavailable_heading": "Two-way audio unavailable", "call_invalid_target": "The requested camera or stream is not available to call.", "call_microphone_forbidden": "Microphone access has been denied for this page. Update your browser permissions and try again.", "call_microphone_unsupported": "Microphone access is not available in this browser (e.g. requires HTTPS).", "call_no_two_way_audio": "This camera does not support two-way audio.", + "call_unavailable_heading": "Two-way audio unavailable", "camera_initialization": "Camera initialization failed", "camera_initialization_reolink": "Could not initialize Reolink camera", "configuration_error": "Configuration error", @@ -828,7 +855,7 @@ }, "config_upgrade": { "heading": "Configuration upgrade available", - "text": "A configuration upgrade is available. To upgrade, edit this card (Dashboard pencil icon \u2192 Three-dot menu \u2192 Edit) and click the 'Automatic Upgrade' button in the card editor" + "text": "A configuration upgrade is available. To upgrade, edit this card (Dashboard pencil icon → Three-dot menu → Edit) and click the 'Automatic Upgrade' button in the card editor" }, "config_upgrade_failure": { "heading": "Configuration could not be fully upgraded", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 070da092..0ff84c27 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -178,6 +178,29 @@ "thumbnails": "Vignettes" } }, + "ptz": { + "editor_label": "PTZ", + "hide_home": "Cacher le bouton position d'origine", + "hide_pan_tilt": "Cacher les boutons panoramiques/inclinaison", + "hide_zoom": "Cacher le bouton de zoom", + "mode": "Mode", + "modes": { + "off": "Désactivé", + "on": "Activé" + }, + "orientation": "Orientation", + "orientations": { + "horizontal": "Horizontale", + "vertical": "Verticale" + }, + "position": "Position", + "positions": { + "bottom-left": "En bas à gauche", + "bottom-right": "En bas à droite", + "top-left": "En haut à gauche", + "top-right": "En haut à droite" + } + }, "thumbnails": { "editor_label": "Vignettes", "mode": "Mode vignettes", @@ -284,6 +307,10 @@ "stack": "Événements empilés et groupés" }, "window_seconds": "Durée par défaut de la vue chronologique en secondes" + }, + "transition_effects": { + "none": "Pas de transition", + "slide": "Transition de diapositive" } }, "dimensions": { @@ -302,30 +329,7 @@ "auto_play": "Lire automatiquement les caméras en direct", "auto_unmute": "Activer automatiquement le son des caméras en direct", "controls": { - "editor_label": "Contrôles en direct", - "ptz": { - "editor_label": "PTZ", - "hide_home": "Cacher le bouton position d'origine", - "hide_pan_tilt": "Cacher les boutons panoramiques/inclinaison", - "hide_zoom": "Cacher le bouton de zoom", - "mode": "Mode", - "modes": { - "off": "Désactivé", - "on": "Activé" - }, - "orientation": "Orientation", - "orientations": { - "horizontal": "Horizontale", - "vertical": "Verticale" - }, - "position": "Position", - "positions": { - "bottom-left": "En bas à gauche", - "bottom-right": "En bas à droite", - "top-left": "En haut à gauche", - "top-right": "En haut à droite" - } - } + "editor_label": "Contrôles en direct" }, "draggable": "La vue des caméras en direct peut être déplacée/glissée", "lazy_load": "Les caméras en direct sont chargeés en différé", @@ -353,10 +357,6 @@ "lazy_load": "Les médias Media Viewer sont chargés en différé dans le carrousel", "snapshot_click_plays_clip": "Cliquer sur un instantané lit l'extrait associé", "transition_effect": "Effet de transition de la visionneuse multimédia", - "transition_effects": { - "none": "Pas de transition", - "slide": "Transition de diapositive" - }, "zoomable": "La visionneuse multimédia peut être zoomée/panoramique" }, "menu": { diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 6e0deed0..ecffb746 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -163,6 +163,10 @@ "clustering_threshold": "Il conteggio degli eventi in cui sono raggruppati (0 = nessun clustering)", "show_recordings": "Mostra registrazioni", "window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi" + }, + "transition_effects": { + "none": "Nessuna transizione", + "slide": "Transizione diapositiva" } }, "dimensions": { @@ -199,11 +203,7 @@ }, "draggable": "Il visualizzatore multimediale può essere trascinato oppure può scorrere", "lazy_load": "Il media Viewer viene caricato pigramente nel carosello", - "transition_effect": "Effetto di transizione del visualizzatore multimediale", - "transition_effects": { - "none": "Nessuna transizione", - "slide": "Transizione diapositiva" - } + "transition_effect": "Effetto di transizione del visualizzatore multimediale" }, "menu": { "alignment": "Allineamento dei menu", diff --git a/src/localize/languages/pl.json b/src/localize/languages/pl.json index d09ac778..86da45cd 100644 --- a/src/localize/languages/pl.json +++ b/src/localize/languages/pl.json @@ -195,6 +195,29 @@ "thumbnails": "Miniatury" } }, + "ptz": { + "editor_label": "PTZ", + "hide_home": "Ukryj przyciski Start i presetów", + "hide_pan_tilt": "Ukryj sterowanie obrotem (Pan & Tilt)", + "hide_zoom": "Ukryj sterowanie przybliżeniem (Zoom)", + "mode": "Tryb", + "modes": { + "off": "Wyłączony", + "on": "Włączony" + }, + "orientation": "Orientacja", + "orientations": { + "horizontal": "Pozioma", + "vertical": "Pionowa" + }, + "position": "Pozycja", + "positions": { + "bottom-left": "Dół, lewo", + "bottom-right": "Dół, prawo", + "top-left": "Góra, lewo", + "top-right": "Góra, prawo" + } + }, "thumbnails": { "editor_label": "Miniatury", "events_media_types": { @@ -312,6 +335,10 @@ "stack": "Zdarzenia ułożone w stos i grupowane" }, "window_seconds": "Domyślna długość widoku osi czasu (sekundy)" + }, + "transition_effects": { + "none": "Brak przejścia", + "slide": "Przesunięcie (Slide)" } }, "dimensions": { @@ -344,30 +371,7 @@ "auto_play": "Automatycznie odtwarzaj kamery na żywo", "auto_unmute": "Automatycznie włącz dźwięk kamer na żywo", "controls": { - "editor_label": "Sterowanie na żywo", - "ptz": { - "editor_label": "PTZ", - "hide_home": "Ukryj przyciski Start i presetów", - "hide_pan_tilt": "Ukryj sterowanie obrotem (Pan & Tilt)", - "hide_zoom": "Ukryj sterowanie przybliżeniem (Zoom)", - "mode": "Tryb", - "modes": { - "off": "Wyłączony", - "on": "Włączony" - }, - "orientation": "Orientacja", - "orientations": { - "horizontal": "Pozioma", - "vertical": "Pionowa" - }, - "position": "Pozycja", - "positions": { - "bottom-left": "Dół, lewo", - "bottom-right": "Dół, prawo", - "top-left": "Góra, lewo", - "top-right": "Góra, prawo" - } - } + "editor_label": "Sterowanie na żywo" }, "draggable": "Widok kamer można przeciągać", "lazy_load": "Kamery ładowane leniwie (na żądanie)", @@ -395,10 +399,6 @@ "lazy_load": "Media ładowane leniwie w karuzeli", "snapshot_click_plays_clip": "Kliknięcie w zrzut odtwarza powiązany klip", "transition_effect": "Efekt przejścia przeglądarki mediów", - "transition_effects": { - "none": "Brak przejścia", - "slide": "Przesunięcie (Slide)" - }, "zoomable": "Przeglądarkę mediów można przybliżać/przesuwać" }, "menu": { diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 99a4bbff..6b1288af 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -163,6 +163,10 @@ "clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)", "show_recordings": "Mostrar gravações", "window_seconds": "A duração padrão da visualização da linha do tempo em segundos" + }, + "transition_effects": { + "none": "Sem transição", + "slide": "Transição de slides" } }, "dimensions": { @@ -200,11 +204,7 @@ "draggable": "Visualizador de eventos pode ser arrastado/deslizado", "lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel", "snapshot_click_plays_clip": "Clicar em um instantâneo reproduz um clipe relacionado", - "transition_effect": "Efeito de transição do Visualizador de eventos", - "transition_effects": { - "none": "Sem transição", - "slide": "Transição de slides" - } + "transition_effect": "Efeito de transição do Visualizador de eventos" }, "menu": { "alignment": "Alinhamento do menu", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index df6211b9..00be003a 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -163,6 +163,10 @@ "clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)", "show_recordings": "Mostrar gravações", "window_seconds": "A duração padrão da visualização da linha do tempo em segundos" + }, + "transition_effects": { + "none": "Sem transição", + "slide": "Transição de slides" } }, "dimensions": { @@ -199,11 +203,7 @@ }, "draggable": "Visualizador de eventos pode ser arrastado/deslizado", "lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel", - "transition_effect": "Efeito de transição do Visualizador de eventos", - "transition_effects": { - "none": "Sem transição", - "slide": "Transição de slides" - } + "transition_effect": "Efeito de transição do Visualizador de eventos" }, "menu": { "alignment": "Alinhamento do menu", diff --git a/src/localize/localize.ts b/src/localize/localize.ts index 93c5c946..06a42eb4 100644 --- a/src/localize/localize.ts +++ b/src/localize/localize.ts @@ -91,7 +91,16 @@ export function localize(string: string, search = '', replace = ''): string { } catch (_e) {} if (!translated) { - translated = string.split('.').reduce((o, i) => o[i], languages[DEFAULT_LANG]); + try { + translated = string.split('.').reduce((o, i) => o[i], languages[DEFAULT_LANG]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_e) {} + } + + // Fall back to the key itself: a missing translation shows as its (self- + // describing) key rather than throwing and blanking the entire component. + if (!translated) { + return string; } if (search !== '' && replace !== '') { diff --git a/src/scss/editor-doc-link.scss b/src/scss/editor-doc-link.scss new file mode 100644 index 00000000..ea8d95d2 --- /dev/null +++ b/src/scss/editor-doc-link.scss @@ -0,0 +1,34 @@ +a { + display: flex; + align-items: center; + padding: 10px 10px 10px 14px; + + border-bottom: 1px solid var(--advanced-camera-card-divider-color); + + text-decoration: none; + font-style: italic; + + color: var(--advanced-camera-card-editor-doc-link-color); + cursor: help; + + transition: + background-color 0.2s ease, + color 0.2s ease; + + opacity: 0.7; +} + +a:hover { + background-color: var(--advanced-camera-card-editor-doc-link-hover-background); + color: var(--advanced-camera-card-editor-doc-link-hover-color); + opacity: 1; +} + +a ha-icon:first-child { + margin-right: 15px; +} + +a div { + flex: 1; + pointer-events: none; +} diff --git a/src/scss/editor-expander-body.scss b/src/scss/editor-expander-body.scss new file mode 100644 index 00000000..385ee276 --- /dev/null +++ b/src/scss/editor-expander-body.scss @@ -0,0 +1,45 @@ +// The content the editor renders inside an expander body. +// +// Every editor component that renders such content does so in its own shadow +// root, which the editor's own stylesheet cannot reach, so each includes these +// rules itself. The theme variables they use need no such treatment: custom +// properties inherit across shadow boundaries from the editor element. + +:host { + display: block; +} + +// A single-column grid rather than block layout: the `minmax(0, 1fr)` column +// lets children shrink below their intrinsic minimum width, so a form field +// with long unbreakable content (an entity ID, a select option) clamps to the +// panel width instead of overflowing it horizontally. +@mixin shrinkable-column { + display: grid; + + grid-template-columns: minmax(0, 1fr); +} + +// No background or border of its own: the body is the panel's own surface, so +// the margins around the form read as part of it rather than as a frame around +// it. +.values { + @include shrinkable-column; +} + +// The blocks stacked in a body all take the same inset, so their outlines line +// up with each other and none touches the body edge. It is applied to each +// block rather than as body padding because a form's own fields cannot be +// reached from outside its shadow DOM, so the form element itself has to carry +// the spacing. +.values ha-form, +.values ha-expansion-panel { + display: block; + margin: 10px; +} + +// De-emphasized to the same degree Home Assistant de-emphasizes the icons on +// the panels `ha-form` renders, so the panels the editor builds itself sit +// alongside them without standing out. +advanced-camera-card-icon[slot='leading-icon'] { + color: var(--advanced-camera-card-editor-secondary-text-color); +} diff --git a/src/scss/editor-keyboard-shortcuts.scss b/src/scss/editor-keyboard-shortcuts.scss new file mode 100644 index 00000000..72b28175 --- /dev/null +++ b/src/scss/editor-keyboard-shortcuts.scss @@ -0,0 +1,19 @@ +// The keyboard shortcuts panel renders editor body content into its own shadow +// root, so it carries the shared body rules alongside its own. +@use './editor-expander-body.scss'; + +// The key assigners are rows of a shared three-column table (label, buttons, +// assigned keys): the columns are defined here and each assigner joins them +// with a subgrid, so the rows align across components. +.values { + // `minmax(0, 1fr)` rather than `1fr`: the keys column must be able to + // shrink below its content so long key combinations wrap instead of + // overflowing. + grid-template-columns: max-content max-content minmax(0, 1fr); +} + +// Content other than the assigners (the documentation link, the shortcuts +// form) spans the whole row. +.values > *:not(advanced-camera-card-key-assigner) { + grid-column: 1 / -1; +} diff --git a/src/scss/editor-list.scss b/src/scss/editor-list.scss new file mode 100644 index 00000000..beb8798f --- /dev/null +++ b/src/scss/editor-list.scss @@ -0,0 +1,66 @@ +// A list of configuration items as rows, styled as Home Assistant styles the +// editable lists in its own editors. +@use './editor-expander-body.scss'; + +// Whatever precedes the list draws a line of its own (a documentation link's +// lower border, a form's fields), which the rows' own borders would otherwise +// sit directly against. +:host { + padding-top: 8px; +} + +ha-md-list { + gap: 8px; + + padding: 0 10px; +} + +ha-md-list-item { + border: 1px solid var(--advanced-camera-card-divider-color); + border-radius: var(--ha-border-radius-md, 8px); + + --ha-md-list-item-gap: 8px; + --md-list-item-top-space: 0; + --md-list-item-bottom-space: 0; + --md-list-item-leading-space: 12px; + --md-list-item-trailing-space: 4px; + --md-list-item-one-line-container-height: 48px; + --md-list-item-two-line-container-height: 56px; +} + +// Sized and de-emphasized as Home Assistant sizes the controls on the rows of +// its own editable lists: 36px buttons rather than the 48px default, against a +// 48px row. +.handle, +ha-md-list-item advanced-camera-card-icon[slot='start'] { + color: var(--advanced-camera-card-editor-secondary-text-color); +} + +.handle { + cursor: move; // Fallback where the grab cursor is unsupported. + cursor: grab; +} + +// The handle is what a drag is started from, so nothing inside it may become +// the target of the pointer instead. +.handle > * { + pointer-events: none; +} + +ha-icon-button { + --ha-icon-button-size: 36px; + + color: var(--advanced-camera-card-editor-secondary-text-color); +} + +.title, +.description { + overflow: hidden; + + white-space: nowrap; + text-overflow: ellipsis; +} + +.add { + margin: 8px 10px; +} diff --git a/src/scss/editor-page.scss b/src/scss/editor-page.scss new file mode 100644 index 00000000..e7827e63 --- /dev/null +++ b/src/scss/editor-page.scss @@ -0,0 +1,36 @@ +// The editor for one item of a list, shown in place of the list, headed as +// Home Assistant heads the editor for a sub-element of a card. +@use './editor-expander-body.scss'; + +// The shared body rules set `:host { display: block }`, which outranks the +// browser's default styling of the `hidden` attribute, so the page is hidden +// explicitly. A page is hidden while it stays mounted under a page opened on +// top of it, keeping its expanded panels expanded for when it returns. +:host([hidden]) { + display: none; +} + +.header { + display: flex; + align-items: center; + + margin: 8px 10px; +} + +.heading { + overflow: hidden; + + white-space: nowrap; + text-overflow: ellipsis; + + font-size: var(--ha-font-size-l, 1.125rem); +} + +// The page's blocks (the item's forms, then any list of its own) are spaced +// as Home Assistant spaces the blocks within a form, so a list below a form +// reads at the same distance as one form group below another. +::slotted(*:not(:last-child)) { + display: block; + + margin-bottom: 24px; +} diff --git a/src/scss/editor-section.scss b/src/scss/editor-section.scss new file mode 100644 index 00000000..5796cb35 --- /dev/null +++ b/src/scss/editor-section.scss @@ -0,0 +1,12 @@ +// A section renders its own panel, so it carries the rules for the panel's +// frame and body. The editor around it positions the sections themselves. +@use './editor-expander-body.scss'; + +// Content the section was given ends its body; without this it sits flush +// against the section's bottom edge. +.values > advanced-camera-card-editor-cameras, +.values > advanced-camera-card-editor-folders, +.values > advanced-camera-card-editor-keyboard-shortcuts { + display: block; + margin-bottom: 10px; +} diff --git a/src/scss/editor.scss b/src/scss/editor.scss index 528ff6ed..c942b65e 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -1,147 +1,36 @@ +@use './themes/base.scss'; @use './button.scss'; -.option { - padding: 8px 4px; - cursor: pointer; +// A hidden form rendered only to pre-load `ha-form`/`ha-selector`'s lazily +// imported element types. +.selector-sideload { + display: none; } -.option.option-overrides .title { - color: var(--warning-color); -} -.row { - display: flex; - margin-bottom: -14px; - pointer-events: none; -} -.title { - padding-left: 16px; - margin-top: -6px; - pointer-events: none; -} -.title.warning { - color: var(--warning-color); -} -.secondary { - padding-left: 40px; - color: var(--secondary-text-color); - pointer-events: none; -} -.values { - background: var(--secondary-background-color); - display: grid; - grid-template-columns: minmax(0, 1fr); +// Card-state notice banners sit above the section stack. +.card-config > ha-alert { + display: block; + margin-bottom: 8px; } -// Space submenus from the next top-level options. -.values + .option, -.submenu + .option { - margin-top: 10px; + +// Each top-level section is an HA expansion panel. An open section draws its +// own outline, which would otherwise sit directly against the neighbouring +// sections, so every section is spaced whether it is open or not: a gap that +// appeared only on opening would move the sections below as the panel grew. +// The margins of two adjacent sections collapse together, so the gap is the +// same wherever it falls. +.section { + display: block; + + margin-top: 8px; + margin-bottom: 8px; } + .action-buttons { display: flex; flex-wrap: wrap; } -.submenu-header { - display: flex; - align-items: center; - padding: 10px; - - cursor: pointer; -} -.submenu.selected > .submenu-header { - background-color: var(--primary-color); - color: var(--primary-text-color); -} - -.submenu-header * { - flex-basis: auto; - - // Only allow clicks on the header. - pointer-events: none; -} -.submenu-header .new { - font-style: italic; -} -.submenu:not(.selected) > .submenu-header .new { - color: var(--secondary-text-color, 'black'); -} - -.submenu-header advanced-camera-card-icon { - margin-right: 15px; -} - -.submenu.selected { - border: 1px solid var(--primary-color); -} - -// Nested menus should shrink width. -.submenu { - width: calc(100% - 10px * 2); - margin-left: auto; - margin-right: auto; - margin-bottom: 10px; -} - -// Add a margin above a submenu unless it's another submenu. -.submenu:first-child, -:not(.submenu) + .submenu { - margin-top: 10px; -} - -.submenu .values .controls { - display: inline-block; - margin-left: auto; - margin-right: 0px; - margin-bottom: 5px; - margin-top: 5px; -} -.submenu .values .controls ha-icon-button { - --ha-icon-button-size: 32px; - --mdc-icon-size: calc(var(--ha-icon-button-size) / 2); -} -span.info { - padding: 10px; -} - -ha-selector { - padding: 10px; - border: 1px solid var(--divider-color); -} - -.doc-link-row { - display: flex; - align-items: center; - padding: 10px 10px 10px 14px; - - border-bottom: 1px solid var(--divider-color); - - text-decoration: none; - font-style: italic; - - color: var(--secondary-text-color); - cursor: help; - - transition: - background-color 0.2s ease, - color 0.2s ease; - - opacity: 0.7; -} -.doc-link-row:hover { - background-color: var(--secondary-background-color); - color: var(--primary-color); - opacity: 1; -} -.doc-link-row ha-icon:first-child { - margin-right: 15px; -} -.doc-link-row div { - flex: 1; - padding-left: 0; - margin-top: 0; - pointer-events: none; -} - ha-button { margin: 10px; } diff --git a/src/scss/icon.scss b/src/scss/icon.scss index ab557475..8ec3e259 100644 --- a/src/scss/icon.scss +++ b/src/scss/icon.scss @@ -7,19 +7,13 @@ } ha-icon, -state-badge, -svg { +state-badge { display: block; height: 100%; width: 100%; box-sizing: border-box; } -svg path { - // !important is necessary to overcome the fill set on the path itself. - fill: currentColor !important; -} - :host([allow-override-non-active-styles]) state-badge { --state-inactive-color: inherit; } diff --git a/src/scss/key-assigner.scss b/src/scss/key-assigner.scss index 48283936..630d7008 100644 --- a/src/scss/key-assigner.scss +++ b/src/scss/key-assigner.scss @@ -1,12 +1,29 @@ +// Fade content out over the last `$distance` of the element's width. The +// prefixed property covers Safari before 15.4 and Chrome before 120. +@mixin fade-out-right($distance: 20px) { + $gradient: linear-gradient( + to right, + black calc(100% - #{$distance}), + transparent 100% + ); + + -webkit-mask-image: $gradient; + mask-image: $gradient; +} + +// Each assigner is one row of the columns its parent grid defines (label, +// buttons, assigned keys), so the rows align like a table across components. :host { - display: flex; - flex-direction: row; + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; align-items: center; - // Values match other HA editor components. + // Values match other HA editor components. A minimum rather than a fixed + // height: a long key combination may wrap onto multiple lines. padding: 10px; - height: 56px; - border: 1px solid var(--divider-color); + min-height: 56px; + box-sizing: border-box; } :host([assigning]) ha-button.assign span, @@ -15,7 +32,7 @@ @keyframes pulse { 50% { - color: var(--warning-color); + color: var(--advanced-camera-card-editor-warning-color); } } } @@ -30,16 +47,27 @@ advanced-camera-card-icon { } div.label { - width: 100px; margin-left: 4px; } -div.key-row { - flex: 1; - +div.buttons { display: flex; - justify-content: center; align-items: center; +} + +div.key-row { + display: flex; + align-items: center; + + // A long key combination wraps rather than overflowing the row. When even a + // single key is wider than the row (a very narrow editor), wrapping cannot + // help, so the overflow is clipped and faded out at the edge rather than + // spilling out of the panel. + flex-wrap: wrap; + row-gap: 4px; + min-width: 0; + overflow: hidden; + @include fade-out-right; padding-left: 10px; padding-right: 10px; @@ -49,7 +77,6 @@ div.key { display: flex; align-items: center; - height: 90%; width: min-content; margin-left: 5px; @@ -57,32 +84,25 @@ div.key { } div.key-inner { - height: 100%; - width: 100%; - - padding-top: 2px; - padding-bottom: 2px; - padding-left: 4px; - padding-right: 4px; - - border: 2px; - border-radius: 4px; - border-style: outset; - border-color: var(--divider-color); - - font-family: monospace; - text-transform: capitalize; -} - -div.unassigned { - font-style: italic; -} - -div.key + div.key:before { display: flex; align-items: center; + justify-content: center; - margin-right: 5px; + min-width: 24px; - content: ' + '; + padding: 4px 8px; + + background-color: var(--secondary-background-color); + border: 1px solid var(--advanced-camera-card-divider-color); + border-radius: 4px; + + // The darker lower edge reads as the side of a raised keycap. + box-shadow: var( + --advanced-camera-card-box-shadow-override, + inset 0 -2px 0 var(--advanced-camera-card-divider-color) + ); + + font-family: monospace; + font-size: 0.9em; + text-transform: capitalize; } diff --git a/src/scss/loading.scss b/src/scss/loading.scss index 56d8bedb..d1077f50 100644 --- a/src/scss/loading.scss +++ b/src/scss/loading.scss @@ -9,6 +9,10 @@ pointer-events: none; + // The rotating icon's bounding box oscillates as it turns; clip it here so + // it cannot grow ancestor scrollable overflow (flickering scrollbars). + overflow: hidden; + background-color: var(--advanced-camera-card-loading-background-color); color: var(--advanced-camera-card-loading-foreground-color); @@ -20,10 +24,20 @@ opacity: 0; } +// The hidden element stays mounted: without this the icon would rotate +// invisibly forever. +:host([loaded]) advanced-camera-card-icon { + animation: none; +} + advanced-camera-card-icon { + // The icon's own box sets the size; the inner `ha-icon` machinery sizes + // itself from `--mdc-icon-size`, which needs a definite box to fill. + --mdc-icon-size: 100%; + margin-bottom: 20px; height: 25%; - width: auto; + aspect-ratio: 1; animation: rotate 8s linear infinite; } diff --git a/src/scss/themes/base.scss b/src/scss/themes/base.scss index 8921a9b9..302f5d38 100644 --- a/src/scss/themes/base.scss +++ b/src/scss/themes/base.scss @@ -301,6 +301,22 @@ transparent 20% ); + /******** + * Editor + ********/ + --advanced-camera-card-editor-doc-link-color: var(--secondary-text-color); + --advanced-camera-card-editor-doc-link-hover-color: var( + --advanced-camera-card-foreground-primary + ); + --advanced-camera-card-editor-doc-link-hover-background: var( + --secondary-background-color + ); + --advanced-camera-card-editor-secondary-text-color: var(--secondary-text-color); + + // Home Assistant's amber `--warning-color`, not the card's own + // `--advanced-camera-card-warning-color`. + --advanced-camera-card-editor-warning-color: var(--warning-color); + /************** * Notification (block) **************/ diff --git a/src/utils/animation.ts b/src/utils/animation.ts index 3b31a66a..80dc212e 100644 --- a/src/utils/animation.ts +++ b/src/utils/animation.ts @@ -1,4 +1,5 @@ -import { POP_OUT_ANIMATION_NAME } from '../const.js'; +// The name of the exit keyframe defined in `scss/pop-animation.scss`. +export const POP_OUT_ANIMATION_NAME = 'pop-out'; /** * Whether `ev` marks the end of a pop-out (exit) animation on the element the diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts index 1c753b7e..6095d12d 100644 --- a/tests/card-controller/media-player-manager.test.ts +++ b/tests/card-controller/media-player-manager.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js'; import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, MEDIA_PLAYER_SUPPORT_STOP, MEDIA_PLAYER_SUPPORT_TURN_OFF, -} from '../../src/const'; + MediaPlayerManager, +} from '../../src/card-controller/media-player-manager.js'; import type { EntityRegistryManager } from '../../src/ha/registry/entity/types.js'; import type { HomeAssistant } from '../../src/ha/types.js'; import { ViewMediaType } from '../../src/view/item.js'; diff --git a/tests/components-lib/editor/controller.test.ts b/tests/components-lib/editor/controller.test.ts new file mode 100644 index 00000000..6a52bef9 --- /dev/null +++ b/tests/components-lib/editor/controller.test.ts @@ -0,0 +1,358 @@ +import type { LitElement } from 'lit'; +import { + afterEach, + assert, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from 'vitest'; + +import { EditorController } from '../../../src/components-lib/editor/controller'; +import { getConfigValue } from '../../../src/config/management'; +import { configDefaults } from '../../../src/config/schema/types'; +import type { RawAdvancedCameraCardConfig } from '../../../src/config/types'; +import { sideLoadHomeAssistantElements } from '../../../src/ha/side-load-ha-elements'; +import { localize } from '../../../src/localize/localize'; +import { isRecord } from '../../../src/utils/basic'; +import { createHASS, createLitElement, flushPromises } from '../../test-utils'; + +vi.mock('../../../src/ha/side-load-ha-elements'); + +interface ControllerHarness { + controller: EditorController; + host: LitElement; + configListener: Mock; +} + +const createController = (): ControllerHarness => { + const host = createLitElement(); + const controller = new EditorController(host); + const configListener = vi.fn(); + host.addEventListener('config-changed', configListener); + + return { controller, host, configListener }; +}; + +const getLastConfig = (configListener: Mock): RawAdvancedCameraCardConfig => { + const event = configListener.mock.lastCall?.[0]; + assert(event instanceof CustomEvent); + const config: unknown = event.detail.config; + assert(isRecord(config)); + + return config; +}; + +// A configuration the upgrade rules can rewrite (`service_data` was renamed to +// `data`). +const createUpgradeableConfig = (): RawAdvancedCameraCardConfig => ({ + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'icon', + icon: 'mdi:cow', + tap_action: { + action: 'call-service', + service: 'notify.persistent_notification', + service_data: { message: 'Hello' }, + }, + }, + ], +}); + +// @vitest-environment jsdom +describe('EditorController', () => { + beforeEach(() => { + vi.mocked(sideLoadHomeAssistantElements).mockReset(); + vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should register with the host', () => { + const host = createLitElement(); + const controller = new EditorController(host); + expect(host.addController).toHaveBeenCalledWith(controller); + controller.hostConnected(); + }); + + describe('should initialize', () => { + it('should side-load Home Assistant elements only once', async () => { + const { controller } = createController(); + + controller.initialize(); + await flushPromises(); + controller.initialize(); + + expect(sideLoadHomeAssistantElements).toHaveBeenCalledTimes(1); + }); + + it('should log and retry after a failed side-load', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockReturnValue(undefined); + vi.mocked(sideLoadHomeAssistantElements).mockRejectedValue(new Error('fail')); + const { controller } = createController(); + + controller.initialize(); + await vi.waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('fail')); + + controller.initialize(); + expect(sideLoadHomeAssistantElements).toHaveBeenCalledTimes(2); + }); + }); + + describe('should set the configuration', () => { + it('should store the configuration and request an update', () => { + const { controller, host } = createController(); + expect(controller.getConfig()).toBeNull(); + + const config = { cameras: [] }; + controller.setConfig(config); + + expect(controller.getConfig()).toBe(config); + expect(host.requestUpdate).toHaveBeenCalled(); + }); + + it('should detect an upgradeable configuration', () => { + const { controller } = createController(); + expect(controller.isConfigUpgradeable()).toBeFalsy(); + + controller.setConfig(createUpgradeableConfig()); + expect(controller.isConfigUpgradeable()).toBeTruthy(); + + controller.setConfig({ cameras: [] }); + expect(controller.isConfigUpgradeable()).toBeFalsy(); + }); + + it('should apply profile defaults', () => { + const { controller } = createController(); + controller.setConfig({ cameras: [], profiles: ['scrubbing'] }); + + expect( + getConfigValue( + controller.getFormsInput().defaults, + 'media_viewer.controls.timeline.style', + ), + ).toBe('ribbon'); + }); + + it('should reset profile defaults when profiles become invalid', () => { + const { controller } = createController(); + controller.setConfig({ cameras: [], profiles: ['scrubbing'] }); + controller.setConfig({ cameras: [], profiles: 42 }); + + expect( + getConfigValue( + controller.getFormsInput().defaults, + 'media_viewer.controls.timeline.style', + ), + ).toBe(configDefaults.media_viewer.controls.timeline.style); + }); + + it.each([ + [{ cameras: [] }, []], + [{ cameras: [], profiles: ['low-performance'] }, ['warning']], + [{ cameras: [], profiles: 42 }, []], + [{ cameras: [], overrides: [] }, []], + [{ cameras: [], overrides: [{}] }, ['info']], + ])('should compute the notices for %j', (config, noticeTypes) => { + const { controller } = createController(); + controller.setConfig(config); + expect(controller.getNotices().map((notice) => notice.type)).toEqual(noticeTypes); + }); + + it('should have notices for the low-performance profile and overrides', () => { + const { controller } = createController(); + controller.setConfig({ + cameras: [], + profiles: ['low-performance'], + overrides: [{}], + }); + expect(controller.getNotices()).toEqual([ + { type: 'warning', message: localize('config.performance.warning') }, + { type: 'info', message: localize('config.overrides.info') }, + ]); + }); + + it('should have no notices without a configuration', () => { + const { controller } = createController(); + expect(controller.getNotices()).toEqual([]); + }); + }); + + describe('should describe what a section needs', () => { + it('should offer an empty configuration before one is set', () => { + const { controller } = createController(); + expect(controller.getFormsInput().config).toEqual({}); + }); + + it('should offer the configuration and its defaults', () => { + const { controller } = createController(); + const config = { cameras: [] }; + controller.setConfig(config); + + const input = controller.getFormsInput(); + expect(input.config).toBe(config); + expect(input.defaults).toBe(controller.getDefaults()); + }); + + it('should offer the cameras and folders its selectors need', () => { + const { controller } = createController(); + controller.setConfig({ + cameras: [{ id: 'one' }, { camera_entity: 'camera.office' }], + folders: [{ id: 'recordings', title: 'Recordings' }], + }); + + const { options } = controller.getFormsInput(); + expect(options.cameras).toEqual([ + { value: 'one', label: 'one' }, + { value: 'camera.office', label: 'Camera #1' }, + ]); + expect(options.folders).toEqual([{ value: 'recordings', label: 'Recordings' }]); + }); + + it('should report a list holding entries that are not objects', () => { + const { controller } = createController(); + controller.setConfig({ cameras: [{ id: 'one' }, 'junk'], folders: 'junk' }); + + const { options } = controller.getFormsInput(); + expect(options.cameras).toHaveLength(2); + expect(options.folders).toEqual([]); + }); + }); + + describe('should set HASS', () => { + it('should offer the HomeAssistant object it was given', () => { + const { controller } = createController(); + const hass = createHASS(); + + controller.setHASS(hass); + + expect(controller.getHASS()).toBe(hass); + }); + }); + + describe('should upgrade', () => { + it('should upgrade an upgradeable configuration', () => { + const { controller, configListener } = createController(); + controller.setConfig(createUpgradeableConfig()); + + controller.upgrade(); + + const config = getLastConfig(configListener); + expect(getConfigValue(config, 'elements.0.tap_action.data')).toEqual({ + message: 'Hello', + }); + expect(controller.isConfigUpgradeable()).toBeFalsy(); + }); + + it('should do nothing without a configuration', () => { + const { controller, configListener } = createController(); + + controller.upgrade(); + + expect(configListener).not.toHaveBeenCalled(); + }); + + it('should not fire for a configuration that needs no upgrade', () => { + const { controller, configListener } = createController(); + controller.setConfig({ cameras: [] }); + + controller.upgrade(); + + expect(configListener).not.toHaveBeenCalled(); + }); + }); + + describe('should carry out an intent', () => { + it('should apply changes', () => { + const { controller, configListener } = createController(); + controller.setConfig({ cameras: [] }); + + controller.applyIntent({ + type: 'changes', + changes: [{ path: ['menu', 'style'], type: 'set', value: 'outside' }], + }); + + expect(getConfigValue(getLastConfig(configListener), 'menu.style')).toBe( + 'outside', + ); + }); + + it('should not fire when the changes leave the configuration unmodified', () => { + const { controller, configListener } = createController(); + controller.setConfig({ menu: { style: 'outside' } }); + + controller.applyIntent({ + type: 'changes', + changes: [{ path: ['menu', 'style'], type: 'set', value: 'outside' }], + }); + + expect(configListener).not.toHaveBeenCalled(); + }); + + it('should add an item to a list', () => { + const { controller, configListener } = createController(); + controller.setConfig({ cameras: [{ id: 'one' }] }); + + controller.applyIntent({ + type: 'list-add', + path: ['cameras'], + item: { id: 'two' }, + }); + + expect(getConfigValue(getLastConfig(configListener), 'cameras')).toEqual([ + { id: 'one' }, + { id: 'two' }, + ]); + }); + + it('should move an item within a list', () => { + const { controller, configListener } = createController(); + controller.setConfig({ cameras: [{ id: 'one' }, { id: 'two' }] }); + + controller.applyIntent({ type: 'list-move', path: ['cameras'], from: 0, to: 1 }); + + expect(getConfigValue(getLastConfig(configListener), 'cameras')).toEqual([ + { id: 'two' }, + { id: 'one' }, + ]); + }); + + it('should delete an item from a list', () => { + const { controller, configListener } = createController(); + controller.setConfig({ cameras: [{ id: 'one' }, { id: 'two' }] }); + + controller.applyIntent({ type: 'list-delete', path: ['cameras'], index: 0 }); + + expect(getConfigValue(getLastConfig(configListener), 'cameras')).toEqual([ + { id: 'two' }, + ]); + }); + + it('should not fire for a list change that cannot be made', () => { + const { controller, configListener } = createController(); + controller.setConfig({ cameras: [{ id: 'one' }] }); + + controller.applyIntent({ type: 'list-delete', path: ['cameras'], index: 5 }); + + expect(configListener).not.toHaveBeenCalled(); + }); + + it.each([ + [{ type: 'changes' as const, changes: [] }], + [{ type: 'list-add' as const, path: ['cameras'], item: {} }], + [{ type: 'list-move' as const, path: ['cameras'], from: 0, to: 1 }], + [{ type: 'list-delete' as const, path: ['cameras'], index: 0 }], + ])('should do nothing for %j without a configuration', (intent) => { + const { controller, configListener } = createController(); + + controller.applyIntent(intent); + + expect(configListener).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/components-lib/editor/doc-links.test.ts b/tests/components-lib/editor/doc-links.test.ts new file mode 100644 index 00000000..83f4386f --- /dev/null +++ b/tests/components-lib/editor/doc-links.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { getDocLinkPath, getDocURL } from '../../../src/components-lib/editor/doc-links'; + +describe('getDocURL', () => { + it('should return the documentation URL for a known path', () => { + expect(getDocURL(['live', 'controls', 'thumbnails'])).toBe( + 'https://card.camera/#/configuration/live?id=thumbnails', + ); + }); + + it('should ignore array indices', () => { + expect(getDocURL(['cameras', 0, 'triggers'])).toBe( + 'https://card.camera/#/configuration/cameras/README?id=triggers', + ); + }); + + it('should return null for a path without documentation', () => { + expect(getDocURL(['unknown', 'path'])).toBeNull(); + }); +}); + +describe('getDocLinkPath', () => { + it('should not link a field', () => { + expect( + getDocLinkPath(['cameras'], { name: 'title', selector: { text: {} } }), + ).toBeNull(); + }); + + it('should link a group by its explicit documentation path', () => { + expect( + getDocLinkPath(['cameras', 2], { + type: 'expandable', + docPath: 'cameras.engine', + schema: [], + }), + ).toEqual(['cameras', 'engine']); + }); + + it('should not link a group that has neither a name nor a path', () => { + expect(getDocLinkPath(['cameras'], { type: 'expandable', schema: [] })).toBeNull(); + }); + + it('should order nested container paths into configuration order', () => { + expect( + getDocLinkPath( + ['cameras', 2], + { name: 'dashboard', type: 'expandable', schema: [] }, + { path: ['cast'] }, + ), + ).toEqual(['cameras', 2, 'cast', 'dashboard']); + }); +}); diff --git a/tests/components-lib/editor/form-data.test.ts b/tests/components-lib/editor/form-data.test.ts new file mode 100644 index 00000000..ac1293b8 --- /dev/null +++ b/tests/components-lib/editor/form-data.test.ts @@ -0,0 +1,518 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyConfigChanges, + computeConfigChanges, + computeDisplayedData, + forEachFieldRecursively, +} from '../../../src/components-lib/editor/form-data'; +import type { ConfigChange, EditorForm } from '../../../src/components-lib/editor/types'; +import { getConfigValue } from '../../../src/config/management'; +import type { RawAdvancedCameraCardConfig } from '../../../src/config/types'; +import type { HAFormSchema } from '../../../src/ha/types'; + +const createForm = (schema: HAFormSchema[] = SCHEMA): EditorForm => ({ + basePath: ['live'], + schema, +}); +const createConfig = (live: unknown) => ({ live }); + +const SCHEMA: HAFormSchema[] = [ + { name: 'title', selector: { text: {} } }, + { name: 'preload', selector: { boolean: {} } }, + { + name: 'controls', + type: 'expandable', + schema: [ + { name: 'wheel', selector: { boolean: {} } }, + { name: 'size', selector: { number: { min: 0 } } }, + { + name: 'thumbnails', + type: 'expandable', + schema: [{ name: 'mode', selector: { select: { options: ['none'] } } }], + }, + ], + }, + { name: 'modes', selector: { select: { options: ['a', 'b'], multiple: true } } }, +]; + +// Defaults mirror the configuration shape; the form displays these where the +// configuration leaves a field unset. +const DEFAULTS = { + preload: false, + controls: { wheel: true }, +}; + +describe('forEachFieldRecursively', () => { + it('should not add a nameless grouping to the path', () => { + const visited: string[][] = []; + forEachFieldRecursively( + [ + { + type: 'expandable', + schema: [{ name: 'style', selector: { text: {} } }], + }, + ], + (path) => visited.push(path), + ); + expect(visited).toEqual([['style']]); + }); + + it('should visit every leaf field with its relative path', () => { + const visited: [string[], string][] = []; + forEachFieldRecursively(SCHEMA, (path, field) => visited.push([path, field.name])); + expect(visited).toEqual([ + [['title'], 'title'], + [['preload'], 'preload'], + [['controls', 'wheel'], 'wheel'], + [['controls', 'size'], 'size'], + [['controls', 'thumbnails', 'mode'], 'mode'], + [['modes'], 'modes'], + ]); + }); +}); + +describe('computeDisplayedData', () => { + it('should fill in defaults for absent fields', () => { + expect( + computeDisplayedData(createForm(), createConfig({}), createConfig(DEFAULTS)), + ).toEqual({ + preload: false, + controls: { wheel: true }, + }); + }); + + it('should not override configured values with defaults', () => { + expect( + computeDisplayedData( + createForm(), + createConfig({ preload: true, controls: { wheel: false } }), + createConfig(DEFAULTS), + ), + ).toEqual({ + preload: true, + controls: { wheel: false }, + }); + }); + + it('should only fill in fields that have a default', () => { + // `title` and `modes` have no default in DEFAULTS, so they stay absent. + expect( + computeDisplayedData(createForm(), createConfig({}), createConfig(DEFAULTS)), + ).not.toHaveProperty('title'); + }); + + it('should preserve unknown keys', () => { + expect( + computeDisplayedData( + createForm(), + createConfig({ unknown_key: 42 }), + createConfig(DEFAULTS), + ), + ).toEqual({ + unknown_key: 42, + preload: false, + controls: { wheel: true }, + }); + }); + + it('should treat a non-object configuration as empty', () => { + expect( + computeDisplayedData( + createForm(), + createConfig('NOT_AN_OBJECT'), + createConfig(DEFAULTS), + ), + ).toEqual({ + preload: false, + controls: { wheel: true }, + }); + }); + + it('should tolerate absent defaults', () => { + expect( + computeDisplayedData(createForm(), createConfig({ title: 'Front' }), {}), + ).toEqual({ + title: 'Front', + }); + }); + + it('should not modify the raw configuration', () => { + const raw = { title: 'Front' }; + computeDisplayedData(createForm(), createConfig(raw), createConfig(DEFAULTS)); + expect(raw).toEqual({ title: 'Front' }); + }); + + it('should copy non-primitive defaults rather than share them', () => { + const defaults = { modes: ['a'] }; + const schema: HAFormSchema[] = [ + { name: 'modes', selector: { select: { options: ['a', 'b'], multiple: true } } }, + ]; + const data = computeDisplayedData( + createForm(schema), + createConfig({}), + createConfig(defaults), + ); + expect(data.modes).toEqual(['a']); + expect(data.modes).not.toBe(defaults.modes); + }); +}); + +describe('computeConfigChanges', () => { + it('should return no changes for a non-object emission', () => { + expect(computeConfigChanges(createForm(), {}, 'NOT_AN_OBJECT', {})).toEqual([]); + }); + + it('should return no changes when nothing changed', () => { + const displayed = computeDisplayedData( + createForm(), + createConfig({ title: 'Front' }), + createConfig(DEFAULTS), + ); + expect(computeConfigChanges(createForm(), displayed, displayed, {})).toEqual([]); + }); + + it('should never report an untouched display default', () => { + const displayed = computeDisplayedData( + createForm(), + createConfig({}), + createConfig(DEFAULTS), + ); + expect(computeConfigChanges(createForm(), displayed, { ...displayed }, {})).toEqual( + [], + ); + }); + + it('should report a changed value', () => { + const displayed = computeDisplayedData( + createForm(), + createConfig({}), + createConfig(DEFAULTS), + ); + expect( + computeConfigChanges( + createForm(), + displayed, + { ...displayed, title: 'Front' }, + {}, + ), + ).toEqual([{ path: ['live', 'title'], type: 'set', value: 'Front' }]); + }); + + it('should report a changed nested value', () => { + const displayed = computeDisplayedData( + createForm(), + createConfig({}), + createConfig(DEFAULTS), + ); + expect( + computeConfigChanges( + createForm(), + displayed, + { preload: false, controls: { wheel: false } }, + {}, + ), + ).toEqual([{ path: ['live', 'controls', 'wheel'], type: 'set', value: false }]); + }); + + it('should report a value set back to its default as a change', () => { + // Diffing against the displayed baseline: when the stored value is + // non-default, returning it to the default value is a real change (this is + // how a user pins a default-equal value). + const displayed = computeDisplayedData( + createForm(), + createConfig({ controls: { wheel: false } }), + createConfig(DEFAULTS), + ); + const emitted = { ...displayed, controls: { wheel: true } }; + expect(computeConfigChanges(createForm(), displayed, emitted, {})).toEqual([ + { path: ['live', 'controls', 'wheel'], type: 'set', value: true }, + ]); + }); + + it('should trim string values', () => { + expect(computeConfigChanges(createForm(), {}, { title: ' Front ' }, {})).toEqual([ + { path: ['live', 'title'], type: 'set', value: 'Front' }, + ]); + }); + + it('should not report a value that only differs by whitespace', () => { + expect( + computeConfigChanges(createForm(), { title: 'Front' }, { title: ' Front ' }, {}), + ).toEqual([]); + }); + + it('should request deletion for an emptied string', () => { + expect( + computeConfigChanges(createForm(), { title: 'Front' }, { title: '' }, {}), + ).toEqual([{ path: ['live', 'title'], type: 'delete' }]); + }); + + it('should request deletion for a cleared value', () => { + expect( + computeConfigChanges( + createForm(), + { controls: { size: 5 } }, + { controls: { size: undefined } }, + {}, + ), + ).toEqual([{ path: ['live', 'controls', 'size'], type: 'delete' }]); + }); + + it('should set a zero value rather than delete it', () => { + expect( + computeConfigChanges(createForm(), {}, { controls: { size: 0 } }, {}), + ).toEqual([{ path: ['live', 'controls', 'size'], type: 'set', value: 0 }]); + }); + + it('should compare multi-select arrays by value', () => { + expect( + computeConfigChanges(createForm(), { modes: ['a'] }, { modes: ['a'] }, {}), + ).toEqual([]); + expect( + computeConfigChanges(createForm(), { modes: ['a'] }, { modes: ['a', 'b'] }, {}), + ).toEqual([{ path: ['live', 'modes'], type: 'set', value: ['a', 'b'] }]); + }); + + it('should ignore emitted keys that are not in the schema', () => { + expect(computeConfigChanges(createForm(), {}, { unknown_key: 42 }, {})).toEqual([]); + }); +}); + +describe('applyConfigChanges', () => { + it('should return null without changes', () => { + expect(applyConfigChanges({ title: 'Front' }, [])).toBeNull(); + }); + + it('should set values', () => { + expect( + applyConfigChanges({}, [{ path: ['title'], type: 'set', value: 'Front' }]), + ).toEqual({ title: 'Front' }); + }); + + it('should create intermediate objects for absent branches', () => { + expect( + applyConfigChanges({}, [ + { path: ['image', 'proxy', 'dynamic'], type: 'set', value: true }, + ]), + ).toEqual({ image: { proxy: { dynamic: true } } }); + }); + + it('should delete values', () => { + expect( + applyConfigChanges({ image: { preload: true } }, [ + { path: ['image', 'preload'], type: 'delete' }, + ]), + ).toEqual({ image: {} }); + }); + + it('should leave unknown keys untouched', () => { + expect( + applyConfigChanges({ image: { unknown_key: 42 } }, [ + { path: ['image', 'title'], type: 'set', value: 'Front' }, + ]), + ).toEqual({ image: { unknown_key: 42, title: 'Front' } }); + }); + + it('should return null when the changes leave the configuration unmodified', () => { + expect( + applyConfigChanges({}, [{ path: ['image', 'preload'], type: 'delete' }]), + ).toBeNull(); + }); + + it('should not modify the given configuration', () => { + const config = { image: { preload: true } }; + applyConfigChanges(config, [ + { path: ['image', 'preload'], type: 'set', value: false }, + ]); + expect(config).toEqual({ image: { preload: true } }); + }); + + it('should copy the values it sets rather than share them', () => { + const value = { mode: 'none' }; + const result = applyConfigChanges({}, [ + { path: ['image', 'thumbnails'], type: 'set', value }, + ]); + expect(getConfigValue(result ?? {}, 'image.thumbnails')).toEqual(value); + expect(getConfigValue(result ?? {}, 'image.thumbnails')).not.toBe(value); + }); +}); + +describe('field bindings', () => { + // A form gathering fields from across the configuration: the field is shown + // in the form under its own name, but stored somewhere else entirely. + const BOUND_FORM: EditorForm = { + basePath: [], + schema: [{ name: 'menu_style', selector: { text: {} } }], + bindings: [{ formPath: ['menu_style'], configPath: ['menu', 'style'] }], + }; + + it('should display the value from the bound path', () => { + expect(computeDisplayedData(BOUND_FORM, { menu: { style: 'outside' } }, {})).toEqual( + { menu: { style: 'outside' }, menu_style: 'outside' }, + ); + }); + + it('should display nothing for a bound path with no value or default', () => { + expect(computeDisplayedData(BOUND_FORM, {}, {})).toEqual({ menu_style: undefined }); + }); + + it('should display a bound path configured to null as null', () => { + // As an unbound field would: only an absent value falls back to a default. + expect( + computeDisplayedData( + BOUND_FORM, + { menu: { style: null } }, + { menu: { style: 'hidden' } }, + ), + ).toMatchObject({ menu_style: null }); + }); + + it('should bind nothing for a path that names no field', () => { + const form: EditorForm = { + basePath: ['live'], + schema: [ + { + name: 'controls', + type: 'expandable', + schema: [{ name: 'mode', selector: { text: {} } }], + }, + ], + // A group, not a field: its fields are bound individually or not at all. + bindings: [{ formPath: ['controls'], configPath: ['elsewhere'] }], + }; + + expect(computeConfigChanges(form, {}, { controls: { mode: 'above' } }, {})).toEqual([ + { path: ['live', 'controls', 'mode'], type: 'set', value: 'above' }, + ]); + }); + + it('should display the default of the bound path', () => { + expect(computeDisplayedData(BOUND_FORM, {}, { menu: { style: 'hidden' } })).toEqual({ + menu_style: 'hidden', + }); + }); + + it('should write an edit to the bound path', () => { + expect( + computeConfigChanges( + BOUND_FORM, + { menu_style: 'hidden' }, + { menu_style: 'overlay' }, + {}, + ), + ).toEqual([{ path: ['menu', 'style'], type: 'set', value: 'overlay' }]); + }); + + it('should delete the bound path for an emptied field', () => { + expect( + computeConfigChanges(BOUND_FORM, { menu_style: 'hidden' }, { menu_style: '' }, {}), + ).toEqual([{ path: ['menu', 'style'], type: 'delete' }]); + }); + + describe('with a field that reads and writes itself', () => { + // One field standing for many configuration keys: a list of which menu + // buttons are on, where each button stores its own `enabled`. This is what + // a computed binding exists for: the field's shape in the form and its + // shape in the configuration have nothing to do with each other. + const BUTTONS = ['cameras', 'fullscreen', 'timeline']; + const isEnabled = ( + config: RawAdvancedCameraCardConfig, + defaults: RawAdvancedCameraCardConfig, + button: string, + ): boolean => + (getConfigValue(config, ['menu', 'buttons', button, 'enabled']) ?? + getConfigValue(defaults, ['menu', 'buttons', button, 'enabled'])) !== false; + + const BUTTONS_FORM: EditorForm = { + basePath: [], + schema: [ + { + name: 'buttons', + selector: { select: { options: BUTTONS, multiple: true } }, + }, + ], + bindings: [ + { + formPath: ['buttons'], + read: (config, defaults) => + BUTTONS.filter((button) => isEnabled(config, defaults, button)), + write: (value, config, defaults) => { + const enabled = Array.isArray(value) ? value : []; + return BUTTONS.flatMap((button): ConfigChange[] => { + const path = ['menu', 'buttons', button, 'enabled']; + const wanted = enabled.includes(button); + if (wanted === isEnabled(config, defaults, button)) { + return []; + } + // Returning to the default deletes the key rather than writing a + // value the user never set. + const isDefault = wanted === (getConfigValue(defaults, path) !== false); + return isDefault + ? [{ path, type: 'delete' }] + : [{ path, type: 'set', value: wanted }]; + }); + }, + }, + ], + }; + + const DEFAULTS = { + menu: { + buttons: { + cameras: { enabled: true }, + fullscreen: { enabled: false }, + timeline: { enabled: true }, + }, + }, + }; + + it('should display the buttons that are on', () => { + expect( + computeDisplayedData( + BUTTONS_FORM, + { menu: { buttons: { timeline: { enabled: false } } } }, + DEFAULTS, + ), + ).toMatchObject({ buttons: ['cameras'] }); + }); + + it('should write only the buttons whose state actually changed', () => { + const config = {}; + const displayed = computeDisplayedData(BUTTONS_FORM, config, DEFAULTS); + + // Turn `fullscreen` on (it defaults off) and `cameras` off (defaults on). + expect( + computeConfigChanges( + BUTTONS_FORM, + displayed, + { buttons: ['fullscreen', 'timeline'] }, + config, + DEFAULTS, + ), + ).toEqual([ + { path: ['menu', 'buttons', 'cameras', 'enabled'], type: 'set', value: false }, + { + path: ['menu', 'buttons', 'fullscreen', 'enabled'], + type: 'set', + value: true, + }, + ]); + }); + + it('should delete a button returned to its default rather than writing it', () => { + const config = { menu: { buttons: { cameras: { enabled: false } } } }; + const displayed = computeDisplayedData(BUTTONS_FORM, config, DEFAULTS); + + expect( + computeConfigChanges( + BUTTONS_FORM, + displayed, + { buttons: ['cameras', 'timeline'] }, + config, + DEFAULTS, + ), + ).toEqual([{ path: ['menu', 'buttons', 'cameras', 'enabled'], type: 'delete' }]); + }); + }); +}); diff --git a/tests/components-lib/editor/form-labels.test.ts b/tests/components-lib/editor/form-labels.test.ts new file mode 100644 index 00000000..c267d470 --- /dev/null +++ b/tests/components-lib/editor/form-labels.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeFormLabel, + getLocalizationKeyForPath, +} from '../../../src/components-lib/editor/form-labels'; + +describe('getLocalizationKeyForPath', () => { + it('should build a config key without array indices', () => { + expect(getLocalizationKeyForPath(['cameras', 2, 'title'])).toBe( + 'config.cameras.title', + ); + }); +}); + +describe('computeFormLabel', () => { + it('should prefer an explicit schema label', () => { + expect( + computeFormLabel(['cameras', 2], { + name: 'title', + label: 'Explicit', + selector: { text: {} }, + }), + ).toBe('Explicit'); + }); + + it('should localize the configuration path', () => { + expect(computeFormLabel(['view'], { name: 'default', selector: { text: {} } })).toBe( + 'Default view', + ); + }); + + it('should include container paths provided by the form', () => { + expect( + computeFormLabel( + ['cameras', 2], + { name: 'title', selector: { text: {} } }, + { path: [] }, + ), + ).toBe('Title for this camera (autodetected from entity)'); + }); + + it('should order nested container paths into configuration order', () => { + expect( + computeFormLabel( + ['cameras', 2], + { name: 'dashboard_path', selector: { text: {} } }, + { path: ['dashboard', 'cast'] }, + ), + ).toBe('Dashboard path'); + }); + + it('should use the title for container nodes', () => { + expect( + computeFormLabel(['image'], { + name: 'proxy', + type: 'expandable', + title: 'Proxy', + schema: [], + }), + ).toBe('Proxy'); + }); + + it('should fall back to the name for container nodes without a title', () => { + expect( + computeFormLabel(['image'], { name: 'proxy', type: 'expandable', schema: [] }), + ).toBe('proxy'); + }); + + it('should return an empty label for a nameless, titleless container', () => { + expect(computeFormLabel(['cameras', 0], { type: 'expandable', schema: [] })).toBe( + '', + ); + }); +}); diff --git a/tests/components-lib/editor/forms-controller.test.ts b/tests/components-lib/editor/forms-controller.test.ts new file mode 100644 index 00000000..1924d912 --- /dev/null +++ b/tests/components-lib/editor/forms-controller.test.ts @@ -0,0 +1,198 @@ +import { html } from 'lit'; +import { describe, expect, it, vi } from 'vitest'; + +import { FormsController } from '../../../src/components-lib/editor/forms-controller'; + +const OPTIONS = { cameras: [], folders: [] }; +const INPUT = { config: {}, defaults: {}, options: OPTIONS }; +const MENU = { kind: 'section' as const, name: 'menu' }; + +const createController = () => { + const onChanges = vi.fn(); + return { + onChanges, + controller: new FormsController(onChanges, () => html`doc`), + }; +}; + +describe('FormsController', () => { + it('should have no contexts before it is given a request', () => { + const { controller } = createController(); + expect(controller.getContexts()).toEqual([]); + }); + + it('should build the forms of the requested section', () => { + const { controller } = createController(); + + controller.setInput(MENU, INPUT); + + const contexts = controller.getContexts(); + expect(contexts.length).toBeGreaterThan(0); + expect(contexts[0].form.basePath).toEqual(['menu']); + }); + + it('should build the forms again for a different request', () => { + const { controller } = createController(); + controller.setInput(MENU, INPUT); + + controller.setInput({ kind: 'section', name: 'timeline' }, INPUT); + + expect(controller.getContexts()[0].form.basePath).toEqual(['timeline']); + }); + + it('should keep the same forms when only the configuration changes', () => { + const { controller } = createController(); + controller.setInput(MENU, INPUT); + const before = controller.getContexts()[0].form; + + controller.setInput(MENU, { ...INPUT, config: { menu: { style: 'outside' } } }); + + expect(controller.getContexts()[0].form).toBe(before); + }); + + it('should keep the same contexts when nothing changed', () => { + const { controller } = createController(); + controller.setInput(MENU, INPUT); + const before = controller.getContexts(); + + controller.setInput(MENU, { ...INPUT }); + + expect(controller.getContexts()).toBe(before); + }); + + it('should build the forms again when the values its selectors offer change', () => { + const { controller } = createController(); + const request = { kind: 'camera' as const, index: 0 }; + controller.setInput(request, INPUT); + const before = controller.getContexts()[0].form; + + // A second camera: the first camera's dependency dropdown, which lists the + // cameras other than itself, now has something to offer. + controller.setInput(request, { + ...INPUT, + options: { + ...OPTIONS, + cameras: [ + { value: 'camera.one', label: 'One' }, + { value: 'camera.other', label: 'Other' }, + ], + }, + }); + + expect(controller.getContexts()[0].form).not.toBe(before); + }); + + it('should keep the forms it has when a rebuild produces the same ones', () => { + const { controller } = createController(); + const request = { kind: 'camera' as const, index: 0 }; + const cameras = [ + { value: 'camera.one', label: 'One' }, + { value: 'camera.two', label: 'Two' }, + ]; + controller.setInput(request, { ...INPUT, options: { ...OPTIONS, cameras } }); + const before = controller.getContexts()[0]; + + // Renaming the camera whose form this is: its own form leaves it out, so + // nothing it shows has changed. + controller.setInput(request, { + ...INPUT, + options: { + ...OPTIONS, + cameras: [{ ...cameras[0], label: 'Renamed' }, cameras[1]], + }, + }); + + expect(controller.getContexts()[0]).toBe(before); + }); + + it('should show the configured value of a field', () => { + const { controller } = createController(); + + controller.setInput(MENU, { ...INPUT, config: { menu: { style: 'outside' } } }); + + expect(controller.getContexts()[0].displayedData['style']).toBe('outside'); + }); + + it('should name a field', () => { + const { controller } = createController(); + controller.setInput(MENU, INPUT); + + const label = controller.getContexts()[0].computeLabel({ + name: 'style', + selector: { text: {} }, + }); + + expect(label).not.toBe(''); + }); + + it('should link a documented field to its documentation', () => { + const { controller } = createController(); + controller.setInput(MENU, INPUT); + + expect( + controller.getContexts()[0].computeHelper({ + name: 'buttons', + type: 'expandable', + title: '', + schema: [], + }), + ).not.toBeNull(); + }); + + it('should not link a field that has no documentation', () => { + const { controller } = createController(); + controller.setInput(MENU, INPUT); + + expect( + controller.getContexts()[0].computeHelper({ + name: 'style', + selector: { text: {} }, + }), + ).toBeNull(); + }); + + it('should report an edit as changes on absolute paths', () => { + const { controller, onChanges } = createController(); + controller.setInput(MENU, INPUT); + + controller + .getContexts()[0] + .valueChanged( + new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }), + ); + + expect(onChanges).toHaveBeenCalledWith([ + { path: ['menu', 'style'], type: 'set', value: 'outside' }, + ]); + }); + + it('should ignore an edit that changes nothing', () => { + const { controller, onChanges } = createController(); + controller.setInput(MENU, { ...INPUT, config: { menu: { style: 'outside' } } }); + + controller + .getContexts()[0] + .valueChanged( + new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }), + ); + + expect(onChanges).not.toHaveBeenCalled(); + }); + + it('should ignore an edit reported by a form it no longer has', () => { + const { controller, onChanges } = createController(); + controller.setInput(MENU, INPUT); + const contexts = controller.getContexts(); + expect(contexts.length).toBeGreaterThan(1); + const last = contexts[contexts.length - 1]; + + // The dimensions section has a single form, so the last of the menu + // section's forms no longer exists once it is asked for. + controller.setInput({ kind: 'section', name: 'dimensions' }, INPUT); + last.valueChanged( + new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }), + ); + + expect(onChanges).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/components-lib/editor/keyboard-shortcuts-controller.test.ts b/tests/components-lib/editor/keyboard-shortcuts-controller.test.ts new file mode 100644 index 00000000..9fdb056f --- /dev/null +++ b/tests/components-lib/editor/keyboard-shortcuts-controller.test.ts @@ -0,0 +1,132 @@ +import { html } from 'lit'; +import { assert, describe, expect, it, vi, type Mock } from 'vitest'; + +import { KeyboardShortcutsController } from '../../../src/components-lib/editor/keyboard-shortcuts-controller'; +import { configDefaults } from '../../../src/config/schema/types'; +import { PTZ_KEYBOARD_SHORTCUTS } from '../../../src/config/schema/view'; +import { createLitElement } from '../../test-utils'; + +const OPTIONS = { cameras: [], folders: [] }; + +const createController = () => { + const host = createLitElement(); + const listener = vi.fn(); + host.addEventListener('advanced-camera-card:editor:intent', listener); + return { + host, + listener, + controller: new KeyboardShortcutsController(host, () => html`doc`), + }; +}; + +const getLastIntent = (listener: Mock): unknown => { + const event = listener.mock.lastCall?.[0]; + assert(event instanceof CustomEvent); + return event.detail; +}; + +// @vitest-environment jsdom +describe('KeyboardShortcutsController', () => { + it('should register with the host', () => { + const { host, controller } = createController(); + expect(host.addController).toHaveBeenCalledWith(controller); + controller.hostConnected(); + }); + + describe('should resolve the shortcuts', () => { + it('should have every shortcut, in order, before it is given any input', () => { + const { controller } = createController(); + expect(Object.keys(controller.getShortcuts())).toEqual([ + ...PTZ_KEYBOARD_SHORTCUTS, + ]); + }); + + it('should resolve a shortcut the configuration leaves unset to its default', () => { + const { controller } = createController(); + controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS }); + + expect(controller.getShortcuts()['ptz_left']).toEqual( + configDefaults.view.keyboard_shortcuts.ptz_left, + ); + }); + + it('should resolve a configured shortcut', () => { + const { controller } = createController(); + controller.setInput({ + config: { view: { keyboard_shortcuts: { ptz_home: { key: 'k', ctrl: true } } } }, + defaults: configDefaults, + options: OPTIONS, + }); + + expect(controller.getShortcuts()['ptz_home']).toEqual({ key: 'k', ctrl: true }); + }); + }); + + describe('should report an assignment', () => { + it('should report a shortcut as an intent', () => { + const { controller, listener } = createController(); + controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS }); + + controller.setShortcut('ptz_home', { key: 'k' }); + + expect(getLastIntent(listener)).toEqual({ + type: 'changes', + changes: [ + { + path: ['view', 'keyboard_shortcuts', 'ptz_home'], + type: 'set', + value: { key: 'k' }, + }, + ], + }); + }); + + it('should record an unassignment rather than deleting the shortcut', () => { + const { controller, listener } = createController(); + controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS }); + + controller.setShortcut('ptz_home', null); + + expect(getLastIntent(listener)).toEqual({ + type: 'changes', + changes: [ + { path: ['view', 'keyboard_shortcuts', 'ptz_home'], type: 'set', value: null }, + ], + }); + }); + }); + + describe('should offer the panel its own form', () => { + it('should have no forms before it is given any input', () => { + const { controller } = createController(); + expect(controller.getContexts()).toEqual([]); + }); + + it('should build the shortcuts form', () => { + const { controller } = createController(); + controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS }); + + const contexts = controller.getContexts(); + expect(contexts).toHaveLength(1); + expect(contexts[0].form.basePath).toEqual(['view', 'keyboard_shortcuts']); + }); + + it('should report an edit to that form as an intent', () => { + const { controller, listener } = createController(); + controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS }); + + controller + .getContexts()[0] + .valueChanged( + new CustomEvent('value-changed', { detail: { value: { enabled: false } } }), + ); + + expect(getLastIntent(listener)).toEqual({ + type: 'changes', + changes: [ + { path: ['view', 'keyboard_shortcuts', 'enabled'], type: 'set', value: false }, + ], + }); + }); + }); +}); diff --git a/tests/components-lib/editor/list-forms-controller.test.ts b/tests/components-lib/editor/list-forms-controller.test.ts new file mode 100644 index 00000000..4b479eb0 --- /dev/null +++ b/tests/components-lib/editor/list-forms-controller.test.ts @@ -0,0 +1,233 @@ +import { html } from 'lit'; +import { assert, describe, expect, it, vi, type Mock } from 'vitest'; + +import { ListFormsController } from '../../../src/components-lib/editor/list-forms-controller'; +import { createLitElement } from '../../test-utils'; + +const OPTIONS = { cameras: [], folders: [] }; + +const createController = () => { + const host = createLitElement(); + const listener = vi.fn(); + host.addEventListener('advanced-camera-card:editor:intent', listener); + return { + host, + listener, + controller: new ListFormsController(host, () => html`doc`), + }; +}; + +const getLastIntent = (listener: Mock): unknown => { + const event = listener.mock.lastCall?.[0]; + assert(event instanceof CustomEvent); + return event.detail; +}; + +// @vitest-environment jsdom +describe('ListFormsController', () => { + it('should register with the host', () => { + const { host, controller } = createController(); + expect(host.addController).toHaveBeenCalledWith(controller); + controller.hostConnected(); + }); + + describe('should read a list', () => { + it('should have no items before it is given any input', () => { + const { controller } = createController(); + expect(controller.getList(['cameras'])).toEqual([]); + }); + + it('should read the items at a path', () => { + const { controller } = createController(); + controller.setInput({ + config: { cameras: [{ id: 'one' }] }, + defaults: {}, + options: OPTIONS, + }); + + expect(controller.getList(['cameras'])).toEqual([{ id: 'one' }]); + }); + + it('should read a nested list', () => { + const { controller } = createController(); + controller.setInput({ + config: { cameras: [{ triggers: { events: [{ event_type: 'a' }] } }] }, + defaults: {}, + options: OPTIONS, + }); + + expect(controller.getList(['cameras', 0, 'triggers', 'events'])).toEqual([ + { event_type: 'a' }, + ]); + }); + + it('should report an item that is not an object as an empty one', () => { + const { controller } = createController(); + controller.setInput({ + config: { cameras: [{ id: 'one' }, 'junk'] }, + defaults: {}, + options: OPTIONS, + }); + + expect(controller.getList(['cameras'])).toEqual([{ id: 'one' }, {}]); + }); + + it('should report no items for a value that is not a list', () => { + const { controller } = createController(); + controller.setInput({ + config: { cameras: 'junk' }, + defaults: {}, + options: OPTIONS, + }); + + expect(controller.getList(['cameras'])).toEqual([]); + }); + }); + + describe('should build the forms of an item', () => { + it('should have no contexts before it is given any input', () => { + const { controller } = createController(); + expect(controller.getFormContexts({ kind: 'camera', index: 0 })).toEqual([]); + }); + + it.each([ + [{ kind: 'camera' as const, index: 1 }, ['cameras', 1]], + [{ kind: 'folder' as const, index: 2 }, ['folders', 2]], + [{ kind: 'camera-triggers' as const, cameraIndex: 1 }, ['cameras', 1, 'triggers']], + [ + { kind: 'camera-event' as const, cameraIndex: 1, eventIndex: 3 }, + ['cameras', 1, 'triggers', 'events', 3], + ], + ])('should build the forms for %j', (request, basePath) => { + const { controller } = createController(); + controller.setInput({ config: {}, defaults: {}, options: OPTIONS }); + + const contexts = controller.getFormContexts(request); + expect(contexts).toHaveLength(1); + expect(contexts[0].form.basePath).toEqual(basePath); + }); + + it('should keep the forms of an item across configuration changes', () => { + const { controller } = createController(); + const request = { kind: 'camera' as const, index: 0 }; + controller.setInput({ config: {}, defaults: {}, options: OPTIONS }); + const before = controller.getFormContexts(request)[0].form; + + controller.setInput({ + config: { cameras: [{ id: 'one' }] }, + defaults: {}, + options: OPTIONS, + }); + + expect(controller.getFormContexts(request)[0].form).toBe(before); + }); + + it('should keep the same contexts when nothing changed', () => { + const { controller } = createController(); + const request = { kind: 'camera' as const, index: 0 }; + const input = { config: {}, defaults: {}, options: OPTIONS }; + controller.setInput(input); + const before = controller.getFormContexts(request); + + controller.setInput({ ...input }); + + expect(controller.getFormContexts(request)).toBe(before); + }); + + it('should give each request its own contexts', () => { + const { controller, listener } = createController(); + controller.setInput({ config: {}, defaults: {}, options: OPTIONS }); + + const camera = controller.getFormContexts({ kind: 'camera', index: 0 }); + const folder = controller.getFormContexts({ kind: 'folder', index: 0 }); + expect(camera[0]).not.toBe(folder[0]); + + folder[0].valueChanged( + new CustomEvent('value-changed', { detail: { value: { title: 'Clips' } } }), + ); + + // The edit is attributed to the folder, not to the camera of the same + // index. + expect(getLastIntent(listener)).toEqual({ + type: 'changes', + changes: [{ path: ['folders', 0, 'title'], type: 'set', value: 'Clips' }], + }); + }); + + it('should rebuild the forms when the values its selectors offer change', () => { + const { controller } = createController(); + const request = { kind: 'camera' as const, index: 0 }; + controller.setInput({ config: {}, defaults: {}, options: OPTIONS }); + const before = controller.getFormContexts(request)[0].form; + + controller.setInput({ + config: {}, + defaults: {}, + options: { + ...OPTIONS, + cameras: [ + { value: 'camera.one', label: 'One' }, + { value: 'camera.other', label: 'Other' }, + ], + }, + }); + + expect(controller.getFormContexts(request)[0].form).not.toBe(before); + }); + + it('should report an edit as an intent with an absolute path', () => { + const { controller, listener } = createController(); + controller.setInput({ config: {}, defaults: {}, options: OPTIONS }); + + controller + .getFormContexts({ kind: 'camera', index: 2 })[0] + .valueChanged( + new CustomEvent('value-changed', { detail: { value: { id: 'front' } } }), + ); + + expect(getLastIntent(listener)).toEqual({ + type: 'changes', + changes: [{ path: ['cameras', 2, 'id'], type: 'set', value: 'front' }], + }); + }); + }); + + describe('should report a list change as an intent', () => { + it('should report an addition', () => { + const { controller, listener } = createController(); + + controller.addItem(['cameras'], { id: 'one' }); + + expect(getLastIntent(listener)).toEqual({ + type: 'list-add', + path: ['cameras'], + item: { id: 'one' }, + }); + }); + + it('should report a move', () => { + const { controller, listener } = createController(); + + controller.moveItem(['cameras'], 0, 1); + + expect(getLastIntent(listener)).toEqual({ + type: 'list-move', + path: ['cameras'], + from: 0, + to: 1, + }); + }); + + it('should report a deletion', () => { + const { controller, listener } = createController(); + + controller.deleteItem(['cameras'], 1); + + expect(getLastIntent(listener)).toEqual({ + type: 'list-delete', + path: ['cameras'], + index: 1, + }); + }); + }); +}); diff --git a/tests/components-lib/editor/list-pages-controller.test.ts b/tests/components-lib/editor/list-pages-controller.test.ts new file mode 100644 index 00000000..1bd9b624 --- /dev/null +++ b/tests/components-lib/editor/list-pages-controller.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { ListPagesController } from '../../../src/components-lib/editor/list-pages-controller'; +import { createLitElement } from '../../test-utils'; + +const createController = () => { + const host = createLitElement(); + return { host, controller: new ListPagesController(host) }; +}; + +// @vitest-environment jsdom +describe('ListPagesController', () => { + it('should register with the host', () => { + const { host, controller } = createController(); + expect(host.addController).toHaveBeenCalledWith(controller); + controller.hostConnected(); + }); + + it('should start on the list itself', () => { + const { controller } = createController(); + expect(controller.getPath()).toEqual([]); + }); + + it('should open an item and request an update', () => { + const { host, controller } = createController(); + + controller.open('cameras', 2); + + expect(controller.getPath()).toEqual([{ list: 'cameras', index: 2 }]); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + }); + + it('should open an item within an open item', () => { + const { controller } = createController(); + controller.open('cameras', 2); + + controller.open('events', 0); + + expect(controller.getPath()).toEqual([ + { list: 'cameras', index: 2 }, + { list: 'events', index: 0 }, + ]); + }); + + it('should go back to the item above', () => { + const { controller } = createController(); + controller.open('cameras', 2); + controller.open('events', 0); + + controller.back(); + + expect(controller.getPath()).toEqual([{ list: 'cameras', index: 2 }]); + }); + + it('should go back to the list itself', () => { + const { host, controller } = createController(); + controller.open('cameras', 2); + + controller.back(); + + expect(controller.getPath()).toEqual([]); + expect(host.requestUpdate).toHaveBeenCalledTimes(2); + }); + + it('should ignore going back from the list itself', () => { + const { host, controller } = createController(); + + controller.back(); + + expect(controller.getPath()).toEqual([]); + expect(host.requestUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/components-lib/editor/paths.test.ts b/tests/components-lib/editor/paths.test.ts new file mode 100644 index 00000000..4595d0a5 --- /dev/null +++ b/tests/components-lib/editor/paths.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { + getFormContainerPath, + stripArrayIndices, +} from '../../../src/components-lib/editor/paths'; + +describe('getFormContainerPath', () => { + it('should reverse the innermost-first segments into configuration order', () => { + expect(getFormContainerPath({ path: ['dashboard', 'cast'] })).toEqual([ + 'cast', + 'dashboard', + ]); + }); + + it('should return an empty path without options', () => { + expect(getFormContainerPath()).toEqual([]); + }); + + it('should return an empty path without a path in the options', () => { + expect(getFormContainerPath({})).toEqual([]); + }); + + it('should not modify the passed segments', () => { + const path = ['dashboard', 'cast']; + getFormContainerPath({ path }); + expect(path).toEqual(['dashboard', 'cast']); + }); +}); + +describe('stripArrayIndices', () => { + it('should strip numeric segments', () => { + expect(stripArrayIndices(['cameras', 2, 'title'])).toEqual(['cameras', 'title']); + }); + + it('should strip numeric string segments', () => { + expect(stripArrayIndices(['cameras', '2', 'title'])).toEqual(['cameras', 'title']); + }); + + it('should leave paths without indices untouched', () => { + expect(stripArrayIndices(['live', 'controls'])).toEqual(['live', 'controls']); + }); +}); diff --git a/tests/components-lib/editor/schema/common/selectors.test.ts b/tests/components-lib/editor/schema/common/selectors.test.ts new file mode 100644 index 00000000..40e93b78 --- /dev/null +++ b/tests/components-lib/editor/schema/common/selectors.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { + createNumberSelector, + createSelectSelector, +} from '../../../../../src/components-lib/editor/schema/common/selectors'; + +describe('createSelectSelector', () => { + it('should build a single-value dropdown from options', () => { + expect(createSelectSelector(['a', 'b'])).toEqual({ + select: { + mode: 'dropdown', + multiple: false, + custom_value: false, + options: ['a', 'b'], + }, + }); + }); + + it('should allow multiple values', () => { + expect(createSelectSelector(['a'], { multiple: true }).select.multiple).toBe(true); + }); + + it('should allow custom values only when no options are given', () => { + expect(createSelectSelector([]).select.custom_value).toBe(true); + expect(createSelectSelector(['a']).select.custom_value).toBe(false); + }); +}); + +describe('createNumberSelector', () => { + it('should default to an input box with a zero minimum', () => { + expect(createNumberSelector()).toEqual({ + number: { min: 0, max: undefined, mode: 'box', step: undefined }, + }); + }); + + it('should be a slider when a maximum is given', () => { + expect(createNumberSelector({ min: 1, max: 10, step: 2 })).toEqual({ + number: { min: 1, max: 10, mode: 'slider', step: 2 }, + }); + }); +}); diff --git a/tests/components-lib/editor/schema/completeness.test.ts b/tests/components-lib/editor/schema/completeness.test.ts new file mode 100644 index 00000000..63156d28 --- /dev/null +++ b/tests/components-lib/editor/schema/completeness.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { forEachFieldRecursively } from '../../../../src/components-lib/editor/form-data'; +import { + getCameraSchema, + getCameraTriggersSchema, + getTriggerEventSchema, +} from '../../../../src/components-lib/editor/schema/cameras'; +import { getDimensionsSectionForms } from '../../../../src/components-lib/editor/schema/dimensions'; +import { getFolderSchema } from '../../../../src/components-lib/editor/schema/folders'; +import { getImageSectionForms } from '../../../../src/components-lib/editor/schema/image'; +import { getLiveSectionForms } from '../../../../src/components-lib/editor/schema/live'; +import { getMediaGallerySectionForms } from '../../../../src/components-lib/editor/schema/media-gallery'; +import { getMediaViewerSectionForms } from '../../../../src/components-lib/editor/schema/media-viewer'; +import { getMenuSectionForms } from '../../../../src/components-lib/editor/schema/menu'; +import { getPerformanceSectionForms } from '../../../../src/components-lib/editor/schema/performance'; +import { getProfilesSectionForms } from '../../../../src/components-lib/editor/schema/profiles'; +import { getRemoteControlSectionForms } from '../../../../src/components-lib/editor/schema/remote-control'; +import { getStatusBarSectionForms } from '../../../../src/components-lib/editor/schema/status-bar'; +import { getTimelineSectionForms } from '../../../../src/components-lib/editor/schema/timeline'; +import { + getViewKeyboardShortcutsSectionForms, + getViewSectionForms, +} from '../../../../src/components-lib/editor/schema/view'; +import type { EditorForm } from '../../../../src/components-lib/editor/types'; +import { advancedCameraCardConfigSchema } from '../../../../src/config/schema/types'; +import { PTZ_KEYBOARD_SHORTCUTS } from '../../../../src/config/schema/view'; +import type { HAFormSelectorSchema } from '../../../../src/ha/types'; + +// ============================================================================ +// Editor <-> Zod Schema Completeness tests +// +// Two directions: +// - Direction 1 (editor -> config): every editor field path is a real config +// key, its selector kind matches the zod type (number/boolean/enum), and its +// dropdown offers every enum value. +// - Direction 2 (config -> editor): every configurable zod leaf appears in an +// editor form, unless explicitly excepted. Catches config fields forgotten in +// the editor. +// ============================================================================ + +// Configuration subtrees the editor renders with a dedicated widget instead of +// an `ha-form` field, so the field walk cannot see them. +const EDITOR_CUSTOM_WIDGETS = PTZ_KEYBOARD_SHORTCUTS.map( + (name) => `view.keyboard_shortcuts.${name}`, +); + +// Configuration subtrees intentionally kept YAML-only. +const EDITOR_EXCLUDED = [ + 'cameras_global', + 'elements', + 'automations', + 'overrides', + 'debug', + 'type', + 'card_id', + 'card_mod', + + // Advanced/freeform camera subtrees not surfaced in the editor. + 'cameras.jsmpeg', + 'cameras.ptz', + 'cameras.dimensions.grid', + 'cameras.triggers.events.context', + + // Free-form CSS variable overrides. + 'view.theme.overrides', + + // Not surfaced in the editor (the maintainer declined adding it). + 'view.render_entities', + + // Section-level action handlers (`tap_action` etc.) need a dedicated action + // editor and have never been exposed. + 'view.actions', + 'image.actions', + 'media_gallery.actions', + 'live.actions', + 'media_viewer.actions', + + // Free-form CSS object for the PTZ control styling. + 'live.controls.ptz.style', + 'media_viewer.controls.ptz.style', + + // Advanced folder path parsers/matchers/templating: text-editor only. + 'folders.ha.path', +]; + +// Every section's forms, keyed by section, so the harness walks the full +// field tree of everything rendered via `ha-form`. +const SECTION_FORMS: Record = { + profiles: getProfilesSectionForms(), + view: [...getViewSectionForms(), ...getViewKeyboardShortcutsSectionForms()], + image: getImageSectionForms(), + live: getLiveSectionForms(), + media_gallery: getMediaGallerySectionForms(), + media_viewer: getMediaViewerSectionForms(), + menu: getMenuSectionForms(), + status_bar: getStatusBarSectionForms(), + timeline: getTimelineSectionForms(), + dimensions: getDimensionsSectionForms(), + performance: getPerformanceSectionForms(), + remote_control: getRemoteControlSectionForms(), + + // Array section: one representative item at index 0 for testing purposes. The + // numeric index is stripped when building covered paths, so this checks the + // item's fields (`folders.type`, ...) against the array element's schema. + folders: [{ basePath: ['folders', 0], schema: getFolderSchema() }], + + // Array section split across the camera form, the triggers sub-form, and the + // per-event item form (the triggers group is hand-built to host the events + // list, so its fields live in a separate schema). + cameras: [ + { + basePath: ['cameras', 0], + schema: getCameraSchema({ + otherCameras: [{ value: 'camera.other', label: 'Other' }], + folders: [{ value: 'folder-1', label: 'Folder' }], + }), + }, + { + basePath: ['cameras', 0, 'triggers'], + schema: getCameraTriggersSchema(), + }, + { + basePath: ['cameras', 0, 'triggers', 'events', 0], + schema: getTriggerEventSchema(), + }, + ], +}; + +// `z.core.$ZodType` is zod v4's base schema type for introspection: it is what +// `.unwrap()` and `ZodArray.element` return, and what every schema (including +// the config root) is assignable to. The classic `z.ZodType` is a subtype with +// extra methods, so those introspection results would not assign back to it. + +// Unwrap the zod wrappers that carry no structural meaning for path +// navigation (optional/default/nullable/readonly/lazy). +const unwrap = (schema: z.core.$ZodType): z.core.$ZodType => { + let current: z.core.$ZodType = schema; + for (let guard = 0; guard < 20; guard++) { + if ( + current instanceof z.ZodOptional || + current instanceof z.ZodNullable || + current instanceof z.ZodDefault || + current instanceof z.ZodReadonly + ) { + current = current.unwrap(); + } else if (current instanceof z.ZodLazy) { + current = current.unwrap(); + } else { + break; + } + } + return current; +}; + +// Resolve a configuration path to its zod leaf schema, or null if the path +// does not exist in the configuration schema. +const resolvePath = (path: (string | number)[]): z.core.$ZodType | null => { + let current: z.core.$ZodType = advancedCameraCardConfigSchema; + for (const segment of path) { + current = unwrap(current); + if (current instanceof z.ZodObject) { + const next: z.core.$ZodType | undefined = current.shape[segment]; + if (!next) { + return null; + } + current = next; + } else if (current instanceof z.ZodArray) { + current = current.element; + } else { + return null; + } + } + return unwrap(current); +}; + +// A selector object has exactly one key naming its kind (`{ number: {} }` -> +// 'number', `{ select: {} }` -> 'select'), so its single key is the kind. +const selectorKind = (field: HAFormSelectorSchema): string => + Object.keys(field.selector)[0]; + +// Collect the literal values of a zod enum or union-of-literals, or null if the +// schema is not an enumeration. +const enumValues = (schema: z.core.$ZodType): unknown[] | null => { + if (schema instanceof z.ZodEnum) { + return Object.values(schema.enum); + } + if (schema instanceof z.ZodUnion) { + const values: unknown[] = []; + for (const option of schema.options) { + const inner = unwrap(option); + if (inner instanceof z.ZodLiteral) { + values.push(inner.value); + } else { + return null; + } + } + return values; + } + return null; +}; + +// True if a config path lies inside a subtree the editor forms do not own +// (YAML-only, or rendered by a custom widget), so the completeness walk skips +// it. +const isPruned = (path: (string | number)[]): boolean => { + const key = path.join('.'); + return [...EDITOR_CUSTOM_WIDGETS, ...EDITOR_EXCLUDED].some( + (prefix) => key === prefix || key.startsWith(`${prefix}.`), + ); +}; + +// Enumerate the configuration schema's leaf field paths (a leaf is anything +// that is not a plain object; arrays are descended into rather than treated +// as leaves), pruning the subtrees the editor does not own. +const enumerateConfigLeavesRecursively = ( + schema: z.core.$ZodType, + prefix: (string | number)[] = [], +): string[] => { + if (prefix.length && isPruned(prefix)) { + return []; + } + const current = unwrap(schema); + if (current instanceof z.ZodObject) { + return Object.keys(current.shape).flatMap((key) => + enumerateConfigLeavesRecursively(current.shape[key], [...prefix, key]), + ); + } + if (current instanceof z.ZodArray) { + // Descend into array items without an index: array sections cover the item + // fields once (e.g. `folders.type`, not `folders.0.type`). + return enumerateConfigLeavesRecursively(current.element, prefix); + } + return [prefix.join('.')]; +}; + +// Every configuration path the editor forms currently cover. +const coveredPaths = new Set(); +for (const forms of Object.values(SECTION_FORMS)) { + for (const form of forms) { + forEachFieldRecursively(form.schema, (path) => { + // Array sections carry a numeric index in `basePath` (`['folders', 0]`); + // strip it so covered paths match the index-free enumerated leaves. + const key = [...form.basePath, ...path] + .filter((segment) => typeof segment !== 'number') + .join('.'); + coveredPaths.add(key); + }); + } +} + +describe('editor schema completeness', () => { + // Direction 1: every editor field path is a real configuration key. + describe('every form field resolves to a matching configuration key', () => { + for (const [section, forms] of Object.entries(SECTION_FORMS)) { + for (const form of forms) { + forEachFieldRecursively(form.schema, (path, field) => { + const fullPath = [...form.basePath, ...path]; + const key = fullPath.join('.'); + + it(`${section}: ${key}`, () => { + const resolved = resolvePath(fullPath); + expect(resolved, `path ${key} is not in the config schema`).not.toBeNull(); + + // Direction 1b: the selector kind matches the zod type where the + // type is unambiguous (number/boolean/enum). + const kind = selectorKind(field); + if (resolved instanceof z.ZodNumber) { + expect(kind, `${key} should use a number selector`).toBe('number'); + } else if (resolved instanceof z.ZodBoolean) { + expect(kind, `${key} should use a boolean selector`).toBe('boolean'); + } else { + const values = resolved ? enumValues(resolved) : null; + if (values) { + expect(kind, `${key} should use a select selector`).toBe('select'); + // Direction 1c: the dropdown offers every enum value. + if ('select' in field.selector) { + const options = field.selector.select.options.map((option) => + typeof option === 'object' ? option.value : option, + ); + for (const value of values) { + expect( + options, + `${key} dropdown is missing enum value ${String(value)}`, + ).toContain(value); + } + } + } + } + }); + }); + } + } + }); + + // Direction 2: every configurable field (outside the excluded subtrees) + // appears in the editor. This is the forgotten-feature catcher. + describe('every configurable field appears in the editor', () => { + for (const path of enumerateConfigLeavesRecursively( + advancedCameraCardConfigSchema, + )) { + it(path, () => { + expect( + coveredPaths.has(path), + `${path} is a configurable field but is not in any editor form ` + + `(add it to the editor, or to EDITOR_CUSTOM_WIDGETS / EDITOR_EXCLUDED)`, + ).toBe(true); + }); + } + }); +}); diff --git a/tests/components-lib/editor/schema/registry.test.ts b/tests/components-lib/editor/schema/registry.test.ts new file mode 100644 index 00000000..840669ef --- /dev/null +++ b/tests/components-lib/editor/schema/registry.test.ts @@ -0,0 +1,90 @@ +import { assert, describe, expect, it } from 'vitest'; + +import { getForms } from '../../../../src/components-lib/editor/schema/registry'; + +const OPTIONS = { cameras: [], folders: [] }; + +// Every section the editor offers, and where its first form edits. +const SECTIONS: [string, (string | number)[]][] = [ + ['dimensions', ['dimensions']], + ['image', ['image']], + ['live', ['live']], + ['media_gallery', ['media_gallery', 'controls']], + ['media_viewer', ['media_viewer']], + ['menu', ['menu']], + ['performance', ['performance']], + ['profiles', []], + ['remote_control', ['remote_control']], + ['status_bar', ['status_bar']], + ['timeline', ['timeline']], + ['view', ['view']], + ['view.keyboard_shortcuts', ['view', 'keyboard_shortcuts']], +]; + +describe('getForms', () => { + describe('should build the forms of a section', () => { + it.each(SECTIONS)('should build the %s section', (name, basePath) => { + const forms = getForms({ kind: 'section', name }, OPTIONS); + + expect(forms.length).toBeGreaterThan(0); + expect(forms[0].basePath).toEqual(basePath); + forms.forEach((form) => expect(form.schema.length).toBeGreaterThan(0)); + }); + + it('should build no forms for a section it does not know', () => { + expect(getForms({ kind: 'section', name: 'nonexistent' }, OPTIONS)).toEqual([]); + }); + }); + + describe('should offer a camera the other cameras, but not itself', () => { + const CAMERAS = [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + { value: 'three', label: 'Three' }, + ]; + + const getDependencyOptions = (index: number): unknown => { + const [form] = getForms( + { kind: 'camera', index }, + { ...OPTIONS, cameras: CAMERAS }, + ); + const dependencies = form.schema.find( + (field) => 'name' in field && field.name === 'dependencies', + ); + assert(dependencies && 'schema' in dependencies); + const cameras = dependencies.schema.find( + (field) => 'name' in field && field.name === 'cameras', + ); + assert(cameras && 'selector' in cameras && 'select' in cameras.selector); + return cameras.selector.select?.options; + }; + + it.each([ + [0, ['two', 'three']], + [1, ['one', 'three']], + [2, ['one', 'two']], + ])('should leave camera %i out of its own dependencies', (index, expected) => { + expect(getDependencyOptions(index)).toEqual( + CAMERAS.filter((camera) => expected.includes(camera.value)), + ); + }); + }); + + describe('should build the forms of a list item', () => { + it.each([ + [{ kind: 'camera' as const, index: 2 }, ['cameras', 2]], + [{ kind: 'folder' as const, index: 1 }, ['folders', 1]], + [{ kind: 'camera-triggers' as const, cameraIndex: 2 }, ['cameras', 2, 'triggers']], + [ + { kind: 'camera-event' as const, cameraIndex: 2, eventIndex: 4 }, + ['cameras', 2, 'triggers', 'events', 4], + ], + ])('should build the forms for %j', (request, basePath) => { + const forms = getForms(request, OPTIONS); + + expect(forms).toHaveLength(1); + expect(forms[0].basePath).toEqual(basePath); + expect(forms[0].schema.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/tests/components-lib/editor/section-controller.test.ts b/tests/components-lib/editor/section-controller.test.ts new file mode 100644 index 00000000..9ba1d305 --- /dev/null +++ b/tests/components-lib/editor/section-controller.test.ts @@ -0,0 +1,100 @@ +import { html } from 'lit'; +import { describe, expect, it, vi } from 'vitest'; + +import { SectionController } from '../../../src/components-lib/editor/section-controller'; +import { createLitElement } from '../../test-utils'; + +const OPTIONS = { cameras: [], folders: [] }; +const INPUT = { config: {}, defaults: {}, options: OPTIONS }; + +const createController = () => { + const host = createLitElement(); + return { host, controller: new SectionController(host, () => html`doc`) }; +}; + +// @vitest-environment jsdom +describe('SectionController', () => { + it('should register with the host', () => { + const { host, controller } = createController(); + expect(host.addController).toHaveBeenCalledWith(controller); + controller.hostConnected(); + }); + + it('should have no contexts before it is given a request', () => { + const { controller } = createController(); + expect(controller.getContexts()).toEqual([]); + }); + + it('should build the forms of the requested section', () => { + const { controller } = createController(); + + controller.setInput({ kind: 'section', name: 'menu' }, INPUT); + + const contexts = controller.getContexts(); + expect(contexts.length).toBeGreaterThan(0); + expect(contexts[0].form.basePath).toEqual(['menu']); + }); + + it('should report an edit as an intent', () => { + const { host, controller } = createController(); + const listener = vi.fn(); + host.addEventListener('advanced-camera-card:editor:intent', listener); + controller.setInput({ kind: 'section', name: 'menu' }, INPUT); + + controller + .getContexts()[0] + .valueChanged( + new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }), + ); + + const event = listener.mock.lastCall?.[0]; + expect(event).toBeInstanceOf(CustomEvent); + if (event instanceof CustomEvent) { + expect(event.detail).toEqual({ + type: 'changes', + changes: [{ path: ['menu', 'style'], type: 'set', value: 'outside' }], + }); + // The editor is the only thing that listens, and it sits outside this + // section's shadow root. + expect(event.bubbles).toBe(true); + expect(event.composed).toBe(true); + } + }); + + describe('should track whether it is open', () => { + it('should start closed and never opened', () => { + const { controller } = createController(); + expect(controller.isOpen()).toBe(false); + expect(controller.wasEverOpened()).toBe(false); + }); + + it('should open and request an update', () => { + const { host, controller } = createController(); + + controller.setOpen(true); + + expect(controller.isOpen()).toBe(true); + expect(controller.wasEverOpened()).toBe(true); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + }); + + it('should remember having been opened after it closes', () => { + const { controller } = createController(); + controller.setOpen(true); + + controller.setOpen(false); + + expect(controller.isOpen()).toBe(false); + expect(controller.wasEverOpened()).toBe(true); + }); + + it('should ignore a state it is already in', () => { + const { host, controller } = createController(); + controller.setOpen(true); + + controller.setOpen(true); + + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/components-lib/editor/titles.test.ts b/tests/components-lib/editor/titles.test.ts new file mode 100644 index 00000000..45a3db97 --- /dev/null +++ b/tests/components-lib/editor/titles.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { + getEditorCameraTitle, + getEditorFolderTitle, + getEditorTriggerEventTitle, +} from '../../../src/components-lib/editor/titles'; +import { createHASS, createStateEntity } from '../../test-utils'; + +describe('getEditorCameraTitle', () => { + it('should prefer the configured title', () => { + expect(getEditorCameraTitle(0, { title: 'Front', id: 'front' })).toBe('Front'); + }); + + it('should use the camera entity title', () => { + const hass = createHASS({ + 'camera.front': createStateEntity({ + attributes: { friendly_name: 'Front Door' }, + }), + }); + expect(getEditorCameraTitle(0, { camera_entity: 'camera.front' }, hass)).toBe( + 'Front Door', + ); + }); + + it('should use the webrtc_card entity', () => { + expect(getEditorCameraTitle(0, { webrtc_card: { entity: 'camera.front' } })).toBe( + 'camera.front', + ); + }); + + it('should prettify the frigate camera name', () => { + expect(getEditorCameraTitle(0, { frigate: { camera_name: 'front_door' } })).toBe( + 'Front Door', + ); + }); + + it('should use the camera id', () => { + expect(getEditorCameraTitle(0, { id: 'front' })).toBe('front'); + }); + + it('should fall back to an indexed label', () => { + expect(getEditorCameraTitle(2, {})).toBe('Camera #2'); + expect(getEditorCameraTitle(2, 'NOT_AN_OBJECT')).toBe('Camera #2'); + }); +}); + +describe('getEditorFolderTitle', () => { + it('should prefer the configured title', () => { + expect(getEditorFolderTitle(0, { title: 'Snapshots', id: 'snaps' })).toBe( + 'Snapshots', + ); + }); + + it('should use the folder id', () => { + expect(getEditorFolderTitle(0, { id: 'snaps' })).toBe('snaps'); + }); + + it('should fall back to an indexed label', () => { + expect(getEditorFolderTitle(1, {})).toBe('Folder #1'); + expect(getEditorFolderTitle(1, 'NOT_AN_OBJECT')).toBe('Folder #1'); + }); +}); + +describe('getEditorTriggerEventTitle', () => { + it('should prefer the event type', () => { + expect(getEditorTriggerEventTitle(0, { event_type: 'motion' })).toBe('motion'); + }); + + it('should fall back to an indexed label', () => { + expect(getEditorTriggerEventTitle(3, {})).toBe('Event #3'); + expect(getEditorTriggerEventTitle(3, 'NOT_AN_OBJECT')).toBe('Event #3'); + }); +}); diff --git a/tests/components-lib/icon-controller.test.ts b/tests/components-lib/icon-controller.test.ts index e1ac40dc..17d80e0f 100644 --- a/tests/components-lib/icon-controller.test.ts +++ b/tests/components-lib/icon-controller.test.ts @@ -4,41 +4,28 @@ import { IconController } from '../../src/components-lib/icon-controller'; import { createHASS, createStateEntity } from '../test-utils'; describe('IconController', () => { - describe('should get custom icon', () => { - it('should return frigate SVG for frigate icon', () => { - expect(new IconController().getCustomIcon({ icon: 'frigate' })).toMatch( - /frigate.svg$/, - ); + describe('should get icon name', () => { + it.each(['frigate', 'iris', 'motioneye', 'reolink', 'tplink'])( + 'should prefix the bare legacy name %s', + (name) => { + expect(new IconController().getIconName({ icon: name })).toBe( + `advanced-camera-card:${name}`, + ); + }, + ); + + it('should leave an iconset-prefixed name untouched', () => { + expect( + new IconController().getIconName({ icon: 'advanced-camera-card:frigate' }), + ).toBe('advanced-camera-card:frigate'); }); - it('should return motioneye SVG for motioneye icon', () => { - expect(new IconController().getCustomIcon({ icon: 'motioneye' })).toMatch( - /motioneye.svg$/, - ); + it('should leave an mdi name untouched', () => { + expect(new IconController().getIconName({ icon: 'mdi:car' })).toBe('mdi:car'); }); - it('should return reolink SVG for reolink icon', () => { - expect(new IconController().getCustomIcon({ icon: 'reolink' })).toMatch( - /reolink.svg$/, - ); - }); - - it('should return tplink SVG for tplink icon', () => { - expect(new IconController().getCustomIcon({ icon: 'tplink' })).toMatch( - /tplink.svg$/, - ); - }); - - it('should return iris SVG for iris icon', () => { - expect(new IconController().getCustomIcon({ icon: 'iris' })).toMatch(/iris.svg$/); - }); - - it('should return null for mdi icon', () => { - expect(new IconController().getCustomIcon({ icon: 'mdi:car' })).toBeNull(); - }); - - it('should return null for undefined icon', () => { - expect(new IconController().getCustomIcon()).toBeNull(); + it('should return null for an undefined icon', () => { + expect(new IconController().getIconName()).toBeNull(); }); }); diff --git a/tests/components-lib/key-assigner-controller.test.ts b/tests/components-lib/key-assigner-controller.test.ts index 529a89ac..64bbd697 100644 --- a/tests/components-lib/key-assigner-controller.test.ts +++ b/tests/components-lib/key-assigner-controller.test.ts @@ -40,6 +40,35 @@ describe('KeyAssignerController', () => { }); }); + describe('should show a value without reporting it', () => { + it('should adopt the value it is given', () => { + const element = createLitElement(); + const controller = new KeyAssignerController(element); + const listener = vi.fn(); + element.addEventListener('value-changed', listener); + + controller.showValue({ key: 'ArrowLeft' }); + + expect(controller.getValue()).toEqual({ key: 'ArrowLeft' }); + expect(element.requestUpdate).toHaveBeenCalled(); + + // The value came from outside: reporting it back would have the owner + // store a shortcut the user never assigned. + expect(listener).not.toHaveBeenCalled(); + }); + + it('should do nothing for the value it already has', () => { + const element = createLitElement(); + const controller = new KeyAssignerController(element); + controller.showValue({ key: 'ArrowLeft' }); + vi.mocked(element.requestUpdate).mockClear(); + + controller.showValue({ key: 'ArrowLeft' }); + + expect(element.requestUpdate).not.toHaveBeenCalled(); + }); + }); + describe('should manage assignment state', () => { it('should not be assigned to start', () => { const element = createLitElement(); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 2ed3c5fa..f2000aad 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -107,7 +107,7 @@ describe('MenuButtonController', () => { expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, - icon: 'iris', + icon: 'advanced-camera-card:iris', enabled: true, permanent: true, priority: 50, @@ -126,7 +126,7 @@ describe('MenuButtonController', () => { expect(buttons).toContainEqual({ alignment: 'matching', state_color: true, - icon: 'iris', + icon: 'advanced-camera-card:iris', enabled: true, permanent: true, priority: 50, diff --git a/tests/components-lib/notification/notification-popup-controller.test.ts b/tests/components-lib/notification/notification-popup-controller.test.ts index 7f04be44..6ce301f3 100644 --- a/tests/components-lib/notification/notification-popup-controller.test.ts +++ b/tests/components-lib/notification/notification-popup-controller.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest'; import { NotificationPopupController } from '../../../src/components-lib/notification/notification-popup-controller'; -import { POP_OUT_ANIMATION_NAME } from '../../../src/const'; +import { POP_OUT_ANIMATION_NAME } from '../../../src/utils/animation'; import { createLitElement } from '../../test-utils'; // @vitest-environment jsdom diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index 0b4d8646..d4522f2c 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it } from 'vitest'; import { + addConfigArrayItem, copyConfig, createRangedTransform, + deleteConfigArrayItem, deleteConfigValue, deleteTransform, deleteWithOverrides, - getArrayConfigPath, getConfigValue, hasConfigUpgradeFailures, isConfigUpgradeable, + moveConfigArrayItem, moveConfigValue, setConfigValue, upgradeArrayOfObjects, @@ -88,8 +90,98 @@ describe('general functions', () => { expect(copy).not.toBe(target); }); - it('should get array config path', () => { - expect(getArrayConfigPath('a.#.b', 10)).toBe('a.[10].b'); + describe('addConfigArrayItem', () => { + it('should append an item', () => { + const config = { cameras: [{ id: 'a' }] }; + expect(addConfigArrayItem(config, 'cameras', { id: 'b' })).toBe(true); + expect(config.cameras).toEqual([{ id: 'a' }, { id: 'b' }]); + }); + + it('should append at a segment-array path', () => { + const config = { cameras: [{ triggers: { events: [{ a: 1 }] } }] }; + expect( + addConfigArrayItem(config, ['cameras', 0, 'triggers', 'events'], { b: 2 }), + ).toBe(true); + expect(config.cameras[0].triggers.events).toEqual([{ a: 1 }, { b: 2 }]); + }); + + it('should create the array when the configuration has none', () => { + const config = {}; + expect(addConfigArrayItem(config, 'cameras', { id: 'a' })).toBe(true); + expect(config).toEqual({ cameras: [{ id: 'a' }] }); + }); + + it('should replace a value that is not an array', () => { + const config = { cameras: 'junk' }; + expect(addConfigArrayItem(config, 'cameras', { id: 'a' })).toBe(true); + expect(config).toEqual({ cameras: [{ id: 'a' }] }); + }); + }); + + describe('moveConfigArrayItem', () => { + it('should move an item at a segment-array path', () => { + const config = { cameras: [{ triggers: { events: [{ a: 1 }, { b: 2 }] } }] }; + expect( + moveConfigArrayItem(config, ['cameras', 0, 'triggers', 'events'], 0, 1), + ).toBe(true); + expect(config.cameras[0].triggers.events).toEqual([{ b: 2 }, { a: 1 }]); + }); + + it('should move an item in place and report a change', () => { + const config = { cameras: [{ id: 'a' }, { id: 'b' }] }; + expect(moveConfigArrayItem(config, 'cameras', 0, 1)).toBe(true); + expect(config).toEqual({ cameras: [{ id: 'b' }, { id: 'a' }] }); + }); + + it('should return false when the path is not an array', () => { + const config = { cameras: 'nope' }; + expect(moveConfigArrayItem(config, 'cameras', 0, 1)).toBe(false); + expect(config).toEqual({ cameras: 'nope' }); + }); + + it.each([ + ['same index', 0, 0], + ['from out of bounds', 2, 0], + ['to out of bounds', 0, 2], + ['negative from', -1, 0], + ['negative to', 0, -1], + ['non-integer from', 0.5, 1], + ['non-integer to', 0, Number.NaN], + ])( + 'should return false and not modify for an impossible move (%s)', + (_name, from, to) => { + const config = { cameras: [{ id: 'a' }, { id: 'b' }] }; + expect(moveConfigArrayItem(config, 'cameras', from, to)).toBe(false); + expect(config).toEqual({ cameras: [{ id: 'a' }, { id: 'b' }] }); + }, + ); + }); + + describe('deleteConfigArrayItem', () => { + it('should delete an item in place and report a change', () => { + const config = { cameras: [{ id: 'a' }, { id: 'b' }] }; + expect(deleteConfigArrayItem(config, 'cameras', 0)).toBe(true); + expect(config).toEqual({ cameras: [{ id: 'b' }] }); + }); + + it('should return false when the path is not an array', () => { + const config = { cameras: 'nope' }; + expect(deleteConfigArrayItem(config, 'cameras', 0)).toBe(false); + expect(config).toEqual({ cameras: 'nope' }); + }); + + it.each([ + ['out of bounds', 1], + ['negative', -1], + ['non-integer', 0.5], + ])( + 'should return false and not modify for an impossible delete (%s)', + (_name, index) => { + const config = { cameras: [{ id: 'a' }] }; + expect(deleteConfigArrayItem(config, 'cameras', index)).toBe(false); + expect(config).toEqual({ cameras: [{ id: 'a' }] }); + }, + ); }); }); @@ -5220,14 +5312,27 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); - it('should migrate an empty legacy array', () => { + it('should leave an empty array alone', () => { + // An empty list says nothing about which of the two shapes it is, and + // is valid under the new schema. Claiming it would rename the key of a + // user who has simply deleted their last event trigger. const config = { type: 'custom:advanced-camera-card', cameras: [{ camera_entity: 'camera.office', triggers: { events: [] } }], }; - expect(upgradeConfig(config)).toBeTruthy(); - expect(config.cameras[0].triggers).toEqual({ media_events: [] }); - postUpgradeChecks(config); + upgradeConfig(config); + expect(config.cameras[0].triggers).toEqual({ events: [] }); + }); + + it('should not report an upgrade for an empty array', () => { + // Otherwise the editor offers an upgrade to a user who has only + // deleted their last event trigger. + expect( + isConfigUpgradeable({ + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office', triggers: { events: [] } }], + }), + ).toBeFalsy(); }); it('should leave the new object-array shape untouched', () => { diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index d9e2f6c4..a702ad1f 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -503,7 +503,6 @@ describe('config defaults', () => { format: { '24h': true, }, - pan_mode: 'pan', show_recordings: true, style: 'stack', window_seconds: 3600, diff --git a/tests/ha/custom-icons.test.ts b/tests/ha/custom-icons.test.ts new file mode 100644 index 00000000..c78dccc7 --- /dev/null +++ b/tests/ha/custom-icons.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + CUSTOM_ICON_NAMES, + CUSTOM_ICONSET_PREFIX, + registerCustomIconset, +} from '../../src/ha/custom-icons'; + +// @vitest-environment jsdom +describe('registerCustomIconset', () => { + beforeEach(() => { + delete window.customIcons; + }); + + it('should register the iconset', () => { + registerCustomIconset(); + + expect(window.customIcons?.[CUSTOM_ICONSET_PREFIX]).toBeDefined(); + }); + + it('should leave an existing registration in place', () => { + registerCustomIconset(); + const helpers = window.customIcons?.[CUSTOM_ICONSET_PREFIX]; + + registerCustomIconset(); + + expect(window.customIcons?.[CUSTOM_ICONSET_PREFIX]).toBe(helpers); + }); + + it('should leave other iconsets untouched', () => { + const other = { + getIcon: async () => ({ path: '' }), + getIconList: async () => [], + }; + window.customIcons = { other: other }; + + registerCustomIconset(); + + expect(window.customIcons['other']).toBe(other); + expect(window.customIcons[CUSTOM_ICONSET_PREFIX]).toBeDefined(); + }); + + it('should resolve an icon', async () => { + registerCustomIconset(); + + await expect( + window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIcon('frigate'), + ).resolves.toEqual({ + path: expect.stringMatching(/^M/), + viewBox: '0 0 24 24', + }); + }); + + it('should resolve an icon with a non-default viewBox', async () => { + registerCustomIconset(); + + await expect( + window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIcon('tplink'), + ).resolves.toEqual({ + path: expect.stringMatching(/^M/), + viewBox: '0 0 256 256', + }); + }); + + it('should reject an unknown icon', async () => { + registerCustomIconset(); + + await expect( + window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIcon('unknown'), + ).rejects.toThrow('Unknown icon: advanced-camera-card:unknown'); + }); + + it('should list the icons', async () => { + registerCustomIconset(); + + await expect( + window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIconList(), + ).resolves.toEqual([ + { name: 'frigate' }, + { name: 'iris' }, + { name: 'motioneye' }, + { name: 'reolink' }, + { name: 'tplink' }, + ]); + }); +}); + +describe('custom icon assets', () => { + it.each(CUSTOM_ICON_NAMES)('should have a single-path SVG asset for %s', (name) => { + const source = readFileSync( + join(process.cwd(), 'src', 'images', 'icons', `${name}.svg`), + 'utf-8', + ); + const doc = new DOMParser().parseFromString(source, 'image/svg+xml'); + + expect(doc.querySelectorAll('svg > path')).toHaveLength(1); + expect(doc.querySelector('svg > path')?.getAttribute('d')).toBeTruthy(); + expect(doc.querySelector('svg')?.getAttribute('viewBox')).toBeTruthy(); + }); +}); diff --git a/tests/localize/localize.test.ts b/tests/localize/localize.test.ts index c788bdc1..602d7a73 100644 --- a/tests/localize/localize.test.ts +++ b/tests/localize/localize.test.ts @@ -244,12 +244,12 @@ describe('localize', () => { expect(localize('common.advanced_camera_card', '', '')).toBe('Advanced Camera Card'); }); - it('should throw for completely missing key', async () => { + it('should return the key for completely missing key', async () => { const { localize } = await importFresh(); - // The first reduce throws (caught), the English fallback also throws - // (uncaught), so this will throw a TypeError. - expect(() => localize('nonexistent.deeply.nested.key')).toThrow(); + expect(localize('nonexistent.deeply.nested.key')).toBe( + 'nonexistent.deeply.nested.key', + ); }); it('should return translated value for single-segment key', async () => { diff --git a/tsconfig.json b/tsconfig.json index 06b87214..8177daaf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,12 +24,15 @@ // These tags are provided by the HA frontend and [almost all] cannot be // imported by a custom card directly. "globalTags": [ + "ha-alert", "ha-button", "ha-camera-stream", "ha-card", "ha-combo-box", "ha-dropdown-item", "ha-dropdown", + "ha-expansion-panel", + "ha-form", "ha-hls-player", "ha-icon-button", "ha-icon", diff --git a/vite.config.ts b/vite.config.ts index 5a00461e..e2a591b9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vitest/config'; +import { svgPath } from './scripts/svg-path-plugin.js'; + const EXCLUSIONS = [ '.eslintrc.cjs', 'docs/**', @@ -21,6 +23,7 @@ const INCLUSIONS = ['tests/**/*.test.ts']; // ts-prune-ignore-next export default defineConfig({ + plugins: [svgPath()], test: { server: { deps: { diff --git a/yarn.lock b/yarn.lock index fbf032ec..4bcd3e25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2679,7 +2679,6 @@ __metadata: rollup-plugin-git-info: "npm:^1.0.0" rollup-plugin-serve: "npm:^1.1.1" rollup-plugin-styler: "npm:^1.8.0" - rollup-plugin-svgo: "npm:^2.0.0" rollup-plugin-visualizer: "npm:^5.12.0" sass: "patch:sass@npm%3A1.77.4#~/.yarn/patches/sass-npm-1.77.4-13b6910aea.patch" screenfull: "npm:^6.0.2" @@ -3890,19 +3889,6 @@ __metadata: languageName: node linkType: hard -"css-select@npm:^4.1.3": - version: 4.3.0 - resolution: "css-select@npm:4.3.0" - dependencies: - boolbase: "npm:^1.0.0" - css-what: "npm:^6.0.1" - domhandler: "npm:^4.3.1" - domutils: "npm:^2.8.0" - nth-check: "npm:^2.0.1" - checksum: 10c0/a489d8e5628e61063d5a8fe0fa1cc7ae2478cb334a388a354e91cf2908154be97eac9fa7ed4dffe87a3e06cf6fcaa6016553115335c4fd3377e13dac7bd5a8e1 - languageName: node - linkType: hard - "css-select@npm:^5.1.0": version: 5.1.0 resolution: "css-select@npm:5.1.0" @@ -3916,16 +3902,6 @@ __metadata: languageName: node linkType: hard -"css-tree@npm:^1.1.2, css-tree@npm:^1.1.3": - version: 1.1.3 - resolution: "css-tree@npm:1.1.3" - dependencies: - mdn-data: "npm:2.0.14" - source-map: "npm:^0.6.1" - checksum: 10c0/499a507bfa39b8b2128f49736882c0dd636b0cd3370f2c69f4558ec86d269113286b7df469afc955de6a68b0dba00bc533e40022a73698081d600072d5d83c1c - languageName: node - linkType: hard - "css-tree@npm:^2.3.1": version: 2.3.1 resolution: "css-tree@npm:2.3.1" @@ -3946,7 +3922,7 @@ __metadata: languageName: node linkType: hard -"css-what@npm:^6.0.1, css-what@npm:^6.1.0": +"css-what@npm:^6.1.0": version: 6.1.0 resolution: "css-what@npm:6.1.0" checksum: 10c0/a09f5a6b14ba8dcf57ae9a59474722e80f20406c53a61e9aedb0eedc693b135113ffe2983f4efc4b5065ae639442e9ae88df24941ef159c218b231011d733746 @@ -4030,15 +4006,6 @@ __metadata: languageName: node linkType: hard -"csso@npm:^4.2.0": - version: 4.2.0 - resolution: "csso@npm:4.2.0" - dependencies: - css-tree: "npm:^1.1.2" - checksum: 10c0/f8c6b1300efaa0f8855a7905ae3794a29c6496e7f16a71dec31eb6ca7cfb1f058a4b03fd39b66c4deac6cb06bf6b4ba86da7b67d7320389cb9994d52b924b903 - languageName: node - linkType: hard - "csso@npm:^5.0.5": version: 5.0.5 resolution: "csso@npm:5.0.5" @@ -4354,17 +4321,6 @@ __metadata: languageName: node linkType: hard -"dom-serializer@npm:^1.0.1": - version: 1.4.1 - resolution: "dom-serializer@npm:1.4.1" - dependencies: - domelementtype: "npm:^2.0.1" - domhandler: "npm:^4.2.0" - entities: "npm:^2.0.0" - checksum: 10c0/67d775fa1ea3de52035c98168ddcd59418356943b5eccb80e3c8b3da53adb8e37edb2cc2f885802b7b1765bf5022aec21dfc32910d7f9e6de4c3148f095ab5e0 - languageName: node - linkType: hard - "dom-serializer@npm:^2.0.0": version: 2.0.0 resolution: "dom-serializer@npm:2.0.0" @@ -4376,7 +4332,7 @@ __metadata: languageName: node linkType: hard -"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0, domelementtype@npm:^2.3.0": +"domelementtype@npm:^2.3.0": version: 2.3.0 resolution: "domelementtype@npm:2.3.0" checksum: 10c0/686f5a9ef0fff078c1412c05db73a0dce096190036f33e400a07e2a4518e9f56b1e324f5c576a0a747ef0e75b5d985c040b0d51945ce780c0dd3c625a18cd8c9 @@ -4392,15 +4348,6 @@ __metadata: languageName: node linkType: hard -"domhandler@npm:^4.2.0, domhandler@npm:^4.3.1": - version: 4.3.1 - resolution: "domhandler@npm:4.3.1" - dependencies: - domelementtype: "npm:^2.2.0" - checksum: 10c0/5c199c7468cb052a8b5ab80b13528f0db3d794c64fc050ba793b574e158e67c93f8336e87fd81e9d5ee43b0e04aea4d8b93ed7be4899cb726a1601b3ba18538b - languageName: node - linkType: hard - "domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": version: 5.0.3 resolution: "domhandler@npm:5.0.3" @@ -4410,17 +4357,6 @@ __metadata: languageName: node linkType: hard -"domutils@npm:^2.8.0": - version: 2.8.0 - resolution: "domutils@npm:2.8.0" - dependencies: - dom-serializer: "npm:^1.0.1" - domelementtype: "npm:^2.2.0" - domhandler: "npm:^4.2.0" - checksum: 10c0/d58e2ae01922f0dd55894e61d18119924d88091837887bf1438f2327f32c65eb76426bd9384f81e7d6dcfb048e0f83c19b222ad7101176ad68cdc9c695b563db - languageName: node - linkType: hard - "domutils@npm:^3.0.1": version: 3.1.0 resolution: "domutils@npm:3.1.0" @@ -4559,13 +4495,6 @@ __metadata: languageName: node linkType: hard -"entities@npm:^2.0.0": - version: 2.2.0 - resolution: "entities@npm:2.2.0" - checksum: 10c0/7fba6af1f116300d2ba1c5673fc218af1961b20908638391b4e1e6d5850314ee2ac3ec22d741b3a8060479911c99305164aed19b6254bde75e7e6b1b2c3f3aa3 - languageName: node - linkType: hard - "entities@npm:^4.2.0, entities@npm:^4.4.0": version: 4.5.0 resolution: "entities@npm:4.5.0" @@ -7464,13 +7393,6 @@ __metadata: languageName: node linkType: hard -"mdn-data@npm:2.0.14": - version: 2.0.14 - resolution: "mdn-data@npm:2.0.14" - checksum: 10c0/67241f8708c1e665a061d2b042d2d243366e93e5bf1f917693007f6d55111588b952dcbfd3ea9c2d0969fb754aad81b30fdcfdcc24546495fc3b24336b28d4bd - languageName: node - linkType: hard - "mdn-data@npm:2.0.28": version: 2.0.28 resolution: "mdn-data@npm:2.0.28" @@ -9794,15 +9716,6 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-svgo@npm:^2.0.0": - version: 2.0.0 - resolution: "rollup-plugin-svgo@npm:2.0.0" - dependencies: - svgo: "npm:2.8.0" - checksum: 10c0/3dead444692eca0c99da8ed668e9a835ceaf6a1e0c6eaf060ae0b113f3f227127d2efb2ba657f910bc51c5733b92a2181bdc393dbf6b905da19c2e37997a8fa3 - languageName: node - linkType: hard - "rollup-plugin-visualizer@npm:^5.12.0": version: 5.12.0 resolution: "rollup-plugin-visualizer@npm:5.12.0" @@ -10456,13 +10369,6 @@ __metadata: languageName: node linkType: hard -"stable@npm:^0.1.8": - version: 0.1.8 - resolution: "stable@npm:0.1.8" - checksum: 10c0/df74b5883075076e78f8e365e4068ecd977af6c09da510cfc3148a303d4b87bc9aa8f7c48feb67ed4ef970b6140bd9eabba2129e28024aa88df5ea0114cba39d - languageName: node - linkType: hard - "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2" @@ -10716,23 +10622,6 @@ __metadata: languageName: node linkType: hard -"svgo@npm:2.8.0": - version: 2.8.0 - resolution: "svgo@npm:2.8.0" - dependencies: - "@trysound/sax": "npm:0.2.0" - commander: "npm:^7.2.0" - css-select: "npm:^4.1.3" - css-tree: "npm:^1.1.3" - csso: "npm:^4.2.0" - picocolors: "npm:^1.0.0" - stable: "npm:^0.1.8" - bin: - svgo: bin/svgo - checksum: 10c0/0741f5d5cad63111a90a0ce7a1a5a9013f6d293e871b75efe39addb57f29a263e45294e485a4d2ff9cc260a5d142c8b5937b2234b4ef05efdd2706fb2d360ecc - languageName: node - linkType: hard - "svgo@npm:^3.2.0": version: 3.3.2 resolution: "svgo@npm:3.3.2"