Merge pull request #1105 from dermotduffy/merge-v5-back-into-main

Merge the `release-5.0.0` branch back into main
This commit is contained in:
Dermot Duffy
2023-04-22 10:44:14 -07:00
committed by GitHub
174 changed files with 25859 additions and 10791 deletions
+8 -9
View File
@@ -1,10 +1,9 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16 FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04
# Install Docker USER vscode
ENV DOCKER_BUILDKIT="1"
# https://github.com/microsoft/vscode-dev-containers/commits/main/script-library/docker-debian.sh # Install Volta
ARG DOCKER_SCRIPT_VERSION="364972b0d7d20ee5de40c1084e65f3f1bc6d5951" ARG HOME="/home/vscode"
RUN bash -c "$(curl -fsSL "https://raw.githubusercontent.com/microsoft/vscode-dev-containers/${DOCKER_SCRIPT_VERSION}/script-library/docker-debian.sh")" \ ENV VOLTA_HOME="${HOME}/.volta"
&& rm -rf /var/lib/apt/lists/* ENV PATH="${VOLTA_HOME}/bin:${PATH}"
ENTRYPOINT ["/usr/local/share/docker-init.sh"] RUN bash -c "$(curl -fsSL https://get.volta.sh)" -- --skip-setup
CMD ["sleep", "infinity"]
+35 -25
View File
@@ -2,8 +2,15 @@
{ {
"dockerComposeFile": "../docker-compose.yml", "dockerComposeFile": "../docker-compose.yml",
"service": "dev", "service": "dev",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"moby": false,
"dockerDashComposeVersion": "v2"
}
},
"workspaceFolder": "${localWorkspaceFolder}", "workspaceFolder": "${localWorkspaceFolder}",
"shutdownAction": "stopCompose", "shutdownAction": "stopCompose",
"forwardPorts": [10001, "hass:8123", "frigate:5000"],
"portsAttributes": { "portsAttributes": {
"10001": { "10001": {
"label": "Rollup", "label": "Rollup",
@@ -18,33 +25,36 @@
"onAutoForward": "silent" "onAutoForward": "silent"
} }
}, },
"forwardPorts": [10001, "hass:8123", "frigate:5000"],
"initializeCommand": ".devcontainer/initialize.sh", "initializeCommand": ".devcontainer/initialize.sh",
"postCreateCommand": "yarn install", "postCreateCommand": "yarn install",
"extensions": [ "customizations": {
"github.vscode-pull-request-github", "vscode": {
"eamodio.gitlens", "extensions": [
"dbaeumer.vscode-eslint", "github.vscode-pull-request-github",
"esbenp.prettier-vscode", "eamodio.gitlens",
"bierner.lit-html", "dbaeumer.vscode-eslint",
"runem.lit-plugin", "esbenp.prettier-vscode",
"davidanson.vscode-markdownlint", "bierner.lit-html",
"redhat.vscode-yaml", "runem.lit-plugin",
"lokalise.i18n-ally", "davidanson.vscode-markdownlint",
"ms-azuretools.vscode-docker" "redhat.vscode-yaml",
], "lokalise.i18n-ally",
"settings": { "ms-azuretools.vscode-docker"
"files.eol": "\n", ],
"editor.tabSize": 2, "settings": {
"editor.formatOnPaste": false, "files.eol": "\n",
"editor.formatOnSave": true, "editor.tabSize": 2,
"editor.formatOnType": true, "editor.formatOnPaste": false,
"files.trimTrailingWhitespace": true, "editor.formatOnSave": true,
"[json]": { "editor.formatOnType": true,
"editor.defaultFormatter": "esbenp.prettier-vscode" "files.trimTrailingWhitespace": true,
}, "[json]": {
"[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "esbenp.prettier-vscode" },
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
} }
} }
} }
+1 -69
View File
@@ -18,85 +18,17 @@
"cameras": [ "cameras": [
{ {
"camera_entity": "camera.big_buck_bunny", "camera_entity": "camera.big_buck_bunny",
"live_provider": "frigate-jsmpeg", "live_provider": "jsmpeg",
"id": "big_buck_bunny_jsmpeg" "id": "big_buck_bunny_jsmpeg"
}, },
{ {
"camera_entity": "camera.big_buck_bunny", "camera_entity": "camera.big_buck_bunny",
"live_provider": "ha", "live_provider": "ha",
"id": "big_buck_bunny_ha" "id": "big_buck_bunny_ha"
},
{
"camera_entity": "camera.demo_camera"
} }
] ]
} }
] ]
},
{
"theme": "Backend-selected",
"title": "Menu",
"path": "menu",
"badges": [],
"cards": [
{
"type": "custom:frigate-card",
"cameras": [
{
"camera_entity": "camera.demo_camera",
"title": "Default"
}
]
},
{
"type": "custom:frigate-card",
"cameras": [
{
"camera_entity": "camera.demo_camera",
"title": "Hidden"
}
],
"menu": {
"style": "hidden"
}
},
{
"type": "custom:frigate-card",
"cameras": [
{
"camera_entity": "camera.demo_camera",
"title": "Overlay"
}
],
"menu": {
"style": "overlay"
}
},
{
"type": "custom:frigate-card",
"cameras": [
{
"camera_entity": "camera.demo_camera",
"title": "Hover"
}
],
"menu": {
"style": "hover"
}
},
{
"type": "custom:frigate-card",
"cameras": [
{
"camera_entity": "camera.demo_camera",
"title": "Outside"
}
],
"menu": {
"style": "outside"
}
}
]
} }
] ]
} }
+14 -5
View File
@@ -16,11 +16,20 @@ jobs:
name: Test build name: Test build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - name: Checkout
uses: actions/checkout@v3
- name: Setup Node and Yarn
uses: volta-cli/action@v3
- name: Install dependencies
run: yarn install --immutable
- name: Build - name: Build
run: | run: yarn run build
yarn install
yarn run build - name: Test
run: yarn run test
- name: HACS build validation - name: HACS build validation
uses: "hacs/action@21.12.1" uses: "hacs/action@21.12.1"
@@ -35,4 +44,4 @@ jobs:
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: frigate-hass-card name: frigate-hass-card
path: dist/frigate-hass-card.js path: dist/*.js
+32 -12
View File
@@ -4,32 +4,52 @@ name: Release
on: on:
release: release:
types: [published] types: [published]
workflow_dispatch:
jobs: jobs:
release: release:
name: Prepare release name: Prepare release
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - name: Checkout
uses: actions/checkout@v3
# Build - name: Setup Node and Yarn
- name: Build the file uses: volta-cli/action@v3
run: |
yarn install
yarn run build
# Upload build file to the releas as an asset. - name: Install dependencies
- name: Upload zip to release run: yarn install --immutable
- name: Build the files
run: yarn run build
- name: Zip the files
uses: thedoctor0/zip-release@0.7.1
with:
type: zip
path: dist
filename: frigate-hass-card.zip
- name: Upload JS files to release
uses: svenstaro/upload-release-action@2.5.0 uses: svenstaro/upload-release-action@2.5.0
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
file: dist/frigate-hass-card.js file: dist/*.js
asset_name: frigate-hass-card.js file_glob: true
tag: ${{ github.ref }}
overwrite: true
- name: Upload Zip file to release
uses: svenstaro/upload-release-action@2.5.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: frigate-hass-card.zip
tag: ${{ github.ref }} tag: ${{ github.ref }}
overwrite: true overwrite: true
- name: HACS release validation - name: HACS release validation
uses: "hacs/action@21.12.1" uses: hacs/action@21.12.1
with: with:
category: "plugin" category: plugin
+12
View File
@@ -5,3 +5,15 @@ package-lock.json
.env .env
.envrc .envrc
stats.html
/coverage/
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
+2 -1
View File
@@ -8,6 +8,7 @@
"runem.lit-plugin", "runem.lit-plugin",
"davidanson.vscode-markdownlint", "davidanson.vscode-markdownlint",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"lokalise.i18n-ally" "lokalise.i18n-ally",
"zixuanchen.vitest-explorer"
] ]
} }
-61
View File
@@ -1,20 +1,6 @@
# Review comments generated by i18n-ally. Please commit this file. # Review comments generated by i18n-ally. Please commit this file.
reviews: reviews:
error.live_camera_not_found:
locales:
pt-BR:
translation_candidate:
source: en
text: A camera_entity configurada não foi encontrada
time: '2022-07-24T05:32:49.977Z'
error.live_camera_unavailable:
locales:
pt-BR:
translation_candidate:
source: en
text: Câmera indisponível
time: '2022-07-24T05:34:22.088Z'
config.live.show_image_during_load: config.live.show_image_during_load:
locales: locales:
it: it:
@@ -22,13 +8,6 @@ reviews:
source: en source: en
text: Mostra l'immagine fissa durante il caricamento del live streaming text: Mostra l'immagine fissa durante il caricamento del live streaming
time: '2022-08-07T23:00:31.001Z' time: '2022-08-07T23:00:31.001Z'
pt-BR:
translation_candidate:
source: en
text: >-
Mostrar imagem estática enquanto a transmissão ao vivo está
carregando
time: '2022-08-07T23:00:35.117Z'
config.common.layout.fit: config.common.layout.fit:
locales: locales:
it: it:
@@ -36,11 +15,6 @@ reviews:
source: en source: en
text: Disposizione adatta text: Disposizione adatta
time: '2022-08-12T03:00:50.438Z' time: '2022-08-12T03:00:50.438Z'
pt-BR:
translation_candidate:
source: en
text: Ajuste de layout
time: '2022-08-12T03:00:54.209Z'
config.common.layout.fits.contain: config.common.layout.fits.contain:
locales: locales:
it: it:
@@ -48,11 +22,6 @@ reviews:
source: en source: en
text: I media sono contenuti/incartati text: I media sono contenuti/incartati
time: '2022-08-12T03:01:00.885Z' time: '2022-08-12T03:01:00.885Z'
pt-BR:
translation_candidate:
source: en
text: A mídia está contida/em letterbox
time: '2022-08-12T03:01:03.607Z'
config.common.layout.fits.cover: config.common.layout.fits.cover:
locales: locales:
it: it:
@@ -60,11 +29,6 @@ reviews:
source: en source: en
text: Il supporto si espande proporzionalmente per coprire la scheda text: Il supporto si espande proporzionalmente per coprire la scheda
time: '2022-08-12T03:01:09.001Z' time: '2022-08-12T03:01:09.001Z'
pt-BR:
translation_candidate:
source: en
text: A mídia se expande proporcionalmente para cobrir o cartão
time: '2022-08-12T03:01:11.240Z'
config.common.layout.fits.fill: config.common.layout.fits.fill:
locales: locales:
it: it:
@@ -72,11 +36,6 @@ reviews:
source: en source: en
text: Il supporto viene allungato per riempire la scheda text: Il supporto viene allungato per riempire la scheda
time: '2022-08-12T03:01:14.657Z' time: '2022-08-12T03:01:14.657Z'
pt-BR:
translation_candidate:
source: en
text: A mídia é esticada para preencher o cartão
time: '2022-08-12T03:01:17.319Z'
config.common.layout.position.x: config.common.layout.position.x:
locales: locales:
it: it:
@@ -84,11 +43,6 @@ reviews:
source: en source: en
text: Percentuale di posizionamento orizzontale text: Percentuale di posizionamento orizzontale
time: '2022-08-12T03:01:20.714Z' time: '2022-08-12T03:01:20.714Z'
pt-BR:
translation_candidate:
source: en
text: Porcentagem de posicionamento horizontal
time: '2022-08-12T03:01:22.619Z'
config.common.layout.position.y: config.common.layout.position.y:
locales: locales:
it: it:
@@ -96,11 +50,6 @@ reviews:
source: en source: en
text: Percentuale di posizionamento verticale text: Percentuale di posizionamento verticale
time: '2022-08-12T03:01:26.332Z' time: '2022-08-12T03:01:26.332Z'
pt-BR:
translation_candidate:
source: en
text: Porcentagem de posicionamento vertical
time: '2022-08-12T03:01:28.398Z'
config.image.layout: config.image.layout:
locales: locales:
it: it:
@@ -108,11 +57,6 @@ reviews:
source: en source: en
text: Disposizione dell'immagine text: Disposizione dell'immagine
time: '2022-08-12T03:01:33.020Z' time: '2022-08-12T03:01:33.020Z'
pt-BR:
translation_candidate:
source: en
text: Esquema de imagem
time: '2022-08-12T03:01:34.987Z'
config.media_viewer.layout: config.media_viewer.layout:
locales: locales:
it: it:
@@ -120,8 +64,3 @@ reviews:
source: en source: en
text: Layout del visualizzatore multimediale text: Layout del visualizzatore multimediale
time: '2022-08-12T03:01:45.033Z' time: '2022-08-12T03:01:45.033Z'
pt-BR:
translation_candidate:
source: en
text: Layout do visualizador de mídia
time: '2022-08-12T03:01:46.528Z'
+2 -1
View File
@@ -4,5 +4,6 @@
"i18n-ally.sortKeys": true, "i18n-ally.sortKeys": true,
"i18n-ally.keepFulfilled": true, "i18n-ally.keepFulfilled": true,
"i18n-ally.editor.preferEditor": true, "i18n-ally.editor.preferEditor": true,
"i18n-ally.translate.saveAsCandidates": true "i18n-ally.translate.saveAsCandidates": true,
"vitest.commandLine": "npx vitest --root ."
} }
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+979 -151
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,10 +1,10 @@
--- ---
version: '3'
services: services:
dev: dev:
user: node
init: true init: true
build: .devcontainer build: .devcontainer
entrypoint: /usr/local/share/docker-init.sh
command: sleep infinity
env_file: env_file:
- .env - .env
volumes: volumes:
Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

+25 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "frigate-hass-card", "name": "frigate-hass-card",
"version": "4.0.0", "version": "5.0.0",
"description": "Frigate Lovelace Card for Home Assistant", "description": "Frigate Lovelace Card for Home Assistant",
"keywords": [ "keywords": [
"frigate", "frigate",
@@ -17,30 +17,34 @@
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.4", "@cycjimmy/jsmpeg-player": "^6.0.4",
"@egjs/hammerjs": "^2.0.17", "@egjs/hammerjs": "^2.0.17",
"@graphiteds/core": "^1.9.6",
"@lit-labs/scoped-registry-mixin": "^1.0.1",
"@lit-labs/task": "^1.1.3", "@lit-labs/task": "^1.1.3",
"@types/bluebird": "^3.5.36", "@types/bluebird": "^3.5.36",
"component-emitter": "^1.3.0", "component-emitter": "^1.3.0",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"custom-card-helpers": "^1.9.0", "custom-card-helpers": "^1.9.0",
"date-fns": "^2.29.2", "date-fns": "^2.29.2",
"embla-carousel": "^7.0.2", "date-fns-tz": "^1.3.7",
"embla-carousel": "^7.0.9",
"embla-carousel-wheel-gestures": "^3.0.0", "embla-carousel-wheel-gestures": "^3.0.0",
"home-assistant-js-websocket": "^8.0.0", "home-assistant-js-websocket": "^8.0.0",
"keycharm": "^0.4.0", "keycharm": "^0.4.0",
"lit": "^2.3.1", "lit": "^2.3.1",
"lit-flatpickr": "^0.4.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"propagating-hammerjs": "^2.0.1", "propagating-hammerjs": "^2.0.1",
"quick-lru": "^6.1.0", "quick-lru": "^6.1.0",
"screenfull": "^6.0.2", "screenfull": "^6.0.2",
"side-drawer": "^3.1.0", "side-drawer": "^3.1.0",
"ts-toolbelt": "^9.6.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"vis-data": "^7.1.3", "vis-data": "^7.1.4",
"vis-timeline": "^7.7.0", "vis-timeline": "^7.7.0",
"vis-util": "^5.0.2", "vis-util": "^5.0.2",
"web-dialog": "^0.0.11",
"xss": "^1.0.14", "xss": "^1.0.14",
"zod": "^3.19.0" "zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.19.0", "@babel/core": "^7.19.0",
@@ -48,18 +52,20 @@
"@babel/plugin-proposal-decorators": "^7.19.0", "@babel/plugin-proposal-decorators": "^7.19.0",
"@rollup/plugin-babel": "^5.3.1", "@rollup/plugin-babel": "^5.3.1",
"@rollup/plugin-commonjs": "^22.0.2", "@rollup/plugin-commonjs": "^22.0.2",
"@rollup/plugin-image": "^2.1.1", "@rollup/plugin-image": "^3.0.2",
"@rollup/plugin-json": "^4.1.0", "@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.3.0", "@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-replace": "^4.0.0", "@rollup/plugin-replace": "^4.0.0",
"@types/lodash-es": "^4.17.5", "@types/lodash-es": "^4.17.5",
"@typescript-eslint/eslint-plugin": "^5.36.2", "@typescript-eslint/eslint-plugin": "^5.36.2",
"@typescript-eslint/parser": "^5.36.2", "@typescript-eslint/parser": "^5.36.2",
"@vitest/coverage-c8": "^0.29.8",
"eslint": "^8.23.0", "eslint": "^8.23.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"jsdom": "^21.1.1",
"prettier": "^2.6.0", "prettier": "^2.6.0",
"rollup": "^2.79.0", "rollup": "^2.79.0",
"rollup-plugin-git-info": "^1.0.0", "rollup-plugin-git-info": "^1.0.0",
@@ -67,13 +73,24 @@
"rollup-plugin-styles": "^4.0.0", "rollup-plugin-styles": "^4.0.0",
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.33.0", "rollup-plugin-typescript2": "^0.33.0",
"rollup-plugin-visualizer": "^5.8.2",
"sass": "^1.54.9", "sass": "^1.54.9",
"typescript": "^4.8.3" "ts-prune": "^0.10.3",
"typescript": "^4.9.5",
"vitest": "^0.29.8",
"vitest-mock-extended": "^1.1.3"
}, },
"scripts": { "scripts": {
"start": "rollup -c --watch", "start": "rollup -c --watch",
"build": "yarn run lint && yarn run rollup", "build": "yarn run lint && yarn run rollup",
"lint": "eslint 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"rollup": "rollup -c" "rollup": "rollup -c",
"prune": "ts-prune",
"test": "vitest run",
"coverage": "vitest run --coverage"
},
"volta": {
"node": "18.14.0",
"yarn": "3.4.1"
} }
} }
+24 -8
View File
@@ -1,7 +1,6 @@
import typescript from 'rollup-plugin-typescript2'; import typescript from 'rollup-plugin-typescript2';
import commonjs from '@rollup/plugin-commonjs'; import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve'; import nodeResolve from '@rollup/plugin-node-resolve';
import babel from '@rollup/plugin-babel';
import { terser } from 'rollup-plugin-terser'; import { terser } from 'rollup-plugin-terser';
import serve from 'rollup-plugin-serve'; import serve from 'rollup-plugin-serve';
import json from '@rollup/plugin-json'; import json from '@rollup/plugin-json';
@@ -9,6 +8,7 @@ import styles from 'rollup-plugin-styles';
import image from '@rollup/plugin-image'; import image from '@rollup/plugin-image';
import replace from '@rollup/plugin-replace'; import replace from '@rollup/plugin-replace';
import gitInfo from 'rollup-plugin-git-info'; import gitInfo from 'rollup-plugin-git-info';
import { visualizer } from 'rollup-plugin-visualizer';
const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1'; const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1';
const dev = watch || process.env.DEV === 'true' || process.env.DEV === '1'; const dev = watch || process.env.DEV === 'true' || process.env.DEV === '1';
@@ -46,13 +46,10 @@ const plugins = [
}), }),
commonjs({ commonjs({
include: 'node_modules/**', include: 'node_modules/**',
sourceMap: false,
}), }),
typescript(), typescript(),
json({ exclude: 'package.json' }), json({ exclude: 'package.json' }),
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**',
}),
replace({ replace({
preventAssignment: true, preventAssignment: true,
values: { values: {
@@ -61,6 +58,7 @@ const plugins = [
}), }),
watch && serve(serveopts), watch && serve(serveopts),
!dev && terser(), !dev && terser(),
visualizer(),
]; ];
/** /**
@@ -68,19 +66,37 @@ const plugins = [
*/ */
const config = { const config = {
input: 'src/card.ts', input: 'src/card.ts',
// Specifically want a facade created as HACS will attach a hacstag
// queryparameter to the resource. Without a facade when chunks re-import the
// card chunk, they'll refer to a 'different' copy of the card chunk without
// the hacstag, causing a re-download of the same content and functionality
// problems.
preserveEntrySignatures: 'strict',
output: { output: {
file: 'dist/frigate-hass-card.js', entryFileNames: 'frigate-hass-card.js',
dir: 'dist',
chunkFileNames: (chunk) => {
// Add "lang-" to the front of the language chunk names for readability.
if (
chunk.facadeModuleId &&
chunk.facadeModuleId.match(/localize\/languages\/.*\.json/)
) {
return 'lang-[name]-[hash].js';
}
return '[name]-[hash].js';
},
format: 'es', format: 'es',
...(dev && { ...(dev && {
sourcemap: true, sourcemap: true,
}), }),
}, },
plugins: plugins, plugins: plugins,
// These two files use this at the toplevel, which causes rollup warning // These files use this at the toplevel, which causes rollup warning
// spam on build: `this` has been rewritten to `undefined` // spam on build: `this` has been rewritten to `undefined`.
moduleContext: { moduleContext: {
'./node_modules/@formatjs/intl-utils/lib/src/diff.js': 'window', './node_modules/@formatjs/intl-utils/lib/src/diff.js': 'window',
'./node_modules/@formatjs/intl-utils/lib/src/resolve-locale.js': 'window', './node_modules/@formatjs/intl-utils/lib/src/resolve-locale.js': 'window',
'./node_modules/flatpickr/dist/esm/index.js': 'window',
}, },
}; };
+1 -1
View File
@@ -165,7 +165,7 @@ const getActionHandler = (): ActionHandler => {
return actionhandler as ActionHandler; return actionhandler as ActionHandler;
}; };
export const actionHandlerBind = ( const actionHandlerBind = (
element: ActionHandlerElement, element: ActionHandlerElement,
options?: FrigateCardActionHandlerOptions, options?: FrigateCardActionHandlerOptions,
): void => { ): void => {
@@ -0,0 +1,215 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, ExtendedHomeAssistant } from '../../types';
import { ViewMedia } from '../../view/media';
import {
CameraManagerMediaCapabilities,
DataQuery,
EventQuery,
PartialEventQuery,
CameraConfigs,
CameraManagerCameraCapabilities,
QueryType,
CameraEndpoint,
} from '../types';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { CameraManagerEngine } from '../engine';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { CameraInitializationError } from '../error';
import { localize } from '../../localize/localize';
import { Entity } from '../../utils/ha/entity-registry/types';
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
import {
BROWSE_MEDIA_CACHE_SECONDS,
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import { BrowseMediaMetadata } from './types';
import { rangesOverlap } from '../range';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
import { canonicalizeHAURL } from '../../utils/ha';
import { RequestCache } from '../cache';
import { BrowseMediaViewMediaFactory } from './media';
/**
* A utility method to determine if a browse media object matches against a
* start and end date.
* @param media The browse media object (with rich metadata).
* @param start The optional start date.
* @param end The optional end date.
* @returns `true` if the media falls within the provided dates.
*/
export const isMediaWithinDates = (
media: RichBrowseMedia<BrowseMediaMetadata>,
start?: Date,
end?: Date,
): boolean => {
// If no date is specified at all, everything matches.
const dateReference = start ?? end;
if (!dateReference) {
return true;
}
// If there's no metadata, nothing matches.
if (!media._metadata) {
return false;
}
// Determine if:
// - The media starts within the query timeframe.
// - The media ends within the query timeframe.
// - The media entirely encompasses the query timeframe.
return rangesOverlap(
{
start: media._metadata.startDate,
end: media._metadata.endDate,
},
{
start: start ?? dateReference,
end: end ?? dateReference,
},
);
};
export const getViewMediaFromBrowseMediaArray = (
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[],
): ViewMedia[] | null => {
const lookup: Map<string, ViewMedia> = new Map();
for (const browseMediaItem of browseMedia) {
const cameraID = browseMediaItem._metadata?.cameraID;
if (!cameraID) {
continue;
}
const mediaType =
browseMediaItem.media_class === MEDIA_CLASS_VIDEO
? 'clip'
: browseMediaItem.media_class === MEDIA_CLASS_IMAGE
? 'snapshot'
: null;
if (!mediaType) {
continue;
}
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
mediaType,
browseMediaItem,
cameraID,
);
if (media) {
const id = media.getID();
const existing = lookup.get(id);
// De-duplicate events with precisely the same ID (same
// hour/minute/second) choosing clip > snapshot.
if (
!existing ||
(existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip')
) {
lookup.set(id, media);
}
}
}
return [...lookup.values()];
};
/**
* A base class for cameras that read events from HA BrowseMedia interface.
*/
export class BrowseMediaCameraManagerEngine
extends GenericCameraManagerEngine
implements CameraManagerEngine
{
protected _cameraEntities: Map<string, Entity> = new Map();
protected _browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>;
protected _resolvedMediaCache: ResolvedMediaCache;
protected _requestCache: RequestCache;
public constructor(
browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>,
resolvedMediaCache: ResolvedMediaCache,
requestCache: RequestCache,
) {
super();
this._browseMediaManager = browseMediaManager;
this._resolvedMediaCache = resolvedMediaCache;
this._requestCache = requestCache;
}
public async initializeCamera(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig> {
const entity = cameraConfig.camera_entity
? await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity)
: null;
if (!entity || !cameraConfig.camera_entity) {
throw new CameraInitializationError(
localize('error.no_camera_entity'),
cameraConfig,
);
}
this._cameraEntities.set(cameraConfig.camera_entity, entity);
return cameraConfig;
}
public generateDefaultEventQuery(
_cameras: CameraConfigs,
cameraIDs: Set<string>,
query: PartialEventQuery,
): EventQuery[] | null {
return [
{
type: QueryType.Event,
cameraIDs: cameraIDs,
...query,
},
];
}
public async getMediaDownloadPath(
hass: ExtendedHomeAssistant,
_cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<CameraEndpoint | null> {
const contentID = media.getContentID();
if (!contentID) {
return null;
}
const resolvedMedia = await resolveMedia(hass, contentID, this._resolvedMediaCache);
return resolvedMedia
? { endpoint: canonicalizeHAURL(hass, resolvedMedia.url) }
: null;
}
public getQueryResultMaxAge(query: DataQuery): number | null {
if (query.type === QueryType.Event) {
return BROWSE_MEDIA_CACHE_SECONDS;
}
return null;
}
public getCameraCapabilities(
cameraConfig: CameraConfig,
): CameraManagerCameraCapabilities | null {
const parentCapabilities = super.getCameraCapabilities(cameraConfig);
if (!parentCapabilities) {
return null;
}
return {
...parentCapabilities,
supportsClips: true,
supportsSnapshots: true,
supportsTimeline: true,
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities {
return {
canFavorite: false,
canDownload: true,
};
}
}
+85
View File
@@ -0,0 +1,85 @@
import format from 'date-fns/format';
import isEqual from 'lodash-es/isEqual';
import { formatDateAndTime } from '../../utils/basic';
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
import {
ViewMedia,
EventViewMedia,
ViewMediaType,
VideoContentType,
} from '../../view/media';
import { BrowseMediaMetadata } from '../browse-media/types';
class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia {
protected _browseMedia: RichBrowseMedia<BrowseMediaMetadata>;
protected _id: string;
constructor(
mediaType: ViewMediaType,
cameraID: string,
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
) {
super(mediaType, cameraID);
this._browseMedia = browseMedia;
// Generate a custom ID that uses the start date (to allow multiple
// BrowseMedia objects (e.g. images and movies) to be de-duplicated).
if (browseMedia._metadata?.startDate) {
this._id = `${cameraID}/${format(
browseMedia._metadata.startDate,
'yyyy-MM-dd HH:mm:ss',
)}`;
} else {
this._id = browseMedia.media_content_id;
}
}
public getStartTime(): Date | null {
return this._browseMedia._metadata?.startDate ?? null;
}
public getEndTime(): Date | null {
return null;
}
public getVideoContentType(): VideoContentType | null {
return VideoContentType.MP4;
}
public getID(): string {
return this._id;
}
public getContentID(): string {
return this._browseMedia.media_content_id;
}
public getTitle(): string | null {
const startTime = this.getStartTime();
return startTime ? formatDateAndTime(startTime) : this._browseMedia.title;
}
public getThumbnail(): string | null {
return this._browseMedia.thumbnail;
}
public getWhat(): string[] | null {
return null;
}
public getScore(): number | null {
return null;
}
public getTags(): string[] | null {
return null;
}
public isGroupableWith(that: EventViewMedia): boolean {
return (
this.getMediaType() === that.getMediaType() &&
isEqual(this.getWhere(), that.getWhere()) &&
isEqual(this.getWhat(), that.getWhat())
);
}
}
export class BrowseMediaViewMediaFactory {
static createEventViewMedia(
mediaType: 'clip' | 'snapshot',
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
cameraID: string,
): BrowseMediaEventViewMedia | null {
return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia);
}
}
+5
View File
@@ -0,0 +1,5 @@
export interface BrowseMediaMetadata {
cameraID: string;
startDate: Date;
endDate: Date;
}
+167
View File
@@ -0,0 +1,167 @@
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import sortedUniqBy from 'lodash-es/sortedUniqBy';
import { DateRange, MemoryRangeSet } from './range';
import { DataQuery, QueryResults, RecordingSegment } from './types';
interface RequestCacheItem<Request, Response> {
request: Request;
response: Response;
expires?: Date;
}
interface CameraManagerCache<Request, Response> {
get(request: Request): Response | null;
has(request: Request): boolean;
set(request: Request, response: Response, expiry?: Date): void;
}
export class MemoryRequestCache<Request, Response>
implements CameraManagerCache<Request, Response>
{
protected _data: RequestCacheItem<Request, Response>[] = [];
public get(request: Request): Response | null {
const now = new Date();
for (const item of this._data) {
if (
(!item.expires || now <= item.expires) &&
this._contains(request, item.request)
) {
return item.response;
}
}
return null;
}
public clear(): void {
this._data = [];
}
public has(request: Request): boolean {
return !!this.get(request);
}
public set(request: Request, response: Response, expiry?: Date): void {
this._data.push({
request: request,
response: response,
expires: expiry,
});
// Clean up old requests on set.
this._expireOldRequests();
}
protected _contains(a: Request, b: Request): boolean {
return isEqual(a, b);
}
protected _expireOldRequests(): void {
const now = new Date();
this._data = this._data.filter((item) => !item.expires || now < item.expires);
}
}
export class RequestCache extends MemoryRequestCache<DataQuery, QueryResults> {}
class MemoryRangedCache<Data> {
protected _ranges: MemoryRangeSet = new MemoryRangeSet();
protected _data: Data[] = [];
protected _timeFunc: (data: Data) => number;
protected _idFunc: (data: Data) => string;
constructor(timeFunc: (data: Data) => number, idFunc: (data: Data) => string) {
this._timeFunc = timeFunc;
this._idFunc = idFunc;
}
public add(range: DateRange, data: Data[]) {
this._ranges.add(range);
this._data = sortedUniqBy(
orderBy(this._data.concat(data), this._timeFunc, 'asc'),
this._idFunc,
);
}
public hasCoverage(range: DateRange): boolean {
return this._ranges.hasCoverage(range);
}
public get(range: DateRange): Data[] | null {
if (!this.hasCoverage(range)) {
return null;
}
const output: Data[] = [];
for (const data of this._data) {
const start = this._timeFunc(data);
if (start >= range.start.getTime()) {
if (start > range.end.getTime()) {
// Data is kept in order.
break;
}
output.push(data);
}
}
return output;
}
public getSize(): number {
return this._data.length;
}
/**
* Remove old data that matches a given predicate. No change to the covered
* ranges is made, i.e. this is asserting authoritiatively that this data does
* not exist in the current ranges.
* @param predicate A predicate to run on each data element.
*/
public expireMatches(predicate: (data: Data) => boolean): void {
this._data = this._data.filter((data) => !predicate(data));
}
}
export class RecordingSegmentsCache {
protected _segments: Map<string, MemoryRangedCache<RecordingSegment>> = new Map();
public add(cameraID: string, range: DateRange, segments: RecordingSegment[]) {
let cameraSegmentCache: MemoryRangedCache<RecordingSegment> | undefined =
this._segments.get(cameraID);
if (!cameraSegmentCache) {
cameraSegmentCache = new MemoryRangedCache(
(segment: RecordingSegment) => segment.start_time * 1000,
(segment: RecordingSegment) => segment.id,
);
this._segments.set(cameraID, cameraSegmentCache);
}
cameraSegmentCache.add(range, segments);
}
public clear(): void {
this._segments.clear();
}
public hasCoverage(cameraID: string, range: DateRange): boolean {
return !!this._segments.get(cameraID)?.hasCoverage(range);
}
public get(cameraID: string, range: DateRange): RecordingSegment[] | null {
return this._segments.get(cameraID)?.get(range) ?? null;
}
public getSize(cameraID: string): number | null {
return this._segments.get(cameraID)?.getSize() ?? null;
}
public getCameraIDs(): string[] {
return [...this._segments.keys()];
}
public expireMatches(
cameraID: string,
func: (segment: RecordingSegment) => boolean,
): void {
this._segments.get(cameraID)?.expireMatches(func);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { HomeAssistant } from 'custom-card-helpers';
import { localize } from '../localize/localize';
import { CameraConfig, CardWideConfig } from '../types';
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
import { BrowseMedia } from '../utils/ha/browse-media/types';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { Entity } from '../utils/ha/entity-registry/types';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache';
import { CameraManagerEngine } from './engine';
import { CameraInitializationError } from './error';
import { Engine } from './types';
export class CameraManagerEngineFactory {
protected _entityRegistryManager: EntityRegistryManager;
protected _resolvedMediaCache: ResolvedMediaCache;
protected _cardWideConfig: CardWideConfig;
constructor(
entityRegistryManager: EntityRegistryManager,
resolvedMediaCache: ResolvedMediaCache,
cardWideConfig: CardWideConfig,
) {
this._entityRegistryManager = entityRegistryManager;
this._cardWideConfig = cardWideConfig;
this._resolvedMediaCache = resolvedMediaCache;
}
public async createEngine(engine: Engine): Promise<CameraManagerEngine | null> {
let cameraManagerEngine: CameraManagerEngine | null = null;
switch (engine) {
case Engine.Generic:
const { GenericCameraManagerEngine } = await import('./generic/engine-generic');
cameraManagerEngine = new GenericCameraManagerEngine();
break;
case Engine.Frigate:
const { FrigateCameraManagerEngine } = await import('./frigate/engine-frigate');
cameraManagerEngine = new FrigateCameraManagerEngine(
this._cardWideConfig,
new RecordingSegmentsCache(),
new RequestCache(),
);
break;
case Engine.MotionEye:
const { MotionEyeCameraManagerEngine } = await import(
'./motioneye/engine-motioneye'
);
cameraManagerEngine = new MotionEyeCameraManagerEngine(
new BrowseMediaManager(new MemoryRequestCache<string, BrowseMedia>()),
this._resolvedMediaCache,
new RequestCache(),
);
}
return cameraManagerEngine;
}
public async getEngineForCamera(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<Engine | null> {
let engine: Engine | null = null;
if (cameraConfig.engine === 'frigate') {
engine = Engine.Frigate;
} else if (cameraConfig.engine === 'motioneye') {
engine = Engine.MotionEye;
} else if (cameraConfig.engine === 'auto') {
const cameraEntity = cameraConfig.camera_entity;
if (cameraEntity) {
let entity: Entity | null;
try {
entity = await this._entityRegistryManager.getEntity(hass, cameraEntity);
} catch (e) {
// Throw a slightly friendlier exception (as a typo in the entity is
// likely to be a common failure mode).
throw new CameraInitializationError(
localize('error.no_camera_entity'),
cameraConfig,
);
}
switch (entity?.platform) {
case 'frigate':
engine = Engine.Frigate;
break;
case 'motioneye':
engine = Engine.MotionEye;
break;
default:
engine = Engine.Generic;
}
} else if (cameraConfig.frigate.camera_name) {
// Frigate technically does not need an entity, if the camera name is
// manually set the camera is assumed to be Frigate.
engine = Engine.Frigate;
}
}
return engine;
}
}
+139
View File
@@ -0,0 +1,139 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, ExtendedHomeAssistant } from '../types';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { ViewMedia } from '../view/media';
import {
DataQuery,
EventQuery,
EventQueryResultsMap,
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
QueryReturnType,
RecordingQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
CameraManagerCameraCapabilities,
CameraManagerMediaCapabilities,
CameraManagerCameraMetadata,
CameraEndpointsContext,
CameraConfigs,
Engine,
CameraEndpoints,
MediaMetadataQuery,
MediaMetadataQueryResultsMap,
EngineOptions,
CameraEndpoint,
} from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
export interface CameraManagerEngine {
getEngineType(): Engine;
initializeCamera(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig>;
generateDefaultEventQuery(
cameras: CameraConfigs,
cameraIDs: Set<string>,
query: PartialEventQuery,
): EventQuery[] | null;
generateDefaultRecordingQuery(
cameras: CameraConfigs,
cameraIDs: Set<string>,
query: PartialRecordingQuery,
): RecordingQuery[] | null;
generateDefaultRecordingSegmentsQuery(
cameras: CameraConfigs,
cameraIDs: Set<string>,
query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] | null;
getEvents(
hass: HomeAssistant,
cameras: CameraConfigs,
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null>;
getRecordings(
hass: HomeAssistant,
cameras: CameraConfigs,
query: RecordingQuery,
engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap | null>;
getRecordingSegments(
hass: HomeAssistant,
cameras: CameraConfigs,
query: RecordingSegmentsQuery,
engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap | null>;
generateMediaFromEvents(
hass: HomeAssistant,
cameras: CameraConfigs,
query: EventQuery,
results: QueryReturnType<EventQuery>,
): ViewMedia[] | null;
generateMediaFromRecordings(
hass: HomeAssistant,
cameras: CameraConfigs,
query: RecordingQuery,
results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null;
getMediaDownloadPath(
hass: ExtendedHomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<CameraEndpoint | null>;
favoriteMedia(
hass: HomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
favorite: boolean,
): Promise<void>;
getQueryResultMaxAge(query: DataQuery): number | null;
getMediaSeekTime(
hass: HomeAssistant,
cameras: CameraConfigs,
media: ViewMedia,
target: Date,
engineOptions?: EngineOptions,
): Promise<number | null>;
getMediaMetadata(
hass: HomeAssistant,
cameras: CameraConfigs,
query: MediaMetadataQuery,
engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null>;
getCameraMetadata(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): CameraManagerCameraMetadata;
getCameraCapabilities(
cameraConfig: CameraConfig,
): CameraManagerCameraCapabilities | null;
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
getCameraEndpoints(
cameraConfig: CameraConfig,
context?: CameraEndpointsContext,
): CameraEndpoints | null;
}
+3
View File
@@ -0,0 +1,3 @@
import { FrigateCardError } from '../types.js';
export class CameraInitializationError extends FrigateCardError {}
@@ -0,0 +1,3 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130 446.5C131.6 459.3 145 468 137 470C129 472 94 406.5 86 378.5C78 350.5 73.5 319 75.4999 301C77.4999 283 181 255 181 247.5C181 240 147.5 247 146 241C144.5 235 171.3 238.6 178.5 229C189.75 214 204 216.5 213 208.5C222 200.5 233 170 235 157C237 144 215 129 209 119C203 109 222 102 268 83C314 64 460 22 462 27C464 32 414 53 379 66C344 79 287 104 287 111C287 118 290 123.5 288 139.5C286 155.5 285.76 162.971 282 173.5C279.5 180.5 277 197 282 212C286 224 299 233 305 235C310 235.333 323.8 235.8 339 235C358 234 385 236 385 241C385 246 344 243 344 250C344 257 386 249 385 256C384 263 350 260 332 260C317.6 260 296.333 259.333 287 256L285 263C281.667 263 274.7 265 267.5 265C258.5 265 258 268 241.5 268C225 268 230 267 215 266C200 265 144 308 134 322C124 336 130 370 130 385.5C130 399.428 128 430.5 130 446.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 936 B

File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
export const FRIGATE_ICON_SVG_PATH =
'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' +
'-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' +
'-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' +
'5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' +
'1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' +
'-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' +
'-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' +
'-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' +
'-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' +
'-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' +
'0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' +
'0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' +
'0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' +
'0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' +
'-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' +
'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' +
'-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' +
'-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' +
'-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z';
@@ -0,0 +1,18 @@
import { ViewMedia } from '../../view/media';
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
export class FrigateViewMediaClassifier {
public static isFrigateMedia(
media: ViewMedia,
): media is FrigateEventViewMedia | FrigateRecordingViewMedia {
return this.isFrigateEvent(media) || this.isFrigateRecording(media);
}
public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia {
return media instanceof FrigateEventViewMedia;
}
public static isFrigateRecording(
media: ViewMedia,
): media is FrigateRecordingViewMedia {
return media instanceof FrigateRecordingViewMedia;
}
}
+206
View File
@@ -0,0 +1,206 @@
import fromUnixTime from 'date-fns/fromUnixTime';
import isEqual from 'lodash-es/isEqual';
import { CameraConfig } from '../../types';
import {
ViewMedia,
EventViewMedia,
RecordingViewMedia,
ViewMediaType,
VideoContentType,
} from '../../view/media';
import { FrigateEvent, FrigateRecording } from './types';
import {
getEventMediaContentID,
getEventThumbnailURL,
getEventTitle,
getRecordingID,
getRecordingMediaContentID,
getRecordingTitle,
} from './util';
export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
protected _event: FrigateEvent;
protected _contentID: string;
protected _thumbnail: string;
protected _subLabels: string[] | null;
constructor(
mediaType: ViewMediaType,
cameraID: string,
event: FrigateEvent,
contentID: string,
thumbnail: string,
// See 'A note on Frigate sub_labels' in engine-frigate.ts for more
// details about why sub-labels are treated specially. By taking in
// subLabels as an array here, we can keep a single place that splits
// sublabels (`_splitSubLabels` in engine-frigate.ts).
subLabels?: string[],
) {
super(mediaType, cameraID);
this._event = event;
this._contentID = contentID;
this._thumbnail = thumbnail;
this._subLabels = subLabels ?? null;
}
public getStartTime(): Date {
return fromUnixTime(this._event.start_time);
}
public getEndTime(): Date | null {
return this._event.end_time ? fromUnixTime(this._event.end_time) : null;
}
public inProgress(): boolean | null {
// In Frigate, events/recordings always have end times unless they are in
// progress.
return !this.getEndTime();
}
public getVideoContentType(): VideoContentType | null {
return VideoContentType.HLS;
}
public getID(): string {
return this._event.id;
}
public getContentID(): string {
return this._contentID;
}
public getTitle(): string | null {
return getEventTitle(this._event);
}
public getThumbnail(): string | null {
return this._thumbnail;
}
public isFavorite(): boolean | null {
return this._event.retain_indefinitely ?? null;
}
public setFavorite(favorite: boolean): void {
this._event.retain_indefinitely = favorite;
}
public getWhat(): string[] | null {
return [this._event.label];
}
public getWhere(): string[] | null {
const zones = this._event.zones;
return zones.length ? zones : null;
}
public getScore(): number | null {
return this._event.top_score;
}
public getTags(): string[] | null {
return this._subLabels;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public isGroupableWith(that: EventViewMedia): boolean {
return (
this.getMediaType() === that.getMediaType() &&
isEqual(this.getWhere(), that.getWhere()) &&
isEqual(this.getWhat(), that.getWhat())
);
}
}
export class FrigateRecordingViewMedia extends ViewMedia implements RecordingViewMedia {
protected _recording: FrigateRecording;
protected _id: string;
protected _contentID: string;
protected _title: string;
constructor(
mediaType: ViewMediaType,
cameraID: string,
recording: FrigateRecording,
id: string,
contentID: string,
title: string,
) {
super(mediaType, cameraID);
this._recording = recording;
this._id = id;
this._contentID = contentID;
this._title = title;
}
public getID(): string {
return this._id;
}
public getStartTime(): Date {
return this._recording.startTime;
}
public getEndTime(): Date {
return this._recording.endTime;
}
public inProgress(): boolean | null {
// In Frigate, events/recordings always have end times unless they are in
// progress.
return !this.getEndTime();
}
public getVideoContentType(): VideoContentType | null {
return VideoContentType.HLS;
}
public getContentID(): string | null {
return this._contentID;
}
public getTitle(): string | null {
return this._title;
}
public getEventCount(): number {
return this._recording.events;
}
}
export class FrigateViewMediaFactory {
static createEventViewMedia(
mediaType: 'clip' | 'snapshot',
cameraID: string,
cameraConfig: CameraConfig,
event: FrigateEvent,
subLabels?: string[],
): FrigateEventViewMedia | null {
if (
(mediaType === 'clip' && !event.has_clip) ||
(mediaType === 'snapshot' && !event.has_snapshot) ||
!cameraConfig.frigate.client_id ||
!cameraConfig.frigate.camera_name
) {
return null;
}
return new FrigateEventViewMedia(
mediaType,
cameraID,
event,
getEventMediaContentID(
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
event,
mediaType === 'clip' ? 'clips' : 'snapshots',
),
getEventThumbnailURL(cameraConfig.frigate.client_id, event),
subLabels,
);
}
static createRecordingViewMedia(
cameraID: string,
recording: FrigateRecording,
cameraConfig: CameraConfig,
cameraTitle: string,
): FrigateRecordingViewMedia | null {
if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) {
return null;
}
return new FrigateRecordingViewMedia(
'recording',
cameraID,
recording,
getRecordingID(cameraConfig, recording),
getRecordingMediaContentID(
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
recording,
),
getRecordingTitle(cameraTitle, recording),
);
}
}
+153
View File
@@ -0,0 +1,153 @@
import { HomeAssistant } from 'custom-card-helpers';
import { localize } from '../../localize/localize';
import { FrigateCardError } from '../../types';
import { homeAssistantWSRequest } from '../../utils/ha';
import { RecordingSegment } from '../types';
import {
EventSummary,
eventSummarySchema,
FrigateEvent,
frigateEventsSchema,
recordingSegmentsSchema,
RecordingSummary,
recordingSummarySchema,
RetainResult,
retainResultSchema,
} from './types';
/**
* Get the recordings summary. May throw.
* @param hass The Home Assistant object.
* @param clientID The Frigate clientID.
* @param camera_name The Frigate camera name.
* @returns A RecordingSummary object.
*/
export const getRecordingsSummary = async (
hass: HomeAssistant,
clientID: string,
camera_name: string,
): Promise<RecordingSummary> => {
return (await homeAssistantWSRequest(
hass,
recordingSummarySchema,
{
type: 'frigate/recordings/summary',
instance_id: clientID,
camera: camera_name,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
true,
// See: https://github.com/colinhacks/zod/pull/1752
)) as RecordingSummary;
};
export interface NativeFrigateRecordingSegmentsQuery {
instance_id: string;
camera: string;
after: number;
before: number;
}
/**
* Get the recording segments. May throw.
* @param hass The Home Assistant object.
* @param params The recording segment query parameters.
* @returns A RecordingSegments object.
*/
export const getRecordingSegments = async (
hass: HomeAssistant,
params: NativeFrigateRecordingSegmentsQuery,
): Promise<RecordingSegment[]> => {
return await homeAssistantWSRequest(
hass,
recordingSegmentsSchema,
{
type: 'frigate/recordings/get',
...params,
},
true,
);
};
/**
* Request that Frigate retain an event. May throw.
* @param hass The HomeAssistant object.
* @param clientID The Frigate clientID.
* @param eventID The event ID to retain.
* @param retain `true` to retain or `false` to unretain.
*/
export async function retainEvent(
hass: HomeAssistant,
clientID: string,
eventID: string,
retain: boolean,
): Promise<void> {
const retainRequest = {
type: 'frigate/event/retain',
instance_id: clientID,
event_id: eventID,
retain: retain,
};
const response = await homeAssistantWSRequest<RetainResult>(
hass,
retainResultSchema,
retainRequest,
true,
);
if (!response.success) {
throw new FrigateCardError(localize('error.failed_retain'), {
request: retainRequest,
response: response,
});
}
}
export interface NativeFrigateEventQuery {
instance_id?: string;
cameras?: string[];
labels?: string[];
zones?: string[];
after?: number;
before?: number;
limit?: number;
has_clip?: boolean;
has_snapshot?: boolean;
favorites?: boolean;
}
/**
* Get events over websocket. May throw.
* @param hass The Home Assistant object.
* @param params The events search parameters.
* @returns An array of 'FrigateEvent's.
*/
export const getEvents = async (
hass: HomeAssistant,
params?: NativeFrigateEventQuery,
): Promise<FrigateEvent[]> => {
return await homeAssistantWSRequest(
hass,
frigateEventsSchema,
{
type: 'frigate/events/get',
...params,
},
true,
);
};
export const getEventSummary = async (
hass: HomeAssistant,
clientID: string,
): Promise<EventSummary> => {
return await homeAssistantWSRequest(
hass,
eventSummarySchema,
{
type: 'frigate/events/summary',
instance_id: clientID,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
true,
);
};
+99
View File
@@ -0,0 +1,99 @@
import { z } from 'zod';
import { dayToDate } from '../../utils/basic';
import {
Engine,
EventQueryResults,
RecordingQueryResults,
RecordingSegmentsQueryResults,
} from '../types';
const dayStringToDate = (arg: unknown): Date | unknown => {
return typeof arg === 'string' ? dayToDate(arg) : arg;
};
const eventSchema = z.object({
camera: z.string(),
end_time: z.number().nullable(),
false_positive: z.boolean().nullable(),
has_clip: z.boolean(),
has_snapshot: z.boolean(),
id: z.string(),
label: z.string(),
sub_label: z.string().nullable(),
start_time: z.number(),
top_score: z.number(),
zones: z.string().array(),
retain_indefinitely: z.boolean().optional(),
});
export const frigateEventsSchema = eventSchema.array();
export type FrigateEvent = z.infer<typeof eventSchema>;
const recordingSummaryHourSchema = z.object({
hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)),
duration: z.number().min(0),
events: z.number().min(0),
});
export const recordingSummarySchema = z
.object({
day: z.preprocess(dayStringToDate, z.date()),
events: z.number(),
hours: recordingSummaryHourSchema.array(),
})
.array();
export type RecordingSummary = z.infer<typeof recordingSummarySchema>;
const recordingSegmentSchema = z.object({
start_time: z.number(),
end_time: z.number(),
id: z.string(),
});
export const recordingSegmentsSchema = recordingSegmentSchema.array();
export const retainResultSchema = z.object({
success: z.boolean(),
message: z.string(),
});
export type RetainResult = z.infer<typeof retainResultSchema>;
export interface FrigateRecording {
cameraID: string;
startTime: Date;
endTime: Date;
events: number;
}
export const eventSummarySchema = z
.object({
camera: z.string(),
// Days in RFC3339 format.
day: z.string(),
label: z.string(),
sub_label: z.string().nullable(),
zones: z.string().array(),
})
.array();
export type EventSummary = z.infer<typeof eventSummarySchema>;
// ==============================
// Frigate concrete query results
// ==============================
export interface FrigateEventQueryResults extends EventQueryResults {
engine: Engine.Frigate;
instanceID: string;
events: FrigateEvent[];
}
export interface FrigateRecordingQueryResults extends RecordingQueryResults {
engine: Engine.Frigate;
instanceID: string;
recordings: FrigateRecording[];
}
export interface FrigateRecordingSegmentsQueryResults
extends RecordingSegmentsQueryResults {
engine: Engine.Frigate;
instanceID: string;
}
+97
View File
@@ -0,0 +1,97 @@
import utcToZonedTime from 'date-fns-tz/utcToZonedTime';
import { CameraConfig, ClipsOrSnapshots } from '../../types';
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
import { FrigateEvent, FrigateRecording } from './types';
/**
* Given an event generate a title.
* @param event
*/
export const getEventTitle = (event: FrigateEvent): string => {
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const durationSeconds = Math.round(
event.end_time
? event.end_time - event.start_time
: Date.now() / 1000 - event.start_time,
);
return `${formatDateAndTime(
utcToZonedTime(event.start_time * 1000, localTimezone),
)} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round(
event.top_score * 100,
)}%]`;
};
export const getRecordingTitle = (
cameraTitle: string,
recording: FrigateRecording,
): string => {
return `${cameraTitle} ${formatDateAndTime(recording.startTime)}`;
};
/**
* Get a thumbnail URL for an event.
* @param clientId The Frigate client id.
* @param event The event.
* @returns A string URL.
*/
export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => {
return `/api/frigate/${clientId}/thumbnail/${event.id}`;
};
/**
* Get a media content ID for an event.
* @param clientId The Frigate client id.
* @param cameraName The Frigate camera name.
* @param event The Frigate event.
* @param mediaType The media type required.
* @returns A string media content id.
*/
export const getEventMediaContentID = (
clientId: string,
cameraName: string,
event: FrigateEvent,
mediaType: ClipsOrSnapshots,
): string => {
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`;
};
/**
* Generate a recording identifier.
* @param clientId The Frigate client id.
* @param cameraName The Frigate camera name.
* @param recording The Frigate recording.
* @returns A recording identifier.
*/
export const getRecordingMediaContentID = (
clientId: string,
cameraName: string,
recording: FrigateRecording,
): string => {
return [
'media-source://frigate',
clientId,
'recordings',
cameraName,
`${recording.startTime.getFullYear()}-${String(
recording.startTime.getMonth() + 1,
).padStart(2, '0')}-${String(
String(recording.startTime.getDate()).padStart(2, '0'),
)}`,
String(recording.startTime.getHours()).padStart(2, '0'),
].join('/');
};
/**
* Get a recording ID for internal de-duping.
*/
export const getRecordingID = (
cameraConfig: CameraConfig,
recording: FrigateRecording,
): string => {
// ID name is derived from the real camera name (not CameraID) since the
// recordings for the same camera across multiple zones will be the same and
// can be dedup'd from this id.
return `${cameraConfig.frigate?.client_id ?? ''}/${
cameraConfig.frigate.camera_name ?? ''
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`;
};
@@ -0,0 +1,198 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, ExtendedHomeAssistant } from '../../types';
import { ViewMedia } from '../../view/media';
import {
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
EventQuery,
EventQueryResultsMap,
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
CameraEndpointsContext,
CameraConfigs,
RecordingQuery,
QueryReturnType,
CameraManagerCameraCapabilities,
Engine,
CameraEndpoints,
MediaMetadataQuery,
MediaMetadataQueryResultsMap,
EngineOptions,
CameraEndpoint,
} from '../types';
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { CameraManagerEngine } from '../engine';
export class GenericCameraManagerEngine implements CameraManagerEngine {
public getEngineType(): Engine {
return Engine.Generic;
}
public async initializeCamera(
_hass: HomeAssistant,
_entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig> {
return cameraConfig;
}
public generateDefaultEventQuery(
_cameras: CameraConfigs,
_cameraIDs: Set<string>,
_query: PartialEventQuery,
): EventQuery[] | null {
return null;
}
public generateDefaultRecordingQuery(
_cameras: CameraConfigs,
_cameraIDs: Set<string>,
_query: PartialRecordingQuery,
): RecordingQuery[] | null {
return null;
}
public generateDefaultRecordingSegmentsQuery(
_cameras: CameraConfigs,
_cameraIDs: Set<string>,
_query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] | null {
return null;
}
public async getEvents(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: EventQuery,
_engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
return null;
}
public async getRecordings(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: RecordingQuery,
_engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap | null> {
return null;
}
public async getRecordingSegments(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: RecordingSegmentsQuery,
_engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap | null> {
return null;
}
public generateMediaFromEvents(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: EventQuery,
_results: QueryReturnType<EventQuery>,
): ViewMedia[] | null {
return null;
}
public generateMediaFromRecordings(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: RecordingQuery,
_results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null {
return null;
}
public async getMediaDownloadPath(
_hass: ExtendedHomeAssistant,
_cameraConfig: CameraConfig,
_media: ViewMedia,
): Promise<CameraEndpoint | null> {
return null;
}
public async favoriteMedia(
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
_media: ViewMedia,
_favorite: boolean,
): Promise<void> {
return;
}
public getQueryResultMaxAge(_query: DataQuery): number | null {
return null;
}
public async getMediaSeekTime(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_media: ViewMedia,
_target: Date,
_engineOptions?: EngineOptions,
): Promise<number | null> {
return null;
}
public async getMediaMetadata(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: MediaMetadataQuery,
_engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null> {
return null;
}
public getCameraMetadata(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): CameraManagerCameraMetadata {
return {
title:
cameraConfig.title ??
getEntityTitle(hass, cameraConfig.camera_entity) ??
getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ??
cameraConfig.id ??
'',
icon:
cameraConfig?.icon ??
getEntityIcon(hass, cameraConfig.camera_entity) ??
'mdi:video',
};
}
public getCameraCapabilities(
_cameraConfig: CameraConfig,
): CameraManagerCameraCapabilities | null {
return {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
};
}
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
return null;
}
public getCameraEndpoints(
_cameraConfig: CameraConfig,
_context?: CameraEndpointsContext,
): CameraEndpoints | null {
return null;
}
}
+760
View File
@@ -0,0 +1,760 @@
import { HomeAssistant } from 'custom-card-helpers';
import {
CameraConfig,
CamerasConfig,
CardWideConfig,
ExtendedHomeAssistant,
} from '../types.js';
import { allPromises, arrayify, setify } from '../utils/basic.js';
import {
CameraManagerCameraCapabilities,
CameraManagerCameraMetadata,
CameraManagerCapabilities,
CameraManagerMediaCapabilities,
CameraEndpointsContext,
DataQuery,
EventQuery,
EventQueryResults,
EventQueryResultsMap,
MediaMetadata,
MediaQuery,
PartialDataQuery,
PartialEventQuery,
PartialQueryConcreteType,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
QueryResults,
QueryResultsType,
QueryReturnType,
QueryType,
RecordingQuery,
RecordingQueryResults,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap,
ResultsMap,
CameraEndpoints,
Engine,
MediaMetadataQuery,
MediaMetadataQueryResults,
EngineOptions,
CameraEndpoint,
} from './types.js';
import { CameraManagerEngineFactory } from './engine-factory.js';
import { ViewMedia } from '../view/media.js';
import { CameraManagerEngine } from './engine.js';
import sum from 'lodash-es/sum';
import add from 'date-fns/add';
import { log } from '../utils/debug.js';
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
import { getCameraID } from '../utils/camera.js';
import { localize } from '../localize/localize.js';
import { CameraInitializationError } from './error.js';
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
import cloneDeep from 'lodash-es/cloneDeep';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { sortMedia } from './util.js';
class QueryClassifier {
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
return query.type === QueryType.Event;
}
public static isRecordingQuery(
query: DataQuery | PartialDataQuery,
): query is RecordingQuery {
return query.type === QueryType.Recording;
}
public static isRecordingSegmentsQuery(
query: DataQuery | PartialDataQuery,
): query is RecordingSegmentsQuery {
return query.type === QueryType.RecordingSegments;
}
public static isMediaMetadataQuery(
query: DataQuery | PartialDataQuery,
): query is MediaMetadataQuery {
return query.type === QueryType.MediaMetadata;
}
}
class QueryResultClassifier {
public static isEventQueryResult(
queryResults: QueryResults,
): queryResults is EventQueryResults {
return queryResults.type === QueryResultsType.Event;
}
public static isRecordingQuery(
queryResults: QueryResults,
): queryResults is RecordingQueryResults {
return queryResults.type === QueryResultsType.Recording;
}
public static isRecordingSegmentsQuery(
queryResults: QueryResults,
): queryResults is RecordingSegmentsQueryResults {
return queryResults.type === QueryResultsType.RecordingSegments;
}
public static isMediaMetadataQuery(
queryResults: QueryResults,
): queryResults is MediaMetadataQueryResults {
return queryResults.type === QueryResultsType.MediaMetadata;
}
}
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
queries: T[];
results: ViewMedia[];
}
interface InitializedCamera {
inputConfig: CameraConfig;
initializedConfig: CameraConfig;
engine: CameraManagerEngine;
}
export class CameraManager {
protected _engineFactory: CameraManagerEngineFactory;
protected _cardWideConfig?: CardWideConfig;
protected _store: CameraManagerStore;
constructor(
engineFactory: CameraManagerEngineFactory,
cardWideConfig?: CardWideConfig,
) {
this._engineFactory = engineFactory;
this._cardWideConfig = cardWideConfig;
this._store = new CameraManagerStore();
}
protected async _getEnginesForCameras(
hass: HomeAssistant,
camerasConfig: CamerasConfig,
): Promise<Map<CameraConfig, CameraManagerEngine>> {
const output: Map<CameraConfig, CameraManagerEngine> = new Map();
const engines: Map<Engine, CameraManagerEngine> = new Map();
const getEngineTypes = async (configs: CameraConfig[]) => {
return await allPromises(configs, (config) =>
this._engineFactory.getEngineForCamera(hass, config),
);
};
const engineTypes = await getEngineTypes(camerasConfig);
for (const [index, cameraConfig] of camerasConfig.entries()) {
const engineType = engineTypes[index];
const engine = engineType
? engines.get(engineType) ?? await this._engineFactory.createEngine(engineType)
: null;
if (!engine || !engineType) {
throw new CameraInitializationError(
localize('error.no_camera_engine'),
cameraConfig,
);
}
engines.set(engineType, engine);
output.set(cameraConfig, engine);
}
return output;
}
protected async _initializeCamera(
hass: HomeAssistant,
engine: CameraManagerEngine,
entityRegistryManager: EntityRegistryManager,
inputCameraConfig: CameraConfig,
): Promise<InitializedCamera> {
const initializedConfig = await engine.initializeCamera(
hass,
entityRegistryManager,
// Camera initialization may modify the configuration. Keep the original
// for display in error messages to avoid user confusion.
cloneDeep(inputCameraConfig),
);
return {
inputConfig: inputCameraConfig,
initializedConfig: initializedConfig,
engine: engine,
};
}
public async initializeCameras(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
camerasConfig: CamerasConfig,
): Promise<void> {
const initializationStartTime = new Date();
const hasAutoTriggers = (config: CameraConfig): boolean => {
return config.triggers.motion || config.triggers.occupancy;
};
if (
// If any camera requires automatic trigger detection ...
camerasConfig.some((config) => hasAutoTriggers(config))
) {
// ... then we need to populate the entity cache by fetching all entities
// from Home Assistant. Do this once upfront, to avoid each camera doing
// it.
await entityRegistryManager.fetchEntityList(hass);
}
// Engines are created sequentially, to avoid duplicate creation of the same
// engine. See: https://github.com/dermotduffy/frigate-hass-card/issues/941
const engineByConfig = await this._getEnginesForCameras(hass, camerasConfig);
// Configuration is initialized in parallel.
const results = await allPromises(
engineByConfig.entries(),
async ([cameraConfig, engine]) =>
await this._initializeCamera(hass, engine, entityRegistryManager, cameraConfig),
);
// Do the additions based off the result-order, to ensure the map order is
// preserved.
results.forEach((result) => {
const id = getCameraID(result.initializedConfig);
if (!id) {
throw new CameraInitializationError(
localize('error.no_camera_id'),
result.inputConfig,
);
}
if (this._store.hasCameraID(id)) {
throw new CameraInitializationError(
localize('error.duplicate_camera_id'),
result.inputConfig,
);
}
this._store.addCamera(id, result.initializedConfig, result.engine);
});
if (!this._store.getVisibleCameraCount()) {
throw new CameraInitializationError(localize('error.no_visible_cameras'));
}
log(
this._cardWideConfig,
'Frigate Card CameraManager initialized (Cameras: ',
this._store.getCameras(),
`, Duration: ${
(new Date().getTime() - initializationStartTime.getTime()) / 1000
}s,`,
')',
);
}
public isInitialized(): boolean {
return this._store.getCameraCount() > 0;
}
public getStore(): CameraManagerReadOnlyConfigStore {
return this._store;
}
public generateDefaultEventQueries(
cameraIDs: string | Set<string>,
partialQuery?: PartialEventQuery,
): EventQuery[] | null {
return this._generateDefaultQueries(cameraIDs, {
type: QueryType.Event,
...partialQuery,
});
}
public generateDefaultRecordingQueries(
cameraIDs: string | Set<string>,
partialQuery?: PartialRecordingQuery,
): RecordingQuery[] | null {
return this._generateDefaultQueries(cameraIDs, {
type: QueryType.Recording,
...partialQuery,
});
}
public generateDefaultRecordingSegmentsQueries(
cameraIDs: string | Set<string>,
partialQuery?: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] | null {
return this._generateDefaultQueries(cameraIDs, {
type: QueryType.RecordingSegments,
...partialQuery,
});
}
public async getMediaMetadata(hass: HomeAssistant): Promise<MediaMetadata | null> {
const tags: Set<string> = new Set();
const what: Set<string> = new Set();
const where: Set<string> = new Set();
const days: Set<string> = new Set();
const query: MediaMetadataQuery = {
type: QueryType.MediaMetadata,
cameraIDs: this._store.getCameraIDs(),
};
const results = await this._handleQuery(hass, query);
for (const result of results?.values() ?? []) {
if (result.metadata.tags) {
result.metadata.tags.forEach(tags.add, tags);
}
if (result.metadata.what) {
result.metadata.what.forEach(what.add, what);
}
if (result.metadata.where) {
result.metadata.where.forEach(where.add, where);
}
if (result.metadata.days) {
result.metadata.days.forEach(days.add, days);
}
}
if (!what.size && !where.size && !days.size) {
return null;
}
return {
...(tags.size && { tags: tags }),
...(what.size && { what: what }),
...(where.size && { where: where }),
...(days.size && { days: days }),
};
}
protected _generateDefaultQueries<PQT extends PartialDataQuery>(
cameraIDs: string | Set<string>,
partialQuery: PQT,
): PartialQueryConcreteType<PQT>[] | null {
const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
const _cameraIDs = setify(cameraIDs);
const engines = this._store.getEnginesForCameraIDs(_cameraIDs);
if (!engines) {
return null;
}
for (const [engine, cameraIDs] of engines) {
let queries: DataQuery[] | null = null;
if (QueryClassifier.isEventQuery(partialQuery)) {
queries = engine.generateDefaultEventQuery(
this._store.getVisibleCameras(),
cameraIDs,
partialQuery,
);
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
queries = engine.generateDefaultRecordingQuery(
this._store.getVisibleCameras(),
cameraIDs,
partialQuery,
);
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
queries = engine.generateDefaultRecordingSegmentsQuery(
this._store.getVisibleCameras(),
cameraIDs,
partialQuery,
);
}
for (const query of queries ?? []) {
concreteQueries.push(query as PartialQueryConcreteType<PQT>);
}
}
return concreteQueries.length ? concreteQueries : null;
}
public async getEvents(
hass: HomeAssistant,
query: EventQuery | EventQuery[],
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap> {
return await this._handleQuery(hass, query, engineOptions);
}
public async getRecordings(
hass: HomeAssistant,
query: RecordingQuery | RecordingQuery[],
engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap> {
return await this._handleQuery(hass, query, engineOptions);
}
public async getRecordingSegments(
hass: HomeAssistant,
query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap> {
return await this._handleQuery(hass, query, engineOptions);
}
public async executeMediaQueries<T extends MediaQuery>(
hass: HomeAssistant,
queries: T[],
engineOptions?: EngineOptions,
): Promise<ViewMedia[] | null> {
return this._convertQueryResultsToMedia(
hass,
await this._handleQuery(hass, queries, engineOptions),
);
}
public async extendMediaQueries<T extends MediaQuery>(
hass: HomeAssistant,
queries: T[],
results: ViewMedia[],
direction: 'earlier' | 'later',
engineOptions?: EngineOptions,
): Promise<ExtendedMediaQueryResult<T> | null> {
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
let output: Date | null = null;
for (const result of results) {
const startTime = result.getStartTime();
if (
startTime &&
(!output ||
(want === 'earliest' && startTime < output) ||
(want === 'latest' && startTime > output))
) {
output = startTime;
}
}
return output;
};
const chunkSize =
this._cardWideConfig?.performance?.features.media_chunk_size ??
MEDIA_CHUNK_SIZE_DEFAULT;
// The queries associated with the chunk to fetch.
const newChunkQueries: T[] = [];
// The re-constituted combined query.
const extendedQueries: T[] = [];
for (const query of queries) {
const newChunkQuery = { ...query };
if (direction === 'later') {
const latestResult = getTimeFromResults('latest');
if (latestResult) {
newChunkQuery.start = latestResult;
}
} else if (direction === 'earlier') {
const earliestResult = getTimeFromResults('earliest');
if (earliestResult) {
newChunkQuery.end = earliestResult;
}
}
newChunkQuery.limit = chunkSize;
extendedQueries.push({
...query,
limit: (query.limit ?? 0) + chunkSize,
});
newChunkQueries.push(newChunkQuery);
}
const newChunkMedia = this._convertQueryResultsToMedia(
hass,
await this._handleQuery(hass, newChunkQueries, engineOptions),
);
if (!newChunkMedia.length) {
return null;
}
const outputMedia = sortMedia(results.concat(newChunkMedia));
// If the media did not _ACTUALLY_ get longer, there is no new media despite
// the increased limit, so just return null.
if (outputMedia.length === results.length) {
return null;
}
return {
queries: extendedQueries,
results: outputMedia,
};
}
public async getMediaDownloadPath(
hass: ExtendedHomeAssistant,
media: ViewMedia,
): Promise<CameraEndpoint | null> {
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
if (!cameraConfig || !engine) {
return null;
}
return await engine.getMediaDownloadPath(hass, cameraConfig, media);
}
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
const engine = this._store.getEngineForMedia(media);
if (!engine) {
return null;
}
return engine.getMediaCapabilities(media);
}
public async favoriteMedia(
hass: HomeAssistant,
media: ViewMedia,
favorite: boolean,
): Promise<void> {
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
if (!cameraConfig || !engine) {
return;
}
const queryStartTime = new Date();
await engine.favoriteMedia(hass, cameraConfig, media, favorite);
log(
this._cardWideConfig,
'Frigate Card CameraManager favorite request (',
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
'Media:',
media.getID(),
', Favorite:',
favorite,
')',
);
}
public areMediaQueriesResultsFresh<T extends MediaQuery>(
queries: T[],
resultsTimestamp: Date,
): boolean {
const now = new Date();
for (const query of queries) {
const engines = this._store.getEnginesForCameraIDs(query.cameraIDs);
for (const [engine, cameraIDs] of engines ?? []) {
const maxAgeSeconds = engine.getQueryResultMaxAge({
...query,
cameraIDs: cameraIDs,
});
if (
maxAgeSeconds !== null &&
add(resultsTimestamp, { seconds: maxAgeSeconds }) < now
) {
return false;
}
}
}
return true;
}
public async getMediaSeekTime(
hass: HomeAssistant,
media: ViewMedia,
target: Date,
): Promise<number | null> {
const startTime = media.getStartTime();
const endTime = media.getEndTime();
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
if (
!cameraConfig ||
!engine ||
!startTime ||
!endTime ||
target < startTime ||
target > endTime
) {
return null;
}
return await engine.getMediaSeekTime(hass, this._store.getCameras(), media, target);
}
protected async _handleQuery<QT extends DataQuery>(
hass: HomeAssistant,
query: QT | QT[],
engineOptions?: EngineOptions,
): Promise<Map<QT, QueryReturnType<QT>>> {
const _queries = arrayify(query);
const results = new Map<QT, QueryReturnType<QT>>();
const queryStartTime = new Date();
const processEngineQuery = async (
engine: CameraManagerEngine,
query?: QT,
): Promise<void> => {
if (!query) {
return;
}
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
if (QueryClassifier.isEventQuery(query)) {
engineResult = (await engine.getEvents(
hass,
this._store.getCameras(),
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingQuery(query)) {
engineResult = (await engine.getRecordings(
hass,
this._store.getCameras(),
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
engineResult = (await engine.getRecordingSegments(
hass,
this._store.getCameras(),
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isMediaMetadataQuery(query)) {
engineResult = (await engine.getMediaMetadata(
hass,
this._store.getCameras(),
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
}
engineResult?.forEach((value, key) => results.set(key, value));
};
const processQuery = async (query: QT): Promise<void> => {
const engines = this._store.getEnginesForCameraIDs(query.cameraIDs);
if (!engines) {
return;
}
await Promise.all(
Array.from(engines.keys()).map((engine) =>
processEngineQuery(engine, { ...query, cameraIDs: engines.get(engine) }),
),
);
};
await Promise.all(_queries.map((query) => processQuery(query)));
const cachedOutputQueries = sum(
Array.from(results.values()).map((result) => Number(result.cached ?? 0)),
);
log(
this._cardWideConfig,
'Frigate Card CameraManager request [Input queries:',
_queries.length,
', Cached output queries:',
cachedOutputQueries,
', Total output queries:',
results.size,
', Duration:',
`${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
', Queries:',
_queries,
', Results:',
results,
']',
);
return results;
}
protected _convertQueryResultsToMedia<QT extends DataQuery>(
hass: HomeAssistant,
results: ResultsMap<QT>,
): ViewMedia[] {
const mediaArray: ViewMedia[] = [];
for (const [query, result] of results.entries()) {
const engine = this._store.getEngineOfType(result.engine);
if (engine) {
let media: ViewMedia[] | null = null;
if (
QueryClassifier.isEventQuery(query) &&
QueryResultClassifier.isEventQueryResult(result)
) {
media = engine.generateMediaFromEvents(
hass,
this._store.getCameras(),
query,
result,
);
} else if (
QueryClassifier.isRecordingQuery(query) &&
QueryResultClassifier.isRecordingQuery(result)
) {
media = engine.generateMediaFromRecordings(
hass,
this._store.getCameras(),
query,
result,
);
}
if (media) {
mediaArray.push(...media);
}
}
}
return sortMedia(mediaArray);
}
public getCameraEndpoints(
cameraID: string,
context?: CameraEndpointsContext,
): CameraEndpoints | null {
const cameraConfig = this._store.getCameraConfig(cameraID);
const engine = this._store.getEngineForCameraID(cameraID);
if (!cameraConfig || !engine) {
return null;
}
return engine.getCameraEndpoints(cameraConfig, context);
}
public getCameraMetadata(
hass: HomeAssistant,
cameraID: string,
): CameraManagerCameraMetadata | null {
const cameraConfig = this._store.getCameraConfig(cameraID);
const engine = this._store.getEngineForCameraID(cameraID);
if (!cameraConfig || !engine) {
return null;
}
return engine.getCameraMetadata(hass, cameraConfig);
}
public getCameraCapabilities(
cameraID: string,
): CameraManagerCameraCapabilities | null {
const cameraConfig = this._store.getCameraConfig(cameraID);
const engine = this._store.getEngineForCameraID(cameraID);
if (!cameraConfig || !engine) {
return null;
}
return engine.getCameraCapabilities(cameraConfig);
}
public getAggregateCameraCapabilities(
cameraIDs?: Set<string>,
): CameraManagerCapabilities | null {
const perCameraCapabilities = [...(cameraIDs ?? this._store.getCameraIDs())].map(
(cameraID) => this.getCameraCapabilities(cameraID),
);
return {
canFavoriteEvents: perCameraCapabilities.some((cap) => cap?.canFavoriteEvents),
canFavoriteRecordings: perCameraCapabilities.some(
(cap) => cap?.canFavoriteRecordings,
),
canSeek: perCameraCapabilities.some(
(cap) => cap?.canSeek,
),
supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips),
supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings),
supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots),
supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline),
};
}
}
@@ -0,0 +1,242 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
width="64"
height="64"
xml:space="preserve"
sodipodi:docname="motioneye-icon.svg"
inkscape:export-filename="/home/ccrisan/projects/motioneye/static/img/motioneye-logo.png"
inkscape:export-xdpi="960"
inkscape:export-ydpi="960"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs6"><linearGradient
id="linearGradient4351"
inkscape:collect="always"><stop
id="stop4353"
offset="0"
style="stop-color:#737373;stop-opacity:1" /><stop
id="stop4355"
offset="1"
style="stop-color:#585858;stop-opacity:1" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient4205"><stop
style="stop-color:#4aa3e0;stop-opacity:1"
offset="0"
id="stop4207" /><stop
style="stop-color:#3096db;stop-opacity:1"
offset="1"
id="stop4209" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient4197"><stop
style="stop-color:#787878;stop-opacity:1"
offset="0"
id="stop4199" /><stop
style="stop-color:#585858;stop-opacity:1"
offset="1"
id="stop4201" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4351"
id="linearGradient4203"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4205"
id="linearGradient4211"
x1="26.602072"
y1="43.034946"
x2="26.602072"
y2="29.466328"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.96428571,0,0,0.96428571,0.91428571,0.91428571)" /><filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4285"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4287" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4289" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4291" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4293" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4295" /></filter><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4197"
id="linearGradient4309"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4197"
id="linearGradient4311"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4197"
id="linearGradient4313"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4315"
x="-0.10000000000000001"
y="-0.16000000000000003"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4317" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4319" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4321" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4323" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4325" /></filter><filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4327"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4329" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4331" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4333" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4335" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4337" /></filter><filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4339"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4341" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4343" /><feGaussianBlur
in="composite1"
stdDeviation="0.2"
result="blur"
id="feGaussianBlur4345" /><feOffset
dx="0"
dy="-0.5"
result="offset"
id="feOffset4347" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4349" /></filter></defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1025"
id="namedview4"
showgrid="false"
inkscape:zoom="2"
inkscape:cx="-94.597631"
inkscape:cy="10.226517"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="g10"
showguides="true"
inkscape:guide-bbox="true" /><g
id="g10"
inkscape:groupmode="layer"
inkscape:label="ink_ext_XXXXXX"
transform="matrix(1.25,0,0,-1.25,0,64)"><g
id="g4170"
style="fill:url(#linearGradient4203);fill-opacity:1;filter:url(#filter4327)"
transform="matrix(0.96428571,0,0,0.96428571,0.91428571,0.91428571)"><path
id="path4244"
d="M 8.9346154,40.515385 C 5.3647588,36.547307 3.2,31.357779 3.2,25.6 3.2,13.228821 13.228821,3.2 25.6,3.2 37.971179,3.2 48,13.228821 48,25.6 c 0,5.736682 -2.161128,10.952493 -5.707692,14.915385 -1.695935,-0.623286 -3.387833,-1.349065 -5.061539,-2.288462 3.2394,-0.937363 5.6,-3.937988 5.6,-7.457692 0,-4.260339 -3.469626,-7.753846 -7.753846,-7.753846 -3.633936,0 -6.690552,2.51055 -7.538461,5.869231 l -3.876924,0 c -0.840685,-3.360193 -3.903443,-5.869231 -7.538461,-5.869231 -4.284219,0 -7.7807693,3.493507 -7.7807693,7.753846 0,3.56112 2.4570323,6.5856 5.7615383,7.484616 -1.676267,0.912203 -3.404813,1.620556 -5.1692306,2.261538 z M 25.6,26.461538 c 0.532632,-1.981435 1.101793,-3.947553 3.446154,-5.16923 L 25.6,16.123077 22.153846,21.292308 c 2.053593,1.454966 3.000771,3.237758 3.446154,5.16923 z"
style="fill:url(#linearGradient4309);fill-opacity:1;stroke:none"
inkscape:connector-curvature="0" /><path
id="path4242"
d="m 16.123077,33.353847 c -1.427443,0 -2.584616,-1.157173 -2.584616,-2.584616 0,-1.427444 1.157173,-2.584615 2.584616,-2.584615 1.427444,0 2.584615,1.157171 2.584615,2.584615 0,1.427443 -1.157171,2.584616 -2.584615,2.584616 z"
style="fill:url(#linearGradient4311);fill-opacity:1;stroke:none"
inkscape:connector-curvature="0" /><path
id="path4240"
d="m 35.076923,33.353847 c -1.427443,0 -2.584615,-1.157173 -2.584615,-2.584616 0,-1.427444 1.157172,-2.584615 2.584615,-2.584615 1.427443,0 2.584616,1.157171 2.584616,2.584615 0,1.427443 -1.157173,2.584616 -2.584616,2.584616 z"
style="fill:url(#linearGradient4313);fill-opacity:1;stroke:none"
inkscape:connector-curvature="0" /></g><path
inkscape:connector-curvature="0"
style="fill:#737373;fill-opacity:1;stroke:none;filter:url(#filter4339)"
d="m 25.6,47.2 c -4.373944,0 -8.437159,-1.399808 -11.838461,-3.634616 3.677605,-0.394237 7.305921,-1.342945 11.423077,-3.375 4.166157,2.122533 8.434154,3.008875 12.279808,3.452886 C 34.057131,45.890032 29.986674,47.2 25.6,47.2 Z"
id="path4248" /><path
inkscape:connector-curvature="0"
style="fill:url(#linearGradient4211);fill-opacity:1;stroke:none;filter:url(#filter4315)"
d="M 39.723077,42.552884 C 35.394064,42.5242 29.479588,40.397223 25.184616,38.432418 20.668821,40.064102 16.035448,42.649343 10.801923,42.526924 10.453022,42.51873 10.118061,42.50105 9.7634616,42.475 L 5.6615384,42.1375 9.5557693,40.839424 c 5.3417977,-1.74056 10.0398397,-2.851302 14.1749997,-10.025963 0.959101,0 2.845924,-4.15e-4 3.738462,-4.15e-4 4.11884,7.134039 9.059296,8.324614 14.149039,10.026378 L 45.460577,42.085577 41.4625,42.475 c -0.544847,0.05181 -1.120992,0.08198 -1.739423,0.07788 z"
id="path4246"
sodipodi:nodetypes="cccccccccccc" /></g></svg>

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,403 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../../types';
import { ViewMedia } from '../../view/media';
import {
CameraConfigs,
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraMetadata,
Engine,
EngineOptions,
EventQuery,
EventQueryResults,
EventQueryResultsMap,
MediaMetadataQuery,
MediaMetadataQueryResults,
MediaMetadataQueryResultsMap,
QueryResults,
QueryResultsType,
QueryReturnType,
} from '../types';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import {
BrowseMediaStep,
BrowseMediaTarget,
} from '../../utils/ha/browse-media/browse-media-manager';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import endOfDay from 'date-fns/endOfDay';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import parse from 'date-fns/parse';
import { MotionEyeEventQueryResults } from './types';
import orderBy from 'lodash-es/orderBy';
import startOfDay from 'date-fns/startOfDay';
import add from 'date-fns/add';
import {
BrowseMediaCameraManagerEngine,
getViewMediaFromBrowseMediaArray,
isMediaWithinDates,
} from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
import motioneyeLogo from './assets/motioneye-logo.svg';
class MotionEyeQueryResultsClassifier {
public static isMotionEyeEventQueryResults(
results: QueryResults,
): results is MotionEyeEventQueryResults {
return (
results.engine === Engine.MotionEye && results.type === QueryResultsType.Event
);
}
}
const MOTIONEYE_REPL_SUBSTITUTIONS: Record<string, string> = {
'%Y': 'yyyy',
'%m': 'MM',
'%d': 'dd',
'%H': 'HH',
'%M': 'mm',
'%S': 'ss',
};
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
public getEngineType(): Engine {
return Engine.MotionEye;
}
protected _convertMotionEyeTimeFormatToDateFNS(part: string): string {
return part.replace(
MOTIONEYE_REPL_REGEXP,
(_, key) => MOTIONEYE_REPL_SUBSTITUTIONS[key],
);
}
// Get metadata for a MotionEye media file.
protected _motionEyeMetadataGeneratorFile(
cameraID: string,
dateFormat: string | null,
media: BrowseMedia,
parent?: RichBrowseMedia<BrowseMediaMetadata>,
): BrowseMediaMetadata | null {
let startDate = parent?._metadata?.startDate ?? new Date();
if (dateFormat) {
const extensionlessTitle = media.title.replace(/\.[^/.]+$/, '');
startDate = parse(extensionlessTitle, dateFormat, startDate);
if (!isValidDate(startDate)) {
return null;
}
}
return {
cameraID: cameraID,
startDate: startDate,
// MotionEye only has start times, the event is effectively a 'point'
endDate: startDate,
};
}
// Get metadata for a MotionEye media directory.
protected _motionEyeMetadataGeneratorDirectory(
cameraID: string,
dateFormat: string | null,
media: BrowseMedia,
parent?: RichBrowseMedia<BrowseMediaMetadata>,
): BrowseMediaMetadata | null {
let startDate = parent?._metadata?.startDate ?? new Date();
if (dateFormat) {
const parsedDate = parse(media.title, dateFormat, startDate);
if (!isValidDate(parsedDate)) {
return null;
}
startDate = startOfDay(parsedDate);
}
return {
cameraID: cameraID,
startDate: startDate,
endDate: parent?._metadata?.endDate ?? endOfDay(startDate),
};
}
// Get media directories that match a given criteria.
protected async _getMatchingDirectories(
hass: HomeAssistant,
cameras: CameraConfigs,
cameraID: string,
matchOptions?: {
start?: Date;
end?: Date;
hasClip?: boolean;
hasSnapshot?: boolean;
} | null,
engineOptions?: EngineOptions,
): Promise<RichBrowseMedia<BrowseMediaMetadata>[] | null> {
const cameraEntityID = cameras.get(cameraID)?.camera_entity;
const entity = cameraEntityID ? this._cameraEntities.get(cameraEntityID) : null;
const configID = entity?.config_entry_id;
const deviceID = entity?.device_id;
const cameraConfig = cameras.get(cameraID);
if (!configID || !deviceID || !cameraConfig) {
return null;
}
const generateNextStep = (
parts: string[],
media: BrowseMediaTarget<BrowseMediaMetadata>[],
): BrowseMediaStep<BrowseMediaMetadata>[] => {
const next = parts.shift();
if (!next) {
return [];
}
const dateFormat = next.includes('%')
? this._convertMotionEyeTimeFormatToDateFNS(next)
: null;
return [
{
targets: media,
metadataGenerator: (
media: BrowseMedia,
parent?: RichBrowseMedia<BrowseMediaMetadata>,
) =>
this._motionEyeMetadataGeneratorDirectory(
cameraID,
dateFormat,
media,
parent,
),
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
media.can_expand &&
(!!dateFormat || media.title === next) &&
isMediaWithinDates(media, matchOptions?.start, matchOptions?.end),
advance: (media) => generateNextStep(parts, media),
},
];
};
// For motionEye snapshots and clips are mutually exclusive.
return await this._browseMediaManager.walkBrowseMedias(
hass,
[
...(matchOptions?.hasClip !== false && !matchOptions?.hasSnapshot
? generateNextStep(
cameraConfig.motioneye.movies.directory_pattern.split('/'),
[`media-source://motioneye/${configID}#${deviceID}#movies`],
)
: []),
...(matchOptions?.hasSnapshot !== false && !matchOptions?.hasClip
? generateNextStep(
cameraConfig.motioneye.images.directory_pattern.split('/'),
[`media-source://motioneye/${configID}#${deviceID}#images`],
)
: []),
],
{
useCache: engineOptions?.useCache,
},
);
}
public async getEvents(
hass: HomeAssistant,
cameras: CameraConfigs,
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
// MotionEye does not support these query types and they will never match.
if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) {
return null;
}
const output: EventQueryResultsMap = new Map();
const getEventsForCamera = async (cameraID: string): Promise<void> => {
const perCameraQuery = { ...query, cameraIDs: new Set([cameraID]) };
const cachedResult =
engineOptions?.useCache ?? true ? this._requestCache.get(perCameraQuery) : null;
if (cachedResult) {
output.set(perCameraQuery, cachedResult as EventQueryResults);
return;
}
const cameraConfig = cameras.get(cameraID);
if (!cameraConfig) {
return;
}
const directories = await this._getMatchingDirectories(
hass,
cameras,
cameraID,
perCameraQuery,
engineOptions,
);
if (!directories || !directories.length) {
return;
}
const moviesDateFormat = this._convertMotionEyeTimeFormatToDateFNS(
cameraConfig.motioneye.movies.file_pattern,
);
const imagesDateFormat = this._convertMotionEyeTimeFormatToDateFNS(
cameraConfig.motioneye.images.file_pattern,
);
const media = await this._browseMediaManager.walkBrowseMedias(
hass,
[
{
targets: directories,
metadataGenerator: (
media: BrowseMedia,
parent?: RichBrowseMedia<BrowseMediaMetadata>,
) => {
if (
media.media_class === MEDIA_CLASS_IMAGE ||
media.media_class === MEDIA_CLASS_VIDEO
) {
return this._motionEyeMetadataGeneratorFile(
cameraID,
media.media_class === MEDIA_CLASS_IMAGE
? imagesDateFormat
: moviesDateFormat,
media,
parent,
);
}
return null;
},
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
!media.can_expand &&
isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end),
},
],
{ useCache: engineOptions?.useCache },
);
// Sort by most recent then slice at the query limit.
const sortedMedia = orderBy(
media,
(media: RichBrowseMedia<BrowseMediaMetadata>) => media._metadata?.startDate,
'desc',
).slice(0, perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT);
const result: MotionEyeEventQueryResults = {
type: QueryResultsType.Event,
engine: Engine.MotionEye,
browseMedia: sortedMedia,
};
if (engineOptions?.useCache ?? true) {
this._requestCache.set(
perCameraQuery,
{ ...result, cached: true },
result.expiry,
);
}
output.set(perCameraQuery, result);
};
await allPromises(query.cameraIDs, (cameraID) => getEventsForCamera(cameraID));
return output.size ? output : null;
}
public generateMediaFromEvents(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: EventQuery,
results: QueryReturnType<EventQuery>,
): ViewMedia[] | null {
if (!MotionEyeQueryResultsClassifier.isMotionEyeEventQueryResults(results)) {
return null;
}
return getViewMediaFromBrowseMediaArray(results.browseMedia);
}
public async getMediaMetadata(
hass: HomeAssistant,
cameras: CameraConfigs,
query: MediaMetadataQuery,
engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null> {
const output: MediaMetadataQueryResultsMap = new Map();
if ((engineOptions?.useCache ?? true) && this._requestCache.has(query)) {
const cachedResult = <MediaMetadataQueryResults | null>(
this._requestCache.get(query)
);
if (cachedResult) {
output.set(query, cachedResult as MediaMetadataQueryResults);
return output;
}
}
const days: Set<string> = new Set();
const getDaysForCamera = async (cameraID: string): Promise<void> => {
const directories = await this._getMatchingDirectories(
hass,
cameras,
cameraID,
null,
engineOptions,
);
for (const dayDirectory of directories ?? []) {
if (dayDirectory._metadata) {
days.add(formatDate(dayDirectory._metadata?.startDate));
}
}
};
await allPromises(query.cameraIDs, (cameraID) => getDaysForCamera(cameraID));
const result: MediaMetadataQueryResults = {
type: QueryResultsType.MediaMetadata,
engine: Engine.MotionEye,
metadata: {
...(days.size && { days: days }),
},
expiry: add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
cached: false,
};
if (engineOptions?.useCache ?? true) {
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
}
output.set(query, result);
return output;
}
public getCameraMetadata(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): CameraManagerCameraMetadata {
const metadata = super.getCameraMetadata(hass, cameraConfig);
return {
...metadata,
engineLogo: motioneyeLogo,
};
}
public getCameraEndpoints(
cameraConfig: CameraConfig,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getUIEndpoint = (): CameraEndpoint | null => {
return cameraConfig.motioneye?.url
? {
endpoint: cameraConfig.motioneye.url,
}
: null;
};
const ui = getUIEndpoint();
return {
...(ui && { ui: ui }),
};
}
}
+51
View File
@@ -0,0 +1,51 @@
// Converted from https://raw.githubusercontent.com/motioneye-project/motioneye/python2/motioneye/static/img/motioneye-icon.svg .
export const MOTIONEYE_ICON_SVG_VIEWBOX = '0 0 64 64';
export const MOTIONEYE_ICON_SVG_PATH =
'M 49.65,10.81 ' +
'C 44.24,10.84 36.85,13.50 31.48,15.96 ' +
'25.84,13.92 20.04,10.69 13.50,10.84 ' +
'13.07,10.85 12.65,10.87 12.20,10.91 ' +
'12.20,10.91 7.08,11.33 7.08,11.33 ' +
'7.08,11.33 11.94,12.95 11.94,12.95 ' +
'18.62,15.13 24.49,16.51 29.66,25.48 ' +
'30.86,25.48 33.22,25.48 34.34,25.48 ' +
'39.49,16.57 45.66,15.08 52.02,12.95 ' +
'52.02,12.95 56.83,11.39 56.83,11.39 ' +
'56.83,11.39 51.83,10.91 51.83,10.91 ' +
'51.15,10.84 50.43,10.80 49.65,10.81 ' +
'49.65,10.81 49.65,10.81 49.65,10.81 Z ' +
'M 32.00,5.00 ' +
'C 26.53,5.00 21.45,6.75 17.20,9.54 ' +
'21.80,10.04 26.33,11.22 31.48,13.76 ' +
'36.69,11.11 42.02,10.00 46.83,9.45 ' +
'42.57,6.64 37.48,5.00 32.00,5.00 Z ' +
'M 43.42,22.65 ' +
'C 41.70,22.65 40.31,24.05 40.31,25.77 ' +
'40.31,27.49 41.70,28.88 43.42,28.88 ' +
'45.14,28.88 46.54,27.49 46.54,25.77 ' +
'46.54,24.05 45.14,22.65 43.42,22.65 Z ' +
'M 20.58,22.65 ' +
'C 18.86,22.65 17.46,24.05 17.46,25.77 ' +
'17.46,27.49 18.86,28.88 20.58,28.88 ' +
'22.30,28.88 23.69,27.49 23.69,25.77 ' +
'23.69,24.05 22.30,22.65 20.58,22.65 Z ' +
'M 11.91,14.02 ' +
'C 7.61,18.80 5.00,25.06 5.00,32.00 ' +
'5.00,46.91 17.09,59.00 32.00,59.00 ' +
'46.91,59.00 59.00,46.91 59.00,32.00 ' +
'59.00,25.09 56.40,18.80 52.12,14.02 ' +
'50.08,14.77 48.04,15.65 46.02,16.78 ' +
'49.92,17.91 52.77,21.53 52.77,25.77 ' +
'52.77,30.90 48.59,35.12 43.42,35.12 ' +
'39.04,35.12 35.36,32.09 34.34,28.04 ' +
'34.34,28.04 29.66,28.04 29.66,28.04 ' +
'28.65,32.09 24.96,35.12 20.58,35.12 ' +
'15.41,35.12 11.20,30.90 11.20,25.77 ' +
'11.20,21.48 14.16,17.83 18.14,16.75 ' +
'16.12,15.65 14.04,14.79 11.91,14.02 ' +
'11.91,14.02 11.91,14.02 11.91,14.02 Z ' +
'M 32.00,30.96 ' +
'C 32.64,33.35 33.33,35.72 36.15,37.19 ' +
'36.15,37.19 32.00,43.42 32.00,43.42 ' +
'32.00,43.42 27.85,37.19 27.85,37.19 ' +
'30.32,35.44 31.46,33.29 32.00,30.96 Z';
+12
View File
@@ -0,0 +1,12 @@
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
import { BrowseMediaMetadata } from '../browse-media/types';
import { Engine, EventQueryResults } from '../types';
// ================================
// MotionEye concrete query results
// ================================
export interface MotionEyeEventQueryResults extends EventQueryResults {
engine: Engine.MotionEye;
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[];
}
+125
View File
@@ -0,0 +1,125 @@
import orderBy from 'lodash-es/orderBy';
interface Range<T extends Date | number> {
start: T;
end: T;
}
export type DateRange = Range<Date>;
interface MemoryRangeSetInterface<T> {
hasCoverage(range: T): boolean;
add(range: T): void;
clear(): void;
}
export class MemoryRangeSet implements MemoryRangeSetInterface<DateRange> {
protected _ranges: DateRange[];
constructor(ranges?: DateRange[]) {
this._ranges = ranges ?? [];
}
public hasCoverage(range: DateRange): boolean {
return this._ranges.some((cachedRange) =>
rangeIsEntirelyContained(cachedRange, range),
);
}
public add(range: DateRange): void {
this._ranges.push(range);
this._ranges = compressRanges(this._ranges);
}
public clear(): void {
this._ranges = [];
}
}
export interface ExpiringRange<T extends Date | number> extends Range<T> {
expires: Date;
}
export class ExpiringMemoryRangeSet
implements MemoryRangeSetInterface<ExpiringRange<Date>>
{
protected _ranges: ExpiringRange<Date>[];
constructor(ranges?: ExpiringRange<Date>[]) {
this._ranges = ranges ?? [];
}
public hasCoverage(range: DateRange): boolean {
const now = new Date();
return this._ranges.some(
(cachedRange) =>
now < cachedRange.expires && rangeIsEntirelyContained(cachedRange, range),
);
}
public add(range: ExpiringRange<Date>): void {
this._expireOldRanges();
this._ranges.push(range);
}
protected _expireOldRanges(): void {
const now = new Date();
this._ranges = this._ranges.filter((range) => now < range.expires);
}
public clear(): void {
this._ranges = [];
}
}
const rangeIsEntirelyContained = (bigger: DateRange, smaller: DateRange): boolean => {
return smaller.start >= bigger.start && smaller.end <= bigger.end;
};
export const rangesOverlap = (a: DateRange, b: DateRange): boolean => {
return (
// a starts within the range of b.
(a.start >= b.start && a.start <= b.end) ||
// a ends within the range of b.
(a.end >= b.start && a.end <= b.end) ||
// a encompasses the entire range of b.
(a.start <= b.start && a.end >= b.end)
);
};
export const compressRanges = <T extends Date | number>(
ranges: Range<T>[],
toleranceSeconds = 0,
): Range<T>[] => {
const compressedRanges: Range<T>[] = [];
ranges = orderBy(ranges, (range) => range.start, 'asc');
let current: Range<T> | null = null;
for (let i = 0; i < ranges.length; ++i) {
const item = ranges[i];
const itemStartSeconds =
item.start instanceof Date ? item.start.getTime() : item.start;
if (!current) {
current = { ...item };
continue;
}
const currentEndSeconds =
current.end instanceof Date ? current.end.getTime() : (current.end as number);
if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) {
if (item.end > current.end) {
current.end = item.end;
}
} else {
compressedRanges.push(current);
current = { ...item };
}
}
if (current) {
compressedRanges.push(current);
}
return compressedRanges;
};
+121
View File
@@ -0,0 +1,121 @@
import { CameraConfig } from '../types';
import { ViewMedia } from '../view/media';
import { CameraManagerEngine } from './engine';
import { CameraConfigs, Engine } from './types';
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
export interface CameraManagerReadOnlyConfigStore {
getCameraConfig(cameraID: string): CameraConfig | null;
getCameraConfigForMedia(media: ViewMedia): CameraConfig | null;
hasCameraID(cameraID: string): boolean;
hasVisibleCameraID(cameraID: string): boolean;
getCameraCount(): number;
getVisibleCameraCount(): number;
getCameras(): CameraConfigs;
getVisibleCameras(): CameraConfigs;
getCameraIDs(): Set<string>;
getVisibleCameraIDs(): Set<string>;
}
export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
protected _allConfigs: Map<string, CameraConfig> = new Map();
protected _visibleConfigs: Map<string, CameraConfig> = new Map();
protected _enginesByCamera: Map<string, CameraManagerEngine> = new Map();
protected _enginesByType: Map<Engine, CameraManagerEngine> = new Map();
public addCamera(
cameraID: string,
cameraConfig: CameraConfig,
engine: CameraManagerEngine,
): void {
if (!cameraConfig.hide) {
this._visibleConfigs.set(cameraID, cameraConfig);
}
this._allConfigs.set(cameraID, cameraConfig);
this._enginesByCamera.set(cameraID, engine);
this._enginesByType.set(engine.getEngineType(), engine);
}
public getCameraConfig(cameraID: string): CameraConfig | null {
return this._allConfigs.get(cameraID) ?? null;
}
public hasCameraID(cameraID: string): boolean {
return this._allConfigs.has(cameraID);
}
public hasVisibleCameraID(cameraID: string): boolean {
return this._visibleConfigs.has(cameraID);
}
public getCameraCount(): number {
return this._allConfigs.size;
}
public getVisibleCameraCount(): number {
return this._visibleConfigs.size;
}
public getCameras(): CameraConfigs {
return this._allConfigs;
}
public getVisibleCameras(): CameraConfigs {
return this._visibleConfigs;
}
public getCameraIDs(): Set<string> {
return new Set(this._allConfigs.keys());
}
public getVisibleCameraIDs(): Set<string> {
return new Set(this._visibleConfigs.keys());
}
public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null {
const cameraID = media.getCameraID();
if (!cameraID) {
return null;
}
return this.getCameraConfig(cameraID);
}
public getEngineOfType(engine: Engine): CameraManagerEngine | null {
return this._enginesByType.get(engine) ?? null;
}
public getEngineForCameraID(cameraID: string): CameraManagerEngine | null {
return this._enginesByCamera.get(cameraID) ?? null;
}
public getEnginesForCameraIDs(
cameraIDs: Set<string>,
): CameraManagerEngineCameraIDMap | null {
const output: CameraManagerEngineCameraIDMap = new Map();
for (const cameraID of cameraIDs) {
const engine = this.getEngineForCameraID(cameraID);
if (!engine) {
continue;
}
if (!output.has(engine)) {
output.set(engine, new Set());
}
output.get(engine)?.add(cameraID);
}
return output.size ? output : null;
}
public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null {
const cameraID = media.getCameraID();
if (!cameraID) {
return null;
}
return this.getEngineForCameraID(cameraID);
}
public getAllEngines(): CameraManagerEngine[] {
return [...this._enginesByType.values()];
}
}
+207
View File
@@ -0,0 +1,207 @@
import { CameraConfig, FrigateCardView } from '../types';
import { ViewMedia } from '../view/media';
// ====
// Base
// ====
export enum QueryType {
Event = 'event-query',
Recording = 'recording-query',
RecordingSegments = 'recording-segments-query',
MediaMetadata = 'media-metadata',
}
export enum QueryResultsType {
Event = 'event-results',
Recording = 'recording-results',
RecordingSegments = 'recording-segments-results',
MediaMetadata = 'media-metadata-results',
}
export enum Engine {
Frigate = 'frigate',
Generic = 'generic',
MotionEye = 'motioneye',
}
export interface DataQuery {
type: QueryType;
cameraIDs: Set<string>;
}
export type PartialDataQuery = Partial<DataQuery>;
interface TimeBasedDataQuery {
start: Date;
end: Date;
}
interface LimitedDataQuery {
limit: number;
}
export interface MediaQuery
extends DataQuery,
Partial<TimeBasedDataQuery>,
Partial<LimitedDataQuery> {
favorite?: boolean;
}
export interface QueryResults {
type: QueryResultsType;
engine: Engine;
expiry?: Date;
cached?: boolean;
}
// Generic recording segment type (inspired by Frigate recording segments).
export interface RecordingSegment {
start_time: number;
end_time: number;
id: string;
}
export type QueryReturnType<QT> = QT extends EventQuery
? EventQueryResults
: QT extends RecordingQuery
? RecordingQueryResults
: QT extends RecordingSegmentsQuery
? RecordingSegmentsQueryResults
: QT extends MediaMetadataQuery
? MediaMetadataQueryResults
: never;
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
? EventQuery
: PQT extends PartialRecordingQuery
? RecordingQuery
: PQT extends PartialRecordingSegmentsQuery
? RecordingSegmentsQuery
: never;
export type ResultsMap<QT> = Map<QT, QueryReturnType<QT>>;
export type EventQueryResultsMap = ResultsMap<EventQuery>;
export type RecordingQueryResultsMap = ResultsMap<RecordingQuery>;
export type RecordingSegmentsQueryResultsMap = ResultsMap<RecordingSegmentsQuery>;
export type MediaMetadataQueryResultsMap = ResultsMap<MediaMetadataQuery>;
export interface MediaMetadata {
days?: Set<string>;
tags?: Set<string>;
where?: Set<string>;
what?: Set<string>;
}
interface BaseCapabilities {
canFavoriteEvents: boolean;
canFavoriteRecordings: boolean;
canSeek: boolean;
supportsClips: boolean;
supportsRecordings: boolean;
supportsSnapshots: boolean;
supportsTimeline: boolean;
}
export type CameraManagerCapabilities = BaseCapabilities;
export type CameraManagerCameraCapabilities = BaseCapabilities;
export interface CameraManagerMediaCapabilities {
canFavorite: boolean;
canDownload: boolean;
}
export interface CameraManagerCameraMetadata {
title: string;
icon: string;
engineLogo?: string;
}
export interface CameraEndpointsContext {
media?: ViewMedia;
view?: FrigateCardView;
}
export interface CameraEndpoint {
endpoint: string;
sign?: boolean;
}
export interface CameraEndpoints {
ui?: CameraEndpoint;
go2rtc?: CameraEndpoint;
jsmpeg?: CameraEndpoint;
webrtcCard?: CameraEndpoint;
}
export type CameraConfigs = Map<string, CameraConfig>;
export interface EngineOptions {
useCache?: boolean;
}
// ===========
// Event Query
// ===========
export interface EventQuery extends MediaQuery {
type: QueryType.Event;
// Frigate equivalent: has_snapshot
hasSnapshot?: boolean;
// Frigate equivalent: has_clip
hasClip?: boolean;
// Frigate equivalent: label
what?: Set<string>;
// Frigate equivalent: sub_label
tags?: Set<string>;
// Frigate equivalent: zone
where?: Set<string>;
}
export type PartialEventQuery = Partial<EventQuery>;
export interface EventQueryResults extends QueryResults {
type: QueryResultsType.Event;
}
// ===============
// Recording Query
// ===============
export interface RecordingQuery extends MediaQuery {
type: QueryType.Recording;
}
export type PartialRecordingQuery = Partial<RecordingQuery>;
export interface RecordingQueryResults extends QueryResults {
type: QueryResultsType.Recording;
}
// ========================
// Recording Segments Query
// ========================
export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery {
type: QueryType.RecordingSegments;
}
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
export interface RecordingSegmentsQueryResults extends QueryResults {
type: QueryResultsType.RecordingSegments;
segments: RecordingSegment[];
}
// ====================
// Media metadata Query
// ====================
export interface MediaMetadataQuery extends DataQuery {
type: QueryType.MediaMetadata;
}
export interface MediaMetadataQueryResults extends QueryResults {
type: QueryResultsType.MediaMetadata;
metadata: MediaMetadata;
}
+55
View File
@@ -0,0 +1,55 @@
import startOfHour from 'date-fns/startOfHour';
import endOfHour from 'date-fns/endOfHour';
import startOfDay from 'date-fns/startOfDay';
import endOfDay from 'date-fns/endOfDay';
import endOfMinute from 'date-fns/endOfMinute';
import { DateRange } from './range';
import orderBy from 'lodash-es/orderBy';
import uniqBy from 'lodash-es/uniqBy';
import { ViewMedia } from '../view/media';
export const convertRangeToCacheFriendlyTimes = (
range: DateRange,
options?: {
endCap?: boolean;
},
): DateRange => {
const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000;
let cacheableStart: Date;
let cacheableEnd: Date;
if (widthSeconds <= 60 * 60) {
cacheableStart = startOfHour(range.start);
cacheableEnd = endOfHour(range.end);
} else {
cacheableStart = startOfDay(range.start);
cacheableEnd = endOfDay(range.end);
}
if (options?.endCap) {
cacheableEnd = endOfMinute(capEndDate(cacheableEnd));
}
return {
start: cacheableStart,
end: cacheableEnd,
};
};
export const capEndDate = (end: Date): Date => {
const now = new Date();
return end > now ? now : end;
};
export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(mediaArray, (media) => media.getID() ?? media),
// Sort all items leading oldest -> youngest (so media is loaded in this
// order in the viewer which matches the left-to-right timeline order).
(media) => media.getStartTime(),
'asc',
);
};
+98 -7
View File
@@ -1,24 +1,28 @@
import type { import {
FrigateCardCondition, FrigateCardCondition,
FrigateCardConfig,
frigateConditionalSchema,
OverrideConfigurationKey, OverrideConfigurationKey,
RawFrigateCardConfig, RawFrigateCardConfig,
} from './types'; } from './types';
import { HassEntities } from 'home-assistant-js-websocket'; import { HassEntities } from 'home-assistant-js-websocket';
import { cloneDeep, merge } from 'lodash-es'; import merge from 'lodash-es/merge';
import { copyConfig } from './config-mgmt';
export interface ConditionState { export interface ConditionState {
view?: string; view?: string;
fullscreen?: boolean; fullscreen?: boolean;
expand?: boolean;
camera?: string; camera?: string;
state?: HassEntities; state?: HassEntities;
mediaLoaded?: boolean; media_loaded?: boolean;
} }
class ConditionStateRequestEvent extends Event { class ConditionStateRequestEvent extends Event {
public conditionState?: ConditionState; public conditionState?: ConditionState;
} }
export function evaluateCondition( function evaluateCondition(
condition?: Readonly<FrigateCardCondition>, condition?: Readonly<FrigateCardCondition>,
state?: Readonly<ConditionState>, state?: Readonly<ConditionState>,
): boolean { ): boolean {
@@ -34,6 +38,10 @@ export function evaluateCondition(
result &&= result &&=
state.fullscreen !== undefined && condition.fullscreen == state.fullscreen; state.fullscreen !== undefined && condition.fullscreen == state.fullscreen;
} }
if (condition?.expand !== undefined) {
result &&=
state.expand !== undefined && condition.expand == state.expand;
}
if (condition?.camera?.length) { if (condition?.camera?.length) {
result &&= !!state.camera && condition.camera.includes(state.camera); result &&= !!state.camera && condition.camera.includes(state.camera);
} }
@@ -49,9 +57,12 @@ export function evaluateCondition(
state.state[stateTest.entity].state !== stateTest.state_not))); state.state[stateTest.entity].state !== stateTest.state_not)));
} }
} }
if (condition?.mediaLoaded !== undefined) { if (condition?.media_loaded !== undefined) {
result &&= result &&=
state.mediaLoaded !== undefined && condition.mediaLoaded == state.mediaLoaded; state.media_loaded !== undefined && condition.media_loaded == state.media_loaded;
}
if (condition?.media_query) {
result &&= window.matchMedia(condition.media_query).matches;
} }
return result; return result;
} }
@@ -109,7 +120,7 @@ export function getOverriddenConfig(
overrides: Readonly<RawOverrides> | undefined, overrides: Readonly<RawOverrides> | undefined,
conditionState?: Readonly<ConditionState>, conditionState?: Readonly<ConditionState>,
): RawFrigateCardConfig { ): RawFrigateCardConfig {
const output = cloneDeep(config); const output = copyConfig(config);
let overridden = false; let overridden = false;
if (overrides) { if (overrides) {
for (const override of overrides) { for (const override of overrides) {
@@ -137,3 +148,83 @@ export function getOverridesByKey(
})) ?? [] })) ?? []
); );
} }
export class CardConditionManager {
// Whether or not to include HA state in ConditionState. Doing so increases
// CPU usage as HA state is pumped out very fast, so this is only enabled if
// the configuration needs to consume it.
protected _hasHAStateConditions = false;
protected _callback: () => void;
protected _mediaQueries: MediaQueryList[] = [];
protected _boundTriggerChange = this._triggerChange.bind(this);
constructor(config: FrigateCardConfig, callback: () => void) {
this._initConditions(config);
this._callback = callback;
}
/**
* Destroy the object.
*/
public destroy(): void {
this._mediaQueries.forEach((mql) =>
mql.removeEventListener('change', this._boundTriggerChange),
);
this._mediaQueries = [];
}
/**
* Determine if the conditions have state conditions.
*/
get hasHAStateConditions(): boolean {
return this._hasHAStateConditions;
}
/**
* Trigger the callback.
* @param _ Ignored parameter.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _triggerChange(_: MediaQueryListEvent): void {
this._callback();
}
/**
* Init the conditions.
* @param config The card configuration.
*/
protected _initConditions(config: FrigateCardConfig): void {
const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => {
const conditions: FrigateCardCondition[] = [];
config.overrides?.forEach((override) => conditions.push(override.conditions));
// Element conditions can be arbitrarily nested underneath conditionals and
// custom elements that this card may not known. Here we recursively parse
// down the elements tree, parsing as we go to find valid conditions.
const getElementsConditions = (data: unknown): void => {
const parseResult = frigateConditionalSchema.safeParse(data);
if (parseResult.success) {
conditions.push(parseResult.data.conditions);
parseResult.data.elements?.forEach(getElementsConditions);
} else if (data && typeof data === 'object') {
Object.keys(data).forEach((key) => getElementsConditions(data[key]));
}
};
config.elements?.forEach(getElementsConditions);
return conditions;
};
const conditions = getAllConditions(config);
this._hasHAStateConditions = conditions.some(
(condition) => !!condition.state?.length,
);
conditions.forEach((condition) => {
if (condition.media_query) {
const mql = window.matchMedia(condition.media_query);
mql.addEventListener('change', this._boundTriggerChange);
this._mediaQueries.push(mql);
}
});
}
}
+675 -573
View File
File diff suppressed because it is too large Load Diff
+30 -67
View File
@@ -15,7 +15,7 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { throttle } from 'lodash-es'; import throttle from 'lodash-es/throttle';
import carouselStyle from '../scss/carousel.scss'; import carouselStyle from '../scss/carousel.scss';
import { TransitionEffect } from '../types'; import { TransitionEffect } from '../types';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
@@ -41,15 +41,12 @@ export class FrigateCardCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public carouselPlugins?: EmblaCarouselPlugins; public carouselPlugins?: EmblaCarouselPlugins;
@property({ attribute: false })
public selected = 0;
@property({ attribute: true }) @property({ attribute: true })
public transitionEffect?: TransitionEffect; public transitionEffect?: TransitionEffect;
// An override to the startIndex, used to preserve the current carousel
// position after the carousel is destroyed (so it can be restored if
// recreated).
// See: https://github.com/dermotduffy/frigate-hass-card/issues/775
protected _savedStartIndex: number | null = null;
protected _refSlot: Ref<HTMLSlotElement> = createRef(); protected _refSlot: Ref<HTMLSlotElement> = createRef();
protected _carousel?: EmblaCarouselType; protected _carousel?: EmblaCarouselType;
@@ -81,7 +78,7 @@ export class FrigateCardCarousel extends LitElement {
// Destroy the carousel when the component is disconnected, which forces the // Destroy the carousel when the component is disconnected, which forces the
// plugins (which may have registered event handlers) to also be destroyed. // plugins (which may have registered event handlers) to also be destroyed.
// The carousel will automatically reconstruct if the component is re-rendered. // The carousel will automatically reconstruct if the component is re-rendered.
this._destroyCarousel({ savePosition: true }); this._destroyCarousel();
super.disconnectedCallback(); super.disconnectedCallback();
} }
@@ -96,32 +93,10 @@ export class FrigateCardCarousel extends LitElement {
'carouselPlugins', 'carouselPlugins',
] as const; ] as const;
if (destroyProperties.some((prop) => changedProps.has(prop))) { if (destroyProperties.some((prop) => changedProps.has(prop))) {
this._destroyCarousel({ savePosition: true }); this._destroyCarousel();
} }
} }
/**
* Scroll to a particular slide.
* @param index Slide number.
*/
public carouselScrollTo(index: number): void {
this._carousel?.scrollTo(index, this.transitionEffect === 'none');
}
/**
* Scroll to the previous slide.
*/
public carouselScrollPrevious(): void {
this._carousel?.scrollPrev(this.transitionEffect === 'none');
}
/**
* Scroll to the next slide.
*/
public carouselScrollNext(): void {
this._carousel?.scrollNext(this.transitionEffect === 'none');
}
/** /**
* Get the selected slide. * Get the selected slide.
* @returns A CarouselSelect object (index & element). * @returns A CarouselSelect object (index & element).
@@ -139,13 +114,6 @@ export class FrigateCardCarousel extends LitElement {
return null; return null;
} }
/**
* Get the carousel.
*/
public carouselClickAllowed(): boolean {
return this._carousel?.clickAllowed() ?? true;
}
/** /**
* Get the carousel. * Get the carousel.
*/ */
@@ -153,25 +121,21 @@ export class FrigateCardCarousel extends LitElement {
return this._carousel ?? null; return this._carousel ?? null;
} }
/**
* ReInit the carousel.
*/
protected _carouselReInit(options?: EmblaOptionsType): void {
// Allow the browser a moment to paint components that are inflight, to
// ensure accurate measurements are taken during the carousel
// reinitialization.
window.requestAnimationFrame(() => {
this._carousel?.reInit({ ...options });
});
}
/** /**
* ReInit the carousel but stay on the current slide. * ReInit the carousel but stay on the current slide.
*/ */
protected _carouselReInitInPlaceInternal(): void { protected _carouselReInitInPlaceInternal(): void {
const selected = this.getCarouselSelected(); const carouselReInit = (options?: EmblaOptionsType): void => {
// Allow the browser a moment to paint components that are inflight, to
// ensure accurate measurements are taken during the carousel
// reinitialization.
window.requestAnimationFrame(() => {
this._carousel?.reInit({ ...options });
});
};
this._carouselReInit({ carouselReInit({
...(selected && { startIndex: selected.index }), startIndex: this.selected,
}); });
} }
@@ -204,6 +168,10 @@ export class FrigateCardCarousel extends LitElement {
if (!this._carousel) { if (!this._carousel) {
this._initCarousel(); this._initCarousel();
} }
if (changedProperties.has('selected')) {
this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none');
}
} }
/** /**
@@ -211,9 +179,7 @@ export class FrigateCardCarousel extends LitElement {
* @param options If `savePosition` is set the existing carousel position * @param options If `savePosition` is set the existing carousel position
* will be saved so it can be restored if the carousel is recreated. * will be saved so it can be restored if the carousel is recreated.
*/ */
protected _destroyCarousel(options?: { savePosition: boolean }): void { protected _destroyCarousel(): void {
this._savedStartIndex =
(options?.savePosition ? this._carousel?.selectedScrollSnap() : null) ?? null;
if (this._carousel) { if (this._carousel) {
this._carousel.destroy(); this._carousel.destroy();
} }
@@ -240,14 +206,13 @@ export class FrigateCardCarousel extends LitElement {
nodes, nodes,
{ {
axis: this.direction == 'horizontal' ? 'x' : 'y', axis: this.direction == 'horizontal' ? 'x' : 'y',
speed: 20, speed: 30,
startIndex: this.selected,
...this.carouselOptions, ...this.carouselOptions,
...(this._savedStartIndex && { startIndex: this._savedStartIndex }),
}, },
this.carouselPlugins, this.carouselPlugins,
); );
this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); const selectSlide = (): void => {
this._carousel.on('select', () => {
const selected = this.getCarouselSelected(); const selected = this.getCarouselSelected();
if (selected) { if (selected) {
dispatchFrigateCardEvent<CarouselSelect>(this, 'carousel:select', selected); dispatchFrigateCardEvent<CarouselSelect>(this, 'carousel:select', selected);
@@ -256,8 +221,10 @@ export class FrigateCardCarousel extends LitElement {
// Make sure every select causes a refresh to allow for re-paint of the // Make sure every select causes a refresh to allow for re-paint of the
// next/previous controls. // next/previous controls.
this.requestUpdate(); this.requestUpdate();
}); };
this._carousel.on('init', selectSlide);
this._carousel.on('select', selectSlide);
this._carousel.on('scroll', () => { this._carousel.on('scroll', () => {
this._scrolling = true; this._scrolling = true;
}); });
@@ -286,18 +253,14 @@ export class FrigateCardCarousel extends LitElement {
protected _slotChanged(): void { protected _slotChanged(): void {
// Cannot just re-init, because the slide elements themselves may have // Cannot just re-init, because the slide elements themselves may have
// changed, and only a carousel init can pass in new (slotted) children. If // changed, and only a carousel init can pass in new (slotted) children. If
// the slides themselves change, any position the user has set is assumed to this._destroyCarousel();
// be abandoned and so the startIndex is reset to whatever the carousel was
// originally configured with.
this._destroyCarousel({ savePosition: false });
this.requestUpdate(); this.requestUpdate();
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || []; const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
const currentSlide = this._carousel?.selectedScrollSnap() ?? 0; const showPrevious = this.carouselOptions?.loop || this.selected > 0;
const showPrevious = this.carouselOptions?.loop || currentSlide > 0; const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length;
const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length;
return html` <div class="embla"> return html` <div class="embla">
${showPrevious ? html`<slot name="previous"></slot>` : ``} ${showPrevious ? html`<slot name="previous"></slot>` : ``}
+44
View File
@@ -0,0 +1,44 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import 'lit-flatpickr';
import { LitFlatpickr } from 'lit-flatpickr';
import { customElement } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import datePickerStyle from '../scss/date-picker.scss';
import { dispatchFrigateCardEvent } from '../utils/basic';
export interface DatePickerEvent {
date: Date;
}
@customElement('frigate-card-date-picker')
export class FrigateCardDatePicker extends LitElement {
protected _refInput: Ref<LitFlatpickr> = createRef();
public open(): void {
this._refInput.value?.open();
}
protected render(): TemplateResult {
return html` <lit-flatpickr
${ref(this._refInput)}
.onChange=${(dates: Date[]) => {
if (dates.length) {
// This is a single date picker, there should be only a single date.
dispatchFrigateCardEvent<DatePickerEvent>(this, 'date-picker:change', {
date: dates[0],
});
}
}}
></lit-flatpickr>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(datePickerStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-date-picker': FrigateCardDatePicker;
}
}
+12 -2
View File
@@ -15,6 +15,11 @@ import drawerStyle from '../scss/drawer.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action'; import { stopEventFromActivatingCardWideActions } from '../utils/action';
import { isHoverableDevice } from '../utils/basic'; import { isHoverableDevice } from '../utils/basic';
export interface DrawerIcons {
open?: string;
closed?: string;
}
@customElement('frigate-card-drawer') @customElement('frigate-card-drawer')
export class FrigateCardDrawer extends LitElement { export class FrigateCardDrawer extends LitElement {
@property({ attribute: true, reflect: true }) @property({ attribute: true, reflect: true })
@@ -26,6 +31,9 @@ export class FrigateCardDrawer extends LitElement {
@property({ type: Boolean, reflect: true, attribute: true }) @property({ type: Boolean, reflect: true, attribute: true })
public open = false; public open = false;
@property({ attribute: false })
public icons?: DrawerIcons;
// The 'empty' attribute is used in the styling to change the drawer // The 'empty' attribute is used in the styling to change the drawer
// visibility and that of all descendants if there is no content. Styling is // visibility and that of all descendants if there is no content. Styling is
// used rather than display or hidden in order to ensure the contents continue // used rather than display or hidden in order to ensure the contents continue
@@ -111,7 +119,9 @@ export class FrigateCardDrawer extends LitElement {
> >
<ha-icon <ha-icon
class="control" class="control"
icon="${this.open ? 'mdi:menu-open' : 'mdi:menu'}" icon="${this.open
? this.icons?.open ?? 'mdi:menu-open'
: this.icons?.closed ?? 'mdi:menu'}"
@mouseenter=${() => { @mouseenter=${() => {
// Only open the drawer on mousenter when the device // Only open the drawer on mousenter when the device
// supports hover (otherwise iOS may end up passing on // supports hover (otherwise iOS may end up passing on
@@ -126,7 +136,7 @@ export class FrigateCardDrawer extends LitElement {
</div> </div>
` `
: ''} : ''}
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot> <slot ${ref(this._refSlot)} @slotchange=${() => this._slotChanged()}></slot>
</side-drawer> </side-drawer>
`; `;
} }
+1 -1
View File
@@ -27,7 +27,7 @@ const defaultOptions: OptionsType = {
breakpoints: {}, breakpoints: {},
}; };
export type AutoMediaOptionsType = Partial<OptionsType> type AutoMediaOptionsType = Partial<OptionsType>
export type AutoMediaType = CreatePluginType< export type AutoMediaType = CreatePluginType<
{ {
+4 -4
View File
@@ -3,7 +3,7 @@ import { CreatePluginType } from 'embla-carousel/components/Plugins';
import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
import { LazyUnloadCondition } from '../../types'; import { LazyUnloadCondition } from '../../types';
export type OptionsType = CreateOptionsType<{ type OptionsType = CreateOptionsType<{
// Number of slides to lazyload left/right of selected (0 == only selected // Number of slides to lazyload left/right of selected (0 == only selected
// slide). // slide).
lazyLoadCount?: number; lazyLoadCount?: number;
@@ -13,15 +13,15 @@ export type OptionsType = CreateOptionsType<{
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
}>; }>;
export const defaultOptions: OptionsType = { const defaultOptions: OptionsType = {
active: true, active: true,
breakpoints: {}, breakpoints: {},
lazyLoadCount: 0, lazyLoadCount: 0,
}; };
export type LazyloadOptionsType = Partial<OptionsType>; type LazyloadOptionsType = Partial<OptionsType>;
export type LazyloadType = CreatePluginType< type LazyloadType = CreatePluginType<
{ {
hasLazyloaded(index: number): boolean; hasLazyloaded(index: number): boolean;
}, },
+360 -132
View File
@@ -1,6 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { import {
css,
CSSResultGroup, CSSResultGroup,
html, html,
LitElement, LitElement,
@@ -8,10 +6,11 @@ import {
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import galleryStyle from '../scss/gallery.scss'; import galleryStyle from '../scss/gallery.scss';
import galleryCoreStyle from '../scss/gallery-core.scss';
import { import {
CameraConfig, CardWideConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
frigateCardConfigDefaults, frigateCardConfigDefaults,
GalleryConfig, GalleryConfig,
@@ -19,14 +18,33 @@ import {
} from '../types.js'; } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { import {
fetchChildMediaAndDispatchViewChange, changeViewToRecentEventsForCameraAndDependents,
fetchLatestMediaAndDispatchViewChange, changeViewToRecentRecordingForCameraAndDependents,
getFullDependentBrowseMediaQueryParametersOrDispatchError, } from '../utils/media-to-view.js';
} from '../utils/ha/browse-media'; import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
import { View } from '../view.js'; import { View } from '../view/view.js';
import { renderProgressIndicator } from './message.js'; import { renderMessage, renderProgressIndicator } from './message.js';
import './thumbnail.js'; import './thumbnail.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types';
import { MediaQueriesResults } from '../view/media-queries-results';
import { errorToConsole, sleep } from '../utils/basic';
import './media-filter';
import './surround-basic';
import { ViewMedia } from '../view/media';
import { localize } from '../localize/localize';
import throttle from 'lodash-es/throttle';
import { classMap } from 'lit/directives/class-map.js';
const GALLERY_MEDIA_FILTER_MENU_ICONS = {
closed: 'mdi:filter-cog-outline',
open: 'mdi:filter-cog',
};
const MIN_GALLERY_EXTENSION_SECONDS = 0.5;
@customElement('frigate-card-gallery') @customElement('frigate-card-gallery')
export class FrigateCardGallery extends LitElement { export class FrigateCardGallery extends LitElement {
@@ -40,69 +58,88 @@ export class FrigateCardGallery extends LitElement {
public galleryConfig?: GalleryConfig; public galleryConfig?: GalleryConfig;
@property({ attribute: false }) @property({ attribute: false })
public cameras?: Map<string, CameraConfig>; public cameraManager?: CameraManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
/** /**
* Master render method. * Master render method.
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const mediaType = this.view?.getMediaType();
if ( if (
!this.hass || !this.hass ||
!this.view || !this.view ||
!this.cameras ||
!this.view.isGalleryView() || !this.view.isGalleryView() ||
!mediaType !this.cameraManager ||
!this.cardWideConfig
) { ) {
return; return;
} }
if (!this.view.target) { if (!this.view.query) {
const browseMediaQueryParameters = if (this.view.is('recordings')) {
getFullDependentBrowseMediaQueryParametersOrDispatchError( changeViewToRecentRecordingForCameraAndDependents(
this, this,
this.hass, this.hass,
this.cameras, this.cameraManager,
this.view.camera, this.cardWideConfig,
mediaType, this.view,
);
} else {
const mediaType = this.view.is('snapshots')
? 'snapshots'
: this.view.is('clips')
? 'clips'
: null;
changeViewToRecentEventsForCameraAndDependents(
this,
this.hass,
this.cameraManager,
this.cardWideConfig,
this.view,
{
...(mediaType && { mediaType: mediaType }),
},
); );
if (!browseMediaQueryParameters) {
return;
} }
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
fetchLatestMediaAndDispatchViewChange(
this,
this.hass,
this.view,
browseMediaQueryParameters,
);
return renderProgressIndicator();
} }
return html` return html`
<frigate-card-gallery-core <frigate-card-surround-basic
.hass=${this.hass} .drawerIcons=${{
.view=${this.view} ...(this.galleryConfig &&
.galleryConfig=${this.galleryConfig} this.galleryConfig.controls.filter.mode !== 'none' && {
.cameras=${this.cameras} [this.galleryConfig.controls.filter.mode]: GALLERY_MEDIA_FILTER_MENU_ICONS,
}),
}}
> >
</frigate-card-gallery-core> ${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none'
? html` <frigate-card-media-filter
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.view=${this.view}
.cardWideConfig=${this.cardWideConfig}
slot=${this.galleryConfig.controls.filter.mode}
>
</frigate-card-media-filter>`
: ''}
<frigate-card-gallery-core
.hass=${this.hass}
.view=${this.view}
.galleryConfig=${this.galleryConfig}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-gallery-core>
</frigate-card-surround-basic>
`; `;
} }
/**
* Get element styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return unsafeCSS(galleryStyle);
:host {
display: block;
width: 100%;
height: 100%;
}
`;
} }
} }
@@ -118,13 +155,112 @@ export class FrigateCardGalleryCore extends LitElement {
public galleryConfig?: GalleryConfig; public galleryConfig?: GalleryConfig;
@property({ attribute: false }) @property({ attribute: false })
public cameras?: Map<string, CameraConfig>; public cameraManager?: CameraManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _intersectionObserver: IntersectionObserver;
protected _resizeObserver: ResizeObserver; protected _resizeObserver: ResizeObserver;
protected _refLoaderBottom: Ref<HTMLElement> = createRef();
protected _refSelected: Ref<HTMLElement> = createRef();
// Bottom loader: A progress indicator shown in a "cell" (not across) at the
// bottom of the gallery. Once visible this attempts to fetch new content from
// "earlier" (less recently) than the current query. This is rendered by
// default (and once visible, the fetch is triggered after which it is
// re-hidden).
@state()
protected _showLoaderBottom = true;
// Top loader: A progress indicator is shown across the top of the gallery if
// the user is _already_ at the top of the gallery and scrolls upwards. This
// attempts to fetch new content from "later" (more recently) than the current
// query. This is hidden by default.
@state()
protected _showLoaderTop = false;
protected _media?: ViewMedia[];
protected _boundWheelHandler = this._wheelHandler.bind(this);
protected _boundTouchStartHandler = this._touchStartHandler.bind(this);
protected _boundTouchEndHandler = this._touchEndHandler.bind(this);
// Wheel / touch events may be voluminous, throttle extension calls.
protected _throttleExtendGalleryLater = throttle(
this._extendGallery.bind(this),
MIN_GALLERY_EXTENSION_SECONDS * 1000,
{
leading: true,
trailing: false,
},
);
protected _touchScrollYPosition: number | null = null;
constructor() { constructor() {
super(); super();
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
}
// Since the scroll event does not fire if the user is already at the top of
// the container, instead we manually use the wheel and touchstart/end events
// to detect "top upwards scrolling" (to trigger an extension of the gallery).
protected _touchStartHandler(ev: TouchEvent): void {
// Remember the Y touch position on touch start, so that we can calculate if
// the user gestured upwards or downards on touchend.
if (ev.touches.length === 1) {
this._touchScrollYPosition = ev.touches[0].screenY;
} else {
this._touchScrollYPosition = null;
}
}
protected async _touchEndHandler(ev: TouchEvent): Promise<void> {
if (
!this.scrollTop &&
ev.changedTouches.length === 1 &&
this._touchScrollYPosition
) {
if (ev.changedTouches[0].screenY > this._touchScrollYPosition) {
await this._extendLater();
}
}
this._touchScrollYPosition = null;
}
protected async _wheelHandler(ev: WheelEvent): Promise<void> {
if (!this.scrollTop && ev.deltaY < 0) {
await this._extendLater();
}
}
protected async _extendLater(): Promise<void> {
const start = new Date();
this._showLoaderTop = true;
await this._throttleExtendGalleryLater(
'later',
// Ask the engine to avoid use of cache since the user is explicitly
// looking for the freshest possible data.
false,
);
const delta = new Date().getTime() - start.getTime();
if (delta < MIN_GALLERY_EXTENSION_SECONDS * 1000) {
// Hidden gem: "legitimate" (?!) use of sleep() :-)
// These calls can return very quickly even with caching disabled since
// the time window constraints on the query will usually be very narrow
// and the backend can thus very quickly reply. It's often so fast it
// actually looks like a rendering issue where the progress indictor
// barely registers before it's gone again. This optional pause ensures
// there is at least some visual feedback to the user that last long
// enough they can 'feel' the fetch has happened.
await sleep(MIN_GALLERY_EXTENSION_SECONDS - delta / 1000);
}
this._showLoaderTop = false;
} }
/** /**
@@ -133,13 +269,24 @@ export class FrigateCardGalleryCore extends LitElement {
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
this._resizeObserver.observe(this); this._resizeObserver.observe(this);
this.addEventListener('wheel', this._boundWheelHandler, { passive: true });
this.addEventListener('touchstart', this._boundTouchStartHandler, { passive: true });
this.addEventListener('touchend', this._boundTouchEndHandler);
// Request update in order to ensure the intersection observer reconnects
// with the loader sentinel.
this.requestUpdate();
} }
/** /**
* Component disconnected callback. * Component disconnected callback.
*/ */
disconnectedCallback(): void { disconnectedCallback(): void {
this.removeEventListener('wheel', this._boundWheelHandler);
this.removeEventListener('touchstart', this._boundTouchStartHandler);
this.removeEventListener('touchend', this._boundTouchEndHandler);
this._resizeObserver.disconnect(); this._resizeObserver.disconnect();
this._intersectionObserver.disconnect();
super.disconnectedCallback(); super.disconnectedCallback();
} }
@@ -149,7 +296,7 @@ export class FrigateCardGalleryCore extends LitElement {
protected _setColumnCount(): void { protected _setColumnCount(): void {
const thumbnailSize = const thumbnailSize =
this.galleryConfig?.controls.thumbnails.size ?? this.galleryConfig?.controls.thumbnails.size ??
frigateCardConfigDefaults.event_gallery.controls.thumbnails.size; frigateCardConfigDefaults.media_gallery.controls.thumbnails.size;
const columns = this.galleryConfig?.controls.thumbnails.show_details const columns = this.galleryConfig?.controls.thumbnails.show_details
? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN)) ? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN))
: Math.max( : Math.max(
@@ -168,16 +315,66 @@ export class FrigateCardGalleryCore extends LitElement {
this._setColumnCount(); this._setColumnCount();
} }
/** protected async _intersectionHandler(
* Determine whether the back arrow should be displayed. entries: IntersectionObserverEntry[],
* @returns `true` if the back arrow should be displayed, `false` otherwise. ): Promise<void> {
*/ if (entries.every((entry) => !entry.isIntersecting)) {
protected _showBackArrow(): boolean { return;
return ( }
!!this.view?.previous &&
!!this.view.previous.target && this._showLoaderBottom = false;
this.view.previous.view === this.view.view await this._extendGallery('earlier');
); }
protected async _extendGallery(
direction: 'earlier' | 'later',
useCache = true,
): Promise<void> {
if (!this.cameraManager || !this.hass || !this.view) {
return;
}
const query = this.view?.query;
const rawQueries = query?.getQueries() ?? null;
const existingMedia = this.view.queryResults?.getResults();
if (!query || !rawQueries || !existingMedia) {
return;
}
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
try {
extension = await this.cameraManager.extendMediaQueries<MediaQuery>(
this.hass,
rawQueries,
existingMedia,
direction,
{
useCache: useCache,
},
);
} catch (e) {
errorToConsole(e as Error);
return;
}
if (extension) {
const newMediaQueries = MediaQueriesClassifier.areEventQueries(query)
? new EventMediaQueries(extension.queries as EventQuery[])
: MediaQueriesClassifier.areRecordingQueries(query)
? new RecordingMediaQueries(extension.queries as RecordingQuery[])
: null;
if (newMediaQueries) {
this.view
?.evolve({
query: newMediaQueries,
queryResults: new MediaQueriesResults(extension.results).selectResultIfFound(
(media) => media === this.view?.queryResults?.getSelectedResult(),
),
})
.dispatchChangeEvent(this);
}
}
} }
/** /**
@@ -199,6 +396,21 @@ export class FrigateCardGalleryCore extends LitElement {
); );
} }
} }
if (changedProps.has('view')) {
// If the view changes, always render the bottom loader to allow for the
// view to be extended once the bottom loader becomes visible.
this._showLoaderBottom = true;
const oldView: View | undefined = changedProps.get('view');
if (
oldView?.queryResults?.getResults() !== this.view?.queryResults?.getResults()
) {
// Gallery places the most recent media at the top (the query results place
// the most recent media at the end for use in the viewer). This is copied
// to a new array to avoid reversing the query results in place.
this._media = [...(this.view?.queryResults?.getResults() ?? [])].reverse();
}
}
} }
/** /**
@@ -206,90 +418,106 @@ export class FrigateCardGalleryCore extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if ( if (!this._media || !this.hass || !this.view || !this.view.isGalleryView()) {
!this.hass ||
!this.view ||
!this.view.target ||
!this.view.target.children ||
!(this.view.is('clips') || this.view.is('snapshots')) ||
!this.cameras
) {
return html``; return html``;
} }
const cameraConfig = this.cameras.get(this.view.camera); if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) {
return html` // Note that this is not throwing up an error message for the card to
${this._showBackArrow() // handle (as typical), but rather directly rendering the message into the
? html` <ha-card // gallery. This is to allow the filter to still be available when a given
@click=${(ev) => { // filter selection returns no media.
if (this.view && this.view.previous) { return renderMessage({
this.view.previous.dispatchChangeEvent(this); type: 'info',
message: localize('common.no_media'),
icon: 'mdi:multimedia',
});
}
const selected = this.view?.queryResults?.getSelectedResult();
return html` <div class="grid">
${this._showLoaderTop
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
classes: {
top: true,
},
size: 'small',
})}`
: ''}
${this._media.map(
(media, index) =>
html`<frigate-card-thumbnail
${media === selected ? ref(this._refSelected) : ''}
class=${classMap({
selected: media === selected,
})}
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.media=${media}
.view=${this.view}
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
.show_favorite_control}
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
.show_timeline_control}
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
.show_download_control}
@click=${(ev: Event) => {
if (this.view && this._media) {
this.view
.evolve({
view: 'media',
queryResults: this.view.queryResults?.clone().selectResult(
// Media in the gallery is reversed vs the queryResults (see
// note above).
this._media.length - index - 1,
),
})
.dispatchChangeEvent(this);
} }
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
outlined=""
> >
<ha-icon .icon=${'mdi:arrow-left'}></ha-icon> </frigate-card-thumbnail>`,
</ha-card>`
: ''}
${this.view.target.children.map(
(child, index) =>
html`
${child.can_expand
? html`
<ha-card
@click=${(ev) => {
if (this.hass && this.view) {
fetchChildMediaAndDispatchViewChange(
this,
this.hass,
this.view,
child,
);
}
stopEventFromActivatingCardWideActions(ev);
}}
outlined=""
>
<div>${child.title}</div>
</ha-card>
`
: child.thumbnail
? html`<frigate-card-thumbnail
.view=${this.view}
.target=${this.view?.target ?? null}
.childIndex=${index}
.hass=${this.hass}
.clientID=${cameraConfig?.frigate.client_id}
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
.show_favorite_control}
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
.show_timeline_control}
@click=${(ev: Event) => {
if (this.view) {
this.view
.evolve({
view: this.view.is('clips') ? 'clip' : 'snapshot',
childIndex: index,
})
.dispatchChangeEvent(this);
}
stopEventFromActivatingCardWideActions(ev);
}}
>
</frigate-card-thumbnail>`
: ``}
`,
)} )}
`; ${this._showLoaderBottom
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
componentRef: this._refLoaderBottom,
})}`
: ''}
</div>`;
}
public updated(changedProps: PropertyValues): void {
if (this._refLoaderBottom.value) {
this._intersectionObserver.disconnect();
this._intersectionObserver.observe(this._refLoaderBottom.value);
}
// This wait for updateComplete is necessary for the scrolling to work
// correctly.
this.updateComplete.then(() => {
// As a special case, if the view has changed and did not previously exist
// (i.e. first setting of it), we intentionally scroll the gallery to the
// selected element in that view (if any).
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
if (
// If this update cycle updated the view ...
changedProps.has('view') &&
// ... and it wasn't set at all prior ...
!changedProps.get('view') &&
// ... and there is a thumbnail rendered that is selected.
this._refSelected.value
) {
this._refSelected.value.scrollIntoView();
}
});
} }
/**
* Get styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(galleryStyle); return unsafeCSS(galleryCoreStyle);
} }
} }
+23 -13
View File
@@ -6,7 +6,7 @@ import {
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js'; import { live } from 'lit/directives/live.js';
@@ -15,13 +15,17 @@ import { CachedValueController } from '../cached-value-controller.js';
import defaultImage from '../images/frigate-bird-in-sky.jpg'; import defaultImage from '../images/frigate-bird-in-sky.jpg';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import imageStyle from '../scss/image.scss'; import imageStyle from '../scss/image.scss';
import { CameraConfig, ImageViewConfig } from '../types.js'; import { CameraConfig, ImageViewConfig, MediaLoadedInfo } from '../types.js';
import { isHassDifferent } from '../utils/ha'; import { isHassDifferent } from '../utils/ha';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; import {
import { View } from '../view.js'; createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
} from '../utils/media-info.js';
import { View } from '../view/view.js';
import { dispatchErrorMessageEvent } from './message.js'; import { dispatchErrorMessageEvent } from './message.js';
import { contentsChanged } from '../utils/basic.js'; import { contentsChanged } from '../utils/basic.js';
import isEqual from 'lodash-es/isEqual';
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py . // See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000; const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
@@ -48,6 +52,8 @@ export class FrigateCardImage extends LitElement {
protected _cachedValueController?: CachedValueController<string>; protected _cachedValueController?: CachedValueController<string>;
protected _boundVisibilityHandler = this._visibilityHandler.bind(this); protected _boundVisibilityHandler = this._visibilityHandler.bind(this);
protected _mediaLoadedInfo: MediaLoadedInfo | null = null;
/** /**
* Get the camera entity for the current camera configuration. * Get the camera entity for the current camera configuration.
* @returns The entity or undefined if no camera entity is available. * @returns The entity or undefined if no camera entity is available.
@@ -234,7 +240,13 @@ export class FrigateCardImage extends LitElement {
${ref(this._refImage)} ${ref(this._refImage)}
src=${live(src)} src=${live(src)}
@load=${(ev: Event) => { @load=${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev); const mediaLoadedInfo = createMediaLoadedInfo(ev);
// Avoid the media being reported as repeatedly loading unless the
// media info changes.
if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) {
this._mediaLoadedInfo = mediaLoadedInfo;
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
}
}} }}
@error=${() => { @error=${() => {
if (this.imageConfig?.mode === 'camera') { if (this.imageConfig?.mode === 'camera') {
@@ -246,11 +258,9 @@ export class FrigateCardImage extends LitElement {
} else if (this.imageConfig?.mode === 'url') { } else if (this.imageConfig?.mode === 'url') {
// In url mode, the user likely specified a URL that cannot be // In url mode, the user likely specified a URL that cannot be
// resolved. Show an error message. // resolved. Show an error message.
dispatchErrorMessageEvent( dispatchErrorMessageEvent(this, localize('error.image_load_error'), {
this, context: this.imageConfig,
localize('error.image_load_error'), });
{ context: this.imageConfig },
);
} }
}} }}
/>` />`
@@ -263,7 +273,7 @@ export class FrigateCardImage extends LitElement {
} }
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"frigate-card-image": FrigateCardImage 'frigate-card-image': FrigateCardImage;
} }
} }
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import liveMSEStyle from '../../scss/live-go2rtc.scss';
import {
CameraConfig,
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
} from '../../types.js';
import '../image.js';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../../utils/media';
import { dispatchMediaLoadedEvent } from '../../utils/media-info';
import { localize } from '../../localize/localize';
import { dispatchErrorMessageEvent } from '../message';
import { VideoRTC } from '../../external/go2rtc/video-rtc';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
// Note (2023-02-18): Depending on the behavior of the player / browser is
// possible this URL will need to be re-signed in order to avoid HA spamming
// logs after the expiry time, but this complexity is not added for now until
// there are verified cases of this being an issue (see equivalent in the JSMPEG
// provider).
const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
@customElement('frigate-card-live-go2rtc-player')
class FrigateCardGo2RTCPlayer extends VideoRTC {
public play(): void {
// Let Frigate card control auto playing.
}
public oninit(): void {
super.oninit();
if (this.video) {
const onloadeddata = this.video.onloadeddata;
this.video.onloadeddata = (e) => {
if (onloadeddata) {
onloadeddata.call(this.video, e);
}
hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
dispatchMediaLoadedEvent(this, this.video);
};
// Always started muted. Media may be unmuted in accordance with user
// configuration.
this.video.muted = true;
}
}
}
@customElement('frigate-card-live-go2rtc')
export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPlayer {
// Not an reactive property to avoid resetting the video.
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public cameraEndpoints?: CameraEndpoints;
protected _player?: FrigateCardGo2RTCPlayer;
public async play(): Promise<void> {
return this._player?.video?.play();
}
public async pause(): Promise<void> {
this._player?.video?.pause();
}
public async mute(): Promise<void> {
if (this._player?.video) {
this._player.video.muted = true;
}
}
public async unmute(): Promise<void> {
if (this._player?.video) {
this._player.video.muted = false;
}
}
public isMuted(): boolean {
return this._player?.video.muted ?? true;
}
public async seek(seconds: number): Promise<void> {
if (this._player?.video) {
this._player.video.currentTime = seconds;
}
}
disconnectedCallback(): void {
this._player = undefined;
}
connectedCallback(): void {
super.connectedCallback();
// Reset the player when reconnected to the DOM.
// https://github.com/dermotduffy/frigate-hass-card/issues/996
this.requestUpdate();
}
protected async _createPlayer(): Promise<void> {
if (!this.hass) {
return;
}
const endpoint = this.cameraEndpoints?.go2rtc;
if (!endpoint) {
return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), {
context: this.cameraConfig,
});
}
const address = await getEndpointAddressOrDispatchError(
this,
this.hass,
endpoint,
GO2RTC_URL_SIGN_EXPIRY_SECONDS,
);
if (!address) {
return;
}
this._player = new FrigateCardGo2RTCPlayer();
this._player.src = address;
this._player.visibilityCheck = false;
if (this.cameraConfig?.go2rtc?.modes && this.cameraConfig.go2rtc.modes.length) {
this._player.mode = this.cameraConfig.go2rtc.modes.join(',');
}
this.requestUpdate();
}
protected willUpdate(changedProps: PropertyValues): void {
if (!this._player || changedProps.has('cameraEndpoints')) {
this._createPlayer();
}
}
protected render(): TemplateResult | void {
return html`${this._player}`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(liveMSEStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-live-go2rtc': FrigateCardGo2RTC;
}
}
+75
View File
@@ -0,0 +1,75 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import liveHAStyle from '../../scss/live-ha.scss';
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
import { getStateObjOrDispatchError } from './live.js';
import '../../patches/ha-camera-stream';
import '../../patches/ha-hls-player.js';
import '../../patches/ha-web-rtc-player.ts';
@customElement('frigate-card-live-ha')
export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPlayer {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
protected _playerRef: Ref<Element & FrigateCardMediaPlayer> = createRef();
public async play(): Promise<void> {
return this._playerRef.value?.play();
}
public async pause(): Promise<void> {
this._playerRef.value?.pause();
}
public async mute(): Promise<void> {
this._playerRef.value?.mute();
}
public async unmute(): Promise<void> {
this._playerRef.value?.unmute();
}
public isMuted(): boolean {
return this._playerRef.value?.isMuted() ?? true;
}
public async seek(seconds: number): Promise<void> {
this._playerRef.value?.seek(seconds);
}
protected render(): TemplateResult | void {
if (!this.hass) {
return;
}
const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
if (!stateObj) {
return;
}
return html` <frigate-card-ha-camera-stream
${ref(this._playerRef)}
.hass=${this.hass}
.stateObj=${stateObj}
.controls=${true}
.muted=${true}
>
</frigate-card-ha-camera-stream>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(liveHAStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-live-ha': FrigateCardLiveHA;
}
}
+75
View File
@@ -0,0 +1,75 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import liveImageStyle from '../../scss/live-image.scss';
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
import { getStateObjOrDispatchError } from './live.js';
import '../image.js';
@customElement('frigate-card-live-image')
export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@state()
protected _playing = true;
public async play(): Promise<void> {
this._playing = true;
}
public async pause(): Promise<void> {
this._playing = false;
}
public async mute(): Promise<void> {
// Not implemented.
}
public async unmute(): Promise<void> {
// Not implemented.
}
public isMuted(): boolean {
return true;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async seek(_seconds: number): Promise<void> {
// Not implemented.
}
protected render(): TemplateResult | void {
if (!this.hass || !this.cameraConfig) {
return;
}
getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
return html` <frigate-card-image
.imageConfig=${{
mode: this.cameraConfig.image.url ? ('url' as const) : ('camera' as const),
refresh_seconds: this._playing ? this.cameraConfig.image.refresh_seconds : 0,
url: this.cameraConfig.image.url,
// Don't need to pass layout options as FrigateCardLiveProvider has
// already taken care of this for us.
}}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
>
</frigate-card-image>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(liveImageStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-live-image': FrigateCardLiveImage;
}
}
+227
View File
@@ -0,0 +1,227 @@
import JSMpeg from '@cycjimmy/jsmpeg-player';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { renderProgressIndicator } from '../../components/message.js';
import { localize } from '../../localize/localize.js';
import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss';
import {
CameraConfig,
CardWideConfig,
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
} from '../../types.js';
import { dispatchMediaLoadedEvent } from '../../utils/media-info.js';
import { dispatchErrorMessageEvent } from '../message.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js';
// Number of seconds a signed URL is valid for.
const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
// Number of seconds before the expiry to trigger a refresh.
const JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60;
@customElement('frigate-card-live-jsmpeg')
export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMediaPlayer {
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public cameraEndpoints?: CameraEndpoints;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected hass?: ExtendedHomeAssistant;
protected _jsmpegCanvasElement?: HTMLCanvasElement;
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
protected _refreshPlayerTimerID?: number;
public async play(): Promise<void> {
return this._jsmpegVideoPlayer?.play();
}
public async pause(): Promise<void> {
this._jsmpegVideoPlayer?.stop();
}
public async mute(): Promise<void> {
const player = this._jsmpegVideoPlayer?.player;
if (player) {
player.volume = 0;
}
}
public async unmute(): Promise<void> {
const player = this._jsmpegVideoPlayer?.player;
if (player) {
player.volume = 1;
}
}
public isMuted(): boolean {
return this._jsmpegVideoPlayer ? this._jsmpegVideoPlayer.player.volume === 0 : true;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async seek(_seconds: number): Promise<void> {
// JSMPEG does not support seeking.
}
/**
* Create a JSMPEG player.
* @param url The URL for the player to connect to.
* @returns A JSMPEG player.
*/
protected async _createJSMPEGPlayer(url: string): Promise<JSMpeg.VideoElement> {
return new Promise<JSMpeg.VideoElement>((resolve) => {
let videoDecoded = false;
const player = new JSMpeg.VideoElement(
this,
url,
{
canvas: this._jsmpegCanvasElement,
},
{
// The media carousel may automatically pause when the browser tab is
// inactive, JSMPEG does not need to also do so independently.
pauseWhenHidden: false,
autoplay: false,
protocols: [],
audio: false,
videoBufferSize: 1024 * 1024 * 4,
// Override with user-specified options.
...this.cameraConfig?.jsmpeg?.options,
// Don't allow the player to internally reconnect, as it may re-use a
// URL with a (newly) invalid signature, e.g. during a Home Assistant
// restart.
reconnectInterval: 0,
onVideoDecode: () => {
// This is the only callback that is called after the dimensions
// are available. It's called on every frame decode, so just
// ignore any subsequent calls.
if (!videoDecoded && this._jsmpegCanvasElement) {
videoDecoded = true;
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement);
resolve(player);
}
},
},
);
});
}
/**
* Reset / destroy the player.
*/
protected _resetPlayer(): void {
if (this._refreshPlayerTimerID) {
window.clearTimeout(this._refreshPlayerTimerID);
this._refreshPlayerTimerID = undefined;
}
if (this._jsmpegVideoPlayer) {
try {
this._jsmpegVideoPlayer.destroy();
} catch (err) {
// Pass.
}
this._jsmpegVideoPlayer = undefined;
}
if (this._jsmpegCanvasElement) {
this._jsmpegCanvasElement.remove();
this._jsmpegCanvasElement = undefined;
}
}
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
if (this.isConnected) {
this.requestUpdate();
}
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
if (!this.isConnected) {
this._resetPlayer();
}
super.disconnectedCallback();
}
/**
* Refresh the JSMPEG player.
*/
protected async _refreshPlayer(): Promise<void> {
if (!this.hass) {
return;
}
this._resetPlayer();
this._jsmpegCanvasElement = document.createElement('canvas');
this._jsmpegCanvasElement.className = 'media';
const endpoint = this.cameraEndpoints?.jsmpeg;
if (!endpoint) {
return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), {
context: this.cameraConfig,
});
}
const address = await getEndpointAddressOrDispatchError(
this,
this.hass,
endpoint,
JSMPEG_URL_SIGN_EXPIRY_SECONDS,
);
if (!address) {
return;
}
this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(address);
this._refreshPlayerTimerID = window.setTimeout(() => {
this.requestUpdate();
}, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
}
/**
* Master render method.
*/
protected render(): TemplateResult | void {
const _render = async (): Promise<TemplateResult | void> => {
await this._refreshPlayer();
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player'));
}
return html`${this._jsmpegCanvasElement}`;
};
return html`${until(
_render(),
renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
}),
)}`;
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveJSMPEGStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-live-jsmpeg': FrigateCardLiveJSMPEG;
}
}
+199
View File
@@ -0,0 +1,199 @@
import { Task } from '@lit-labs/task';
import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { localize } from '../../localize/localize.js';
import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss';
import {
CameraConfig,
CardWideConfig,
FrigateCardError,
FrigateCardMediaPlayer,
} from '../../types.js';
import { dispatchMediaLoadedEvent } from '../../utils/media-info.js';
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
import { renderTask } from '../../utils/task.js';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../../utils/media.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
// Create a wrapper for AlexxIT's WebRTC card
// - https://github.com/AlexxIT/WebRTC
@customElement('frigate-card-live-webrtc-card')
export class FrigateCardLiveWebRTCCard
extends LitElement
implements FrigateCardMediaPlayer
{
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public cameraEndpoints?: CameraEndpoints;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected hass?: HomeAssistant;
// A task to await the load of the WebRTC component.
protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]);
public async play(): Promise<void> {
return this._getPlayer()?.play();
}
public async pause(): Promise<void> {
this._getPlayer()?.pause();
}
public async mute(): Promise<void> {
const player = this._getPlayer();
if (player) {
player.muted = true;
}
}
public async unmute(): Promise<void> {
const player = this._getPlayer();
if (player) {
player.muted = false;
}
}
public isMuted(): boolean {
return this._getPlayer()?.muted ?? true;
}
public async seek(seconds: number): Promise<void> {
const player = this._getPlayer();
if (player) {
player.currentTime = seconds;
}
}
connectedCallback(): void {
super.connectedCallback();
// Reset the player when reconnected to the DOM.
// https://github.com/dermotduffy/frigate-hass-card/issues/996
this.requestUpdate();
}
/**
* Get the underlying video player.
* @returns The player or `null` if not found.
*/
protected _getPlayer(): HTMLVideoElement | null {
const root = this.renderRoot?.querySelector('#webrtc') as
| (HTMLElement & { video?: HTMLVideoElement })
| null;
return root?.video ?? null;
}
protected async _getWebRTCCardElement(): Promise<
CustomElementConstructor | undefined
> {
await customElements.whenDefined('webrtc-camera');
return customElements.get('webrtc-camera');
}
/**
* Create the WebRTC element. May throw.
*/
protected _createWebRTC(): HTMLElement | null {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const webrtcElement = this._webrtcTask.value;
if (webrtcElement && this.hass && this.cameraConfig) {
const webrtc = new webrtcElement() as HTMLElement & {
hass: HomeAssistant;
setConfig: (config: Record<string, unknown>) => void;
};
const config = { ...this.cameraConfig.webrtc_card };
if (!config.url && !config.entity && this.cameraEndpoints?.webrtcCard) {
// This will never need to be signed, it is just used internally by the
// card as a stream name lookup.
config.url = this.cameraEndpoints.webrtcCard.endpoint;
}
webrtc.setConfig(config);
webrtc.hass = this.hass;
return webrtc;
}
return null;
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
const render = (): TemplateResult | void => {
let webrtcElement: HTMLElement | null;
try {
webrtcElement = this._createWebRTC();
} catch (e) {
return dispatchErrorMessageEvent(
this,
e instanceof FrigateCardError
? e.message
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
{ context: (e as FrigateCardError).context },
);
}
if (webrtcElement) {
// Set the id to ensure that the relevant CSS styles will have
// sufficient specifity to overcome some styles that are otherwise
// applied to <ha-card> in Safari.
webrtcElement.id = 'webrtc';
}
return html`${webrtcElement}`;
};
// Use a task to allow us to asynchronously wait for the WebRTC card to
// load, but yet still have the card load be followed by the updated()
// lifecycle callback (unlike just using `until`).
return renderTask(this, this._webrtcTask, render, {
inProgressFunc: () =>
renderProgressIndicator({
message: localize('error.webrtc_card_waiting'),
cardWideConfig: this.cardWideConfig,
}),
});
}
/**
* Updated lifecycle callback.
*/
public updated(): void {
// Extract the video component after it has been rendered and generate the
// media load event.
this.updateComplete.then(() => {
const video = this._getPlayer();
if (video) {
const onloadeddata = video.onloadeddata;
video.onloadeddata = (e) => {
if (onloadeddata) {
onloadeddata.call(video, e);
}
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
dispatchMediaLoadedEvent(this, video);
};
}
});
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveWebRTCCardStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-live-webrtc-card': FrigateCardLiveWebRTCCard;
}
}
+966
View File
@@ -0,0 +1,966 @@
import { EmblaOptionsType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { guard } from 'lit/directives/guard.js';
import { keyed } from 'lit/directives/keyed.js';
import { ConditionState, getOverriddenConfig } from '../../card-condition.js';
import { localize } from '../../localize/localize.js';
import liveStyle from '../../scss/live.scss';
import liveCarouselStyle from '../../scss/live-carousel.scss';
import liveProviderStyle from '../../scss/live-provider.scss';
import {
CameraConfig,
CardWideConfig,
ExtendedHomeAssistant,
frigateCardConfigDefaults,
FrigateCardMediaPlayer,
LiveConfig,
LiveOverrides,
LiveProvider,
MediaLoadedInfo,
Message,
TransitionEffect,
} from '../../types.js';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { contentsChanged } from '../../utils/basic.js';
import {
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} from '../../utils/media-info.js';
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
import { Lazyload } from './../embla-plugins/lazyload.js';
import {
FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel,
wrapMediaUnloadedEventForCarousel,
} from '../media-carousel.js';
import '../next-prev-control.js';
import '../title-control.js';
import '../surround.js';
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
import { classMap } from 'lit/directives/class-map.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { HomeAssistant } from 'custom-card-helpers';
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
import { HassEntity } from 'home-assistant-js-websocket';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { playMediaMutingIfNecessary } from '../../utils/media.js';
interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different
// camera to be live rather than the camera selected in the view).
overrides?: Map<string, string>;
}
declare module 'view' {
interface ViewContext {
live?: LiveViewContext;
}
}
interface LastMediaLoadedInfo {
mediaLoadedInfo: MediaLoadedInfo;
source: EventTarget;
}
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
/**
* Get the state object or dispatch an error. Used in `ha` and `image` live
* providers.
* @param element HTMLElement to dispatch errors from.
* @param hass Home Assistant object.
* @param cameraConfig Camera configuration.
* @returns
*/
export const getStateObjOrDispatchError = (
element: HTMLElement,
hass: HomeAssistant,
cameraConfig?: CameraConfig,
): HassEntity | null => {
if (!cameraConfig?.camera_entity) {
dispatchErrorMessageEvent(element, localize('error.no_live_camera'), {
context: cameraConfig,
});
return null;
}
const stateObj = hass.states[cameraConfig.camera_entity];
if (!stateObj) {
dispatchErrorMessageEvent(element, localize('error.live_camera_not_found'), {
context: cameraConfig,
});
return null;
}
if (stateObj.state === 'unavailable') {
dispatchMessageEvent(element, localize('error.live_camera_unavailable'), 'info', {
icon: 'mdi:connection',
context: cameraConfig,
});
return null;
}
return stateObj;
};
@customElement('frigate-card-live')
export class FrigateCardLive extends LitElement {
@property({ attribute: false })
public conditionState?: ConditionState;
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false })
public liveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
// Whether or not the live view is currently in the background (i.e. preloaded
// but not visible)
@state()
protected _inBackground?: boolean = false;
// Intersection handler is used to detect when the live view flips between
// foreground and background (in preload mode).
protected _intersectionObserver: IntersectionObserver;
// MediaLoadedInfo object and target from the underlying live object. In the
// case of pre-loading these may be propagated later (from the original
// source).
protected _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null;
protected _messageReceivedPostRender = false;
protected _renderKey = 0;
constructor() {
super();
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
}
/**
* Called when the live view intersects with the viewport.
* @param entries The IntersectionObserverEntry entries (should be only 1).
*/
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
this._inBackground = !entries.some((entry) => entry.isIntersecting);
if (
!this._inBackground &&
!this._messageReceivedPostRender &&
this._lastMediaLoadedInfo
) {
// If this isn't being rendered in the background, the last render did not
// generate a message and there's a saved MediaInfo, dispatch it upwards.
dispatchExistingMediaLoadedInfoAsEvent(
// Specifically dispatch the event "where it came from", as otherwise
// the intermediate layers (e.g. media-carousel which controls the title
// popups) will not re-receive the events.
this._lastMediaLoadedInfo.source,
this._lastMediaLoadedInfo.mediaLoadedInfo,
);
}
// Trigger a re-render which may be necessary if the prior render resulted
// in a message.
if (this._messageReceivedPostRender && !this._inBackground) {
this.requestUpdate();
}
}
/**
* Determine whether the element should be updated.
* @param _changedProps The changed properties if any.
* @returns `true` if the element should be updated.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_changedProps: PropertyValues): boolean {
// Don't process updates if it's in the background and a message was
// received (otherwise an error message thrown by the background live
// component may continually be re-spammed hitting performance).
return !this._inBackground || !this._messageReceivedPostRender;
}
/**
* Component connected callback.
*/
connectedCallback(): void {
this._intersectionObserver.observe(this);
super.connectedCallback();
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this._intersectionObserver.disconnect();
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) {
return;
}
// Notes:
// - See use of liveConfig and not config below -- the carousel will
// independently override the liveConfig to reflect the camera in the
// carousel (not necessarily the selected camera).
// - Various events are captured to prevent them propagating upwards if the
// card is in the background.
// - The entire returned template is keyed to allow for the whole template
// to be re-rendered in certain circumstances (specifically: if a message
// is received when the card is in the background).
const result = html`${keyed(
this._renderKey,
html`
<frigate-card-live-carousel
.hass=${this.hass}
.view=${this.view}
.liveConfig=${this.liveConfig}
.inBackground=${this._inBackground}
.conditionState=${this.conditionState}
.liveOverrides=${this.liveOverrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
@frigate-card:message=${(ev: CustomEvent<Message>) => {
this._renderKey++;
this._messageReceivedPostRender = true;
if (this._inBackground) {
ev.stopPropagation();
}
}}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
this._lastMediaLoadedInfo = {
source: ev.composedPath()[0],
mediaLoadedInfo: ev.detail,
};
if (this._inBackground) {
ev.stopPropagation();
}
}}
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
if (this._inBackground) {
ev.stopPropagation();
}
}}
>
</frigate-card-live-carousel>
`,
)}`;
this._messageReceivedPostRender = false;
return result;
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveStyle);
}
}
@customElement('frigate-card-live-carousel')
export class FrigateCardLiveCarousel extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false })
public liveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides;
@property({ attribute: false })
public inBackground?: boolean;
@property({ attribute: false })
public conditionState?: ConditionState;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
// Index between camera name and slide number.
protected _cameraToSlide: Record<string, number> = {};
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has('inBackground')) {
this.updateComplete.then(async () => {
const frigateCardMediaCarousel = this._refMediaCarousel.value;
if (frigateCardMediaCarousel) {
await frigateCardMediaCarousel.updateComplete;
// If this has changed to be in the background (i.e. preloaded but not
// visible) take the appropriate play/pause/mute/unmute actions.
if (this.inBackground) {
frigateCardMediaCarousel.autoPause();
frigateCardMediaCarousel.autoMute();
} else {
frigateCardMediaCarousel.autoPlay();
frigateCardMediaCarousel.autoUnmute();
}
}
});
}
}
/**
* Get the transition effect to use.
* @returns An TransitionEffect object.
*/
protected _getTransitionEffect(): TransitionEffect {
return (
this.liveConfig?.transition_effect ??
frigateCardConfigDefaults.live.transition_effect
);
}
protected _getSelectedCameraIndex(): number {
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (!cameraIDs || !this.view) {
return 0;
}
return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera));
}
/**
* Get the Embla options to use.
* @returns An EmblaOptionsType object or undefined for no options.
*/
protected _getOptions(): EmblaOptionsType {
return {
draggable: this.liveConfig?.draggable,
loop: true,
};
}
/**
* Get the Embla plugins to use.
* @returns A list of EmblaOptionsTypes.
*/
protected _getPlugins(): EmblaCarouselPlugins {
const cameras = this.cameraManager?.getStore().getVisibleCameraIDs();
return [
// Only enable wheel plugin if there is more than one camera.
...(cameras && cameras.size > 1
? [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
// gestures as scrolling for the carousel.
forceWheelAxis: 'y',
}),
]
: []),
Lazyload({
...(this.liveConfig?.lazy_load && {
lazyLoadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('load', index, slide),
}),
lazyUnloadCondition: this.liveConfig?.lazy_unload,
lazyUnloadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('unload', index, slide),
}),
AutoMediaPlugin({
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
...(this.liveConfig?.auto_play && {
autoPlayCondition: this.liveConfig.auto_play,
}),
...(this.liveConfig?.auto_pause && {
autoPauseCondition: this.liveConfig.auto_pause,
}),
...(this.liveConfig?.auto_mute && {
autoMuteCondition: this.liveConfig.auto_mute,
}),
...(this.liveConfig?.auto_unmute && {
autoUnmuteCondition: this.liveConfig.auto_unmute,
}),
}),
];
}
/**
* Returns the number of slides to lazily load. 0 means all slides are lazy
* loaded, 1 means that 1 slide on each side of the currently selected slide
* should lazy load, etc. `null` means lazy loading is disabled and everything
* should load simultaneously.
* @returns
*/
protected _getLazyLoadCount(): number | null {
// Defaults to fully-lazy loading.
return this.liveConfig?.lazy_load === false ? null : 0;
}
/**
* Get slides to include in the render.
* @returns The slides to include in the render and an index keyed by camera
* name to slide number.
*/
protected _getSlides(): [TemplateResult[], Record<string, number>] {
const visibleCameras = this.cameraManager?.getStore().getVisibleCameras();
if (!visibleCameras) {
return [[], {}];
}
const slides: TemplateResult[] = [];
const cameraToSlide: Record<string, number> = {};
for (const [cameraID, cameraConfig] of visibleCameras) {
const liveCameraID =
this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
const liveCameraConfig =
cameraID === liveCameraID
? cameraConfig
: this.cameraManager?.getStore().getCameraConfig(liveCameraID);
const slide = liveCameraConfig
? this._renderLive(liveCameraID, liveCameraConfig, slides.length)
: null;
if (slide) {
cameraToSlide[cameraID] = slides.length;
slides.push(slide);
}
}
return [slides, cameraToSlide];
}
/**
* Handle the user selecting a new slide in the carousel.
*/
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]);
}
}
protected _setViewCameraID(cameraID?: string | null): void {
if (cameraID) {
this.view
?.evolve({
camera: cameraID,
// Reset the query and query results.
query: null,
queryResults: null,
})
// Don't yet fetch thumbnails (they will be fetched when the carousel
// settles).
.mergeInContext({ thumbnails: { fetch: false } })
.dispatchChangeEvent(this);
}
}
/**
* Lazy load a slide.
* @param _index The slide number to lazy load.
* @param slide The slide to lazy load.
*/
protected _lazyloadOrUnloadSlide(
action: 'load' | 'unload',
_index: number,
slide: Element,
): void {
if (slide instanceof HTMLSlotElement) {
slide = slide.assignedElements({ flatten: true })[0];
}
const liveProvider = slide?.querySelector(
FRIGATE_CARD_LIVE_PROVIDER,
) as FrigateCardLiveProvider | null;
if (liveProvider) {
liveProvider.disabled = action !== 'load';
}
}
protected _renderLive(
cameraID: string,
cameraConfig: CameraConfig,
slideIndex: number,
): TemplateResult | void {
if (!this.liveConfig || !this.hass || !this.cameraManager) {
return;
}
// The conditionState object contains the currently live camera, which (in
// the carousel for example) is not necessarily the live camera this
// <frigate-card-live-provider> is rendering right now.
const conditionState = {
...this.conditionState,
camera: cameraID,
};
const config = getOverriddenConfig(
this.liveConfig,
this.liveOverrides,
conditionState,
) as LiveConfig;
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
return html`
<div class="embla__slide">
<frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load}
.cameraConfig=${cameraConfig}
.cameraEndpoints=${guard(
[this.cameraManager, cameraID],
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
)}
.label=${cameraMetadata?.title ?? ''}
.liveConfig=${config}
.hass=${this.hass}
.cardWideConfig=${this.cardWideConfig}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
wrapMediaLoadedEventForCarousel(slideIndex, ev);
}}
@frigate-card:media:unloaded=${(ev: CustomEvent<void>) => {
wrapMediaUnloadedEventForCarousel(slideIndex, ev);
}}
>
</frigate-card-live-provider>
</div>
`;
}
protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (!cameras || !this.view || !this.hass) {
return [null, null];
}
const keys = Array.from(cameras.keys());
const currentIndex = keys.indexOf(this.view.camera);
if (currentIndex < 0 || cameras.size <= 1) {
return [null, null];
}
return [
keys[currentIndex > 0 ? currentIndex - 1 : cameras.size - 1],
keys[currentIndex + 1 < cameras.size ? currentIndex + 1 : 0],
];
}
/**
* Render the element.
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void {
if (!this.liveConfig || !this.view || !this.hass || !this.cameraManager) {
return;
}
const [slides, cameraToSlide] = this._getSlides();
this._cameraToSlide = cameraToSlide;
if (!slides.length) {
return;
}
const config = getOverriddenConfig(
this.liveConfig,
this.liveOverrides,
this.conditionState,
) as LiveConfig;
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
const overrideCameraID = (cameraID: string): string => {
return this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
};
const cameraMetadataPrevious = prevID
? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(prevID))
: null;
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
this.hass,
overrideCameraID(this.view.camera),
);
const cameraMetadataNext = nextID
? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID))
: null;
// Notes on the below:
// - guard() is used to avoid reseting the carousel unless the
// options/plugins actually change.
// - the 'carousel:settle' event is listened for (instead of
// 'carousel:select') to only trigger the view change (which subsequently
// fetches thumbnails) after the carousel has stopped moving. This gives a
// much smoother carousel experience since network fetches are not at the
// same time as carousel movement (at a cost of fetching thumbnails a
// little later).
return html`
<frigate-card-media-carousel
${ref(this._refMediaCarousel)}
.carouselOptions=${guard(
[this.cameraManager, this.liveConfig],
this._getOptions.bind(this),
)}
.carouselPlugins=${guard(
[this.cameraManager, this.liveConfig],
this._getPlugins.bind(this),
) as EmblaCarouselPlugins}
.label="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
.titlePopupConfig=${config.controls.title}
.selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:carousel:settle=${() => {
// Fetch the thumbnails after the carousel has settled.
dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } });
}}
>
<frigate-card-next-previous-control
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${config.controls.next_previous}
.label=${cameraMetadataPrevious?.title ?? ''}
.icon=${cameraMetadataPrevious?.icon}
?disabled=${prevID === null}
@click=${(ev) => {
this._setViewCameraID(prevID);
stopEventFromActivatingCardWideActions(ev);
}}
>
</frigate-card-next-previous-control>
${slides}
<frigate-card-next-previous-control
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${config.controls.next_previous}
.label=${cameraMetadataNext?.title ?? ''}
.icon=${cameraMetadataNext?.icon}
?disabled=${nextID === null}
@click=${(ev) => {
this._setViewCameraID(nextID);
stopEventFromActivatingCardWideActions(ev);
}}
>
</frigate-card-next-previous-control>
</frigate-card-media-carousel>
`;
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveCarouselStyle);
}
}
@customElement(FRIGATE_CARD_LIVE_PROVIDER)
export class FrigateCardLiveProvider
extends LitElement
implements FrigateCardMediaPlayer
{
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
public cameraEndpoints?: CameraEndpoints;
@property({ attribute: false })
public liveConfig?: LiveConfig;
// Whether or not to disable this entity. If `true`, no contents are rendered
// until this attribute is set to `false` (this is useful for lazy loading).
@property({ attribute: true, type: Boolean })
public disabled = false;
// Label that is used for ARIA support and as tooltip.
@property({ attribute: false })
public label = '';
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@state()
protected _isVideoMediaLoaded = false;
protected _refProvider: Ref<LitElement & FrigateCardMediaPlayer> = createRef();
// A note on dynamic imports:
//
// We gather the dynamic live provider import promises and do not consider the
// update of the element complete until these imports have returned. Without
// this behavior calls to the media methods (e.g. `mute()`) may throw if the
// underlying code is not yet loaded.
//
// Test case: A card with a non-live view, but live pre-loaded, attempts to
// call mute() when the <frigate-card-live> element first renders in the
// background. These calls fail without waiting for loading here.
protected _importPromises: Promise<unknown>[] = [];
public async play(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
await playMediaMutingIfNecessary(this, this._refProvider.value);
}
public async pause(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.pause();
}
public async mute(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.mute();
}
public async unmute(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.unmute();
}
public isMuted(): boolean {
return this._refProvider.value?.isMuted() ?? true;
}
public async seek(seconds: number): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.seek(seconds);
}
/**
* Get the fully resolved live provider.
* @returns A live provider (that is not 'auto').
*/
protected _getResolvedProvider(): Omit<LiveProvider, 'auto'> {
if (this.cameraConfig?.live_provider === 'auto') {
if (
this.cameraConfig?.webrtc_card?.entity ||
this.cameraConfig?.webrtc_card?.url
) {
return 'webrtc-card';
} else if (this.cameraConfig?.camera_entity) {
if (this.cardWideConfig?.performance?.profile === 'low') {
return 'image';
} else {
return 'ha';
}
} else if (this.cameraConfig?.frigate.camera_name) {
return 'jsmpeg';
}
return frigateCardConfigDefaults.cameras.live_provider;
}
return this.cameraConfig?.live_provider || 'image';
}
/**
* Determine if a camera image should be shown in lieu of the real stream
* whilst loading.
* @returns`true` if an image should be shown.
*/
protected _shouldShowImageDuringLoading(): boolean {
return (
!!this.cameraConfig?.camera_entity &&
!!this.hass &&
!!this.liveConfig?.show_image_during_load
);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this._isVideoMediaLoaded = false;
}
/**
* Record that video media is being shown.
*/
protected _videoMediaShowHandler(): void {
this._isVideoMediaLoaded = true;
}
/**
* Called before each update.
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('disabled')) {
if (this.disabled) {
this._isVideoMediaLoaded = false;
dispatchMediaUnloadedEvent(this);
}
}
if (changedProps.has('liveConfig')) {
updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout);
if (this.liveConfig?.show_image_during_load) {
this._importPromises.push(import('./live-image.js'));
}
}
if (changedProps.has('cameraConfig')) {
const provider = this._getResolvedProvider();
if (provider === 'jsmpeg') {
this._importPromises.push(import('./live-jsmpeg.js'));
} else if (provider === 'ha') {
this._importPromises.push(import('./live-ha.js'));
} else if (provider === 'webrtc-card') {
this._importPromises.push(import('./live-webrtc-card.js'));
} else if (provider === 'image') {
this._importPromises.push(import('./live-image.js'));
} else if (provider === 'go2rtc') {
this._importPromises.push(import('./live-go2rtc.js'));
}
}
}
override async getUpdateComplete(): Promise<boolean> {
// See 'A note on dynamic imports' above for explanation of why this is
// necessary.
const result = await super.getUpdateComplete();
await Promise.all(this._importPromises);
this._importPromises = [];
return result;
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) {
return;
}
// Set title and ariaLabel from the provided label property.
this.title = this.label;
this.ariaLabel = this.label;
const provider = this._getResolvedProvider();
const showImageDuringLoading =
!this._isVideoMediaLoaded && this._shouldShowImageDuringLoading();
const providerClasses = {
hidden: showImageDuringLoading,
};
return html`
${showImageDuringLoading || provider === 'image'
? html`<frigate-card-live-image
${ref(this._refProvider)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
@frigate-card:media:loaded=${(ev: Event) => {
if (provider === 'image') {
// Only count the media has loaded if the required provider is
// the image (not just the temporary image shown during
// loading).
this._videoMediaShowHandler();
} else {
ev.stopPropagation();
}
}}
>
</frigate-card-live-image>`
: html``}
${provider === 'ha'
? html` <frigate-card-live-ha
${ref(this._refProvider)}
class=${classMap(providerClasses)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-ha>`
: provider === 'go2rtc'
? html`<frigate-card-live-go2rtc
${ref(this._refProvider)}
class=${classMap(providerClasses)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-webrtc-card>`
: provider === 'webrtc-card'
? html`<frigate-card-live-webrtc-card
${ref(this._refProvider)}
class=${classMap(providerClasses)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
.cardWideConfig=${this.cardWideConfig}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-webrtc-card>`
: provider === 'jsmpeg'
? html` <frigate-card-live-jsmpeg
${ref(this._refProvider)}
class=${classMap(providerClasses)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
.cardWideConfig=${this.cardWideConfig}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-jsmpeg>`
: html``}
`;
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveProviderStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider;
'frigate-card-live-carousel': FrigateCardLiveCarousel;
'frigate-card-live': FrigateCardLive;
}
}
+54 -67
View File
@@ -12,7 +12,6 @@ import type {
} from '../types.js'; } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic'; import { dispatchFrigateCardEvent } from '../utils/basic';
import { import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent, dispatchExistingMediaLoadedInfoAsEvent,
isValidMediaLoadedInfo, isValidMediaLoadedInfo,
} from '../utils/media-info.js'; } from '../utils/media-info.js';
@@ -22,17 +21,14 @@ import './next-prev-control.js';
import './carousel.js'; import './carousel.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import { FrigateCardTitleControl } from './title-control.js'; import { FrigateCardTitleControl } from './title-control.js';
import debounce from 'lodash-es/debounce';
const getEmptyImageSrc = (width: number, height: number) => interface CarouselMediaLoadedInfo {
`data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`;
export const IMG_EMPTY = getEmptyImageSrc(16, 9);
export interface CarouselMediaLoadedInfo {
slide: number; slide: number;
mediaLoadedInfo: MediaLoadedInfo; mediaLoadedInfo: MediaLoadedInfo;
} }
export interface CarouselMediaUnloadedInfo { interface CarouselMediaUnloadedInfo {
slide: number; slide: number;
} }
@@ -84,21 +80,6 @@ export const wrapMediaLoadedEventForCarousel = (
}); });
}; };
/**
* Turn a (raw, e.g. img) media load event into a CarouselMediaLoadedInfo.
* @param slide The slide number.
* @param event The MediaShowEvent.
*/
export const wrapRawMediaLoadedEventForCarousel = (slide: number, event: Event) => {
const mediaLoadedInfo = createMediaLoadedInfo(event);
if (mediaLoadedInfo) {
dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], {
slide: slide,
mediaLoadedInfo: mediaLoadedInfo,
});
}
};
/** /**
* Turn a MediaUnloadedInfo into a CarouselMediaUnloadedInfo. * Turn a MediaUnloadedInfo into a CarouselMediaUnloadedInfo.
* @param slide The slide number. * @param slide The slide number.
@@ -125,12 +106,18 @@ export class FrigateCardMediaCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public carouselPlugins?: EmblaCarouselPlugins; public carouselPlugins?: EmblaCarouselPlugins;
@property({ attribute: false, type: Number })
public selected = 0;
@property({ attribute: true }) @property({ attribute: true })
public transitionEffect?: TransitionEffect; public transitionEffect?: TransitionEffect;
@property({ attribute: false }) @property({ attribute: false })
public label?: string; public label?: string;
@property({ attribute: false })
public logo?: string;
@property({ attribute: false }) @property({ attribute: false })
public titlePopupConfig?: TitleControlConfig; public titlePopupConfig?: TitleControlConfig;
@@ -143,14 +130,17 @@ export class FrigateCardMediaCarousel extends LitElement {
protected _boundAutoPlayHandler = this.autoPlay.bind(this); protected _boundAutoPlayHandler = this.autoPlay.bind(this);
protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this);
protected _boundAdaptContainerHeightToSlide =
this._adaptContainerHeightToSlide.bind(this);
protected _boundTitleHandler = this._titleHandler.bind(this); protected _boundTitleHandler = this._titleHandler.bind(this);
// Debounce multiple calls to adapt the container height.
protected _debouncedAdaptContainerHeightToSlide = debounce(
this._adaptContainerHeightToSlide.bind(this),
1 * 100,
{trailing: true});
// This carousel may be resized by Lovelace resizes, window resizes, // This carousel may be resized by Lovelace resizes, window resizes,
// fullscreen, etc. Always call the adaptive height handler when the size // fullscreen, etc. Always call the adaptive height handler when the size
// changes. // changes.
protected _resizeObserver: ResizeObserver;
protected _slideResizeObserver: ResizeObserver; protected _slideResizeObserver: ResizeObserver;
protected _intersectionObserver: IntersectionObserver; protected _intersectionObserver: IntersectionObserver;
@@ -161,7 +151,6 @@ export class FrigateCardMediaCarousel extends LitElement {
// Need to watch both changes in this element (e.g. caused by a window // Need to watch both changes in this element (e.g. caused by a window
// resize or fullscreen change) and changes in the selected slide itself // resize or fullscreen change) and changes in the selected slide itself
// (e.g. changing from a progress indicator to a loaded media). // (e.g. changing from a progress indicator to a loaded media).
this._resizeObserver = new ResizeObserver(this._reInitAndAdjustHeight.bind(this));
this._slideResizeObserver = new ResizeObserver( this._slideResizeObserver = new ResizeObserver(
this._reInitAndAdjustHeight.bind(this), this._reInitAndAdjustHeight.bind(this),
); );
@@ -272,10 +261,9 @@ export class FrigateCardMediaCarousel extends LitElement {
this.addEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler); this.addEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler);
this.addEventListener( this.addEventListener(
'frigate-card:media:loaded', 'frigate-card:media:loaded',
this._boundAdaptContainerHeightToSlide, this._debouncedAdaptContainerHeightToSlide,
); );
this.addEventListener('frigate-card:media:loaded', this._boundTitleHandler); this.addEventListener('frigate-card:media:loaded', this._boundTitleHandler);
this._resizeObserver.observe(this);
this._intersectionObserver.observe(this); this._intersectionObserver.observe(this);
} }
@@ -287,10 +275,9 @@ export class FrigateCardMediaCarousel extends LitElement {
this.removeEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler); this.removeEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler);
this.removeEventListener( this.removeEventListener(
'frigate-card:media:loaded', 'frigate-card:media:loaded',
this._boundAdaptContainerHeightToSlide, this._debouncedAdaptContainerHeightToSlide,
); );
this.removeEventListener('frigate-card:media:loaded', this._boundTitleHandler); this.removeEventListener('frigate-card:media:loaded', this._boundTitleHandler);
this._resizeObserver.disconnect();
this._intersectionObserver.disconnect(); this._intersectionObserver.disconnect();
this._mediaLoadedInfo = {}; this._mediaLoadedInfo = {};
@@ -302,7 +289,7 @@ export class FrigateCardMediaCarousel extends LitElement {
*/ */
protected _reInitAndAdjustHeight(): void { protected _reInitAndAdjustHeight(): void {
this.frigateCardCarousel()?.carouselReInitWhenSafe(); this.frigateCardCarousel()?.carouselReInitWhenSafe();
this._adaptContainerHeightToSlide(); this._debouncedAdaptContainerHeightToSlide();
} }
/** /**
@@ -331,36 +318,25 @@ export class FrigateCardMediaCarousel extends LitElement {
* actually the media load/show that will change the dimensions, and that is * actually the media load/show that will change the dimensions, and that is
* async from carousel actions (e.g. lazy-loaded media). * async from carousel actions (e.g. lazy-loaded media).
* *
* This component does not use the stock Embla auto-height plugin as it * This component does not use the stock Embla auto-height plugin as that
* resizes the container on selection rather than media load. * resizes the container only on selection rather than media load.
*/ */
protected _adaptContainerHeightToSlide(): void { protected _adaptContainerHeightToSlide(): void {
const adaptCarouselHeight = (): void => { const selected = this.frigateCardCarousel()?.getCarouselSelected();
const selected = this.frigateCardCarousel()?.getCarouselSelected(); if (selected) {
if (selected) { this.style.removeProperty('max-height');
this.style.removeProperty('max-height'); const height = selected.element.getBoundingClientRect().height;
const height = selected.element.getBoundingClientRect().height; if (height !== undefined && height > 0) {
if (height !== undefined && height > 0) { this.style.maxHeight = `${height}px`;
this.style.maxHeight = `${height}px`;
}
} }
}; }
// Hack: This method attempts to measure the height of the selected slide in
// order to set the overall carousel height to match. This method is
// triggered from `frigate-card:media:loaded` events, which are usually in
// turn triggered from media/metadata load events from media players.
// Sufficient time needs to be allowed after these metadata load events to
// allow the browser to repaint the element heights, so that we can get the
// right values here. requestAnimationFrame() works well for this.
window.requestAnimationFrame(adaptCarouselHeight);
} }
/** /**
* Fire a media show event when a slide is selected. * Fire a media show event when a slide is selected.
*/ */
protected _dispatchMediaLoadedInfo(): void { protected _dispatchMediaLoadedInfo(selected: CarouselSelect): void {
const slideIndex = this.frigateCardCarousel()?.getCarouselSelected()?.index; const slideIndex = selected.index;
if (slideIndex !== undefined && slideIndex in this._mediaLoadedInfo) { if (slideIndex !== undefined && slideIndex in this._mediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(this, this._mediaLoadedInfo[slideIndex]); dispatchExistingMediaLoadedInfoAsEvent(this, this._mediaLoadedInfo[slideIndex]);
} }
@@ -404,26 +380,36 @@ export class FrigateCardMediaCarousel extends LitElement {
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const selectSlide = (ev: CustomEvent<CarouselSelect>): void => {
this._slideResizeObserver.disconnect();
const parent = this.getRootNode();
if (parent && parent instanceof ShadowRoot) {
this._slideResizeObserver.observe(parent.host);
}
const selected = ev.detail;
this._slideResizeObserver.observe(selected.element);
// Pass up the media-carousel select event first to allow parents to
// initialize/reset before the media info is dispatched.
dispatchFrigateCardEvent<CarouselSelect>(
this,
'media-carousel:select',
selected,
);
// Dispatch media info.
this._dispatchMediaLoadedInfo(selected);
}
return html` <frigate-card-carousel return html` <frigate-card-carousel
${ref(this._refCarousel)} ${ref(this._refCarousel)}
.selected=${this.selected ?? 0}
.carouselOptions=${this.carouselOptions} .carouselOptions=${this.carouselOptions}
.carouselPlugins=${this.carouselPlugins} .carouselPlugins=${this.carouselPlugins}
transitionEffect=${ifDefined(this.transitionEffect)} transitionEffect=${ifDefined(this.transitionEffect)}
@frigate-card:carousel:init=${this._dispatchMediaLoadedInfo.bind(this)}
@frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelect>) => { @frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelect>) => {
this._slideResizeObserver.disconnect(); selectSlide(ev);
this._slideResizeObserver.observe(ev.detail.element);
// Pass up the media-carousel select event first to allow parents to
// initialize/reset before the media info is dispatched.
dispatchFrigateCardEvent<CarouselSelect>(
this,
'media-carousel:select',
ev.detail,
);
// Dispatch media info.
this._dispatchMediaLoadedInfo();
}} }}
@frigate-card:carousel:media:loaded=${this._storeMediaLoadedInfo.bind(this)} @frigate-card:carousel:media:loaded=${this._storeMediaLoadedInfo.bind(this)}
@frigate-card:carousel:media:unloaded=${this._removeMediaLoadedInfo.bind(this)} @frigate-card:carousel:media:unloaded=${this._removeMediaLoadedInfo.bind(this)}
@@ -437,6 +423,7 @@ export class FrigateCardMediaCarousel extends LitElement {
${ref(this._titleControlRef)} ${ref(this._titleControlRef)}
.config=${this.titlePopupConfig} .config=${this.titlePopupConfig}
.text="${this.label}" .text="${this.label}"
.logo="${this.logo}"
.fitInto=${this as HTMLElement} .fitInto=${this as HTMLElement}
> >
</frigate-card-title-control> ` </frigate-card-title-control> `
+604
View File
@@ -0,0 +1,604 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
ReactiveController,
ReactiveControllerHost,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { DateRange } from '../camera-manager/range';
import { localize } from '../localize/localize';
import mediaFilterStyle from '../scss/media-filter.scss';
import { executeMediaQueryForView } from '../utils/media-to-view.js';
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
import './select';
import { FrigateCardSelect, SelectOption, SelectValues } from './select';
import uniqWith from 'lodash-es/uniqWith';
import sub from 'date-fns/sub';
import endOfDay from 'date-fns/endOfDay';
import endOfYesterday from 'date-fns/endOfYesterday';
import endOfToday from 'date-fns/esm/endOfToday';
import startOfToday from 'date-fns/esm/startOfToday';
import startOfDay from 'date-fns/startOfDay';
import startOfYesterday from 'date-fns/startOfYesterday';
import parse from 'date-fns/parse';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { View } from '../view/view';
import { CameraManager } from '../camera-manager/manager';
import { HomeAssistant } from 'custom-card-helpers';
import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types';
import format from 'date-fns/format';
import endOfMonth from 'date-fns/endOfMonth';
import isEqual from 'lodash-es/isEqual';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import './select.js';
import orderBy from 'lodash-es/orderBy';
import { CardWideConfig } from '../types';
interface MediaFilterCoreDefaults {
cameraIDs?: string[];
favorite?: MediaFilterCoreFavoriteSelection;
mediaType?: MediaFilterMediaType;
what?: string[];
when?: string;
where?: string[];
tags?: string[];
}
export enum MediaFilterCoreFavoriteSelection {
Favorite = 'favorite',
NotFavorite = 'not-favorite',
}
export enum MediaFilterCoreWhen {
Today = 'today',
Yesterday = 'yesterday',
PastWeek = 'past-week',
PastMonth = 'past-month',
}
export enum MediaFilterMediaType {
Clips = 'clips',
Snapshots = 'snapshots',
Recordings = 'recordings',
}
@customElement('frigate-card-media-filter')
class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public view?: View;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
static elementDefinitions = {
'frigate-card-select': FrigateCardSelect,
};
protected _mediaMetadataController?: MediaMetadataController;
protected _mediaTypeOptions: SelectOption[];
protected _cameraOptions?: SelectOption[];
protected _whenOptions?: SelectOption[];
protected _favoriteOptions: SelectOption[];
protected _defaults: MediaFilterCoreDefaults | null = null;
protected _refMediaType: Ref<FrigateCardSelect> = createRef();
protected _refCamera: Ref<FrigateCardSelect> = createRef();
protected _refWhen: Ref<FrigateCardSelect> = createRef();
protected _refWhat: Ref<FrigateCardSelect> = createRef();
protected _refWhere: Ref<FrigateCardSelect> = createRef();
protected _refFavorite: Ref<FrigateCardSelect> = createRef();
protected _refTags: Ref<FrigateCardSelect> = createRef();
constructor() {
super();
this._favoriteOptions = [
{
value: MediaFilterCoreFavoriteSelection.Favorite,
label: localize('media_filter.favorite'),
},
{
value: MediaFilterCoreFavoriteSelection.NotFavorite,
label: localize('media_filter.not_favorite'),
},
];
this._mediaTypeOptions = [
{
value: MediaFilterMediaType.Clips,
label: localize('media_filter.media_types.clips'),
},
{
value: MediaFilterMediaType.Snapshots,
label: localize('media_filter.media_types.snapshots'),
},
{
value: MediaFilterMediaType.Recordings,
label: localize('media_filter.media_types.recordings'),
},
];
}
protected _stringToDateRange(input: string): DateRange {
const dates = input.split(',');
return {
start: parse(dates[0], 'yyyy-MM-dd', new Date()),
end: parse(dates[1], 'yyyy-MM-dd', new Date()),
};
}
protected _dateRangeToString(when: DateRange): string {
return `${formatDate(when.start)},${formatDate(when.end)}`;
}
protected _getWhen(): DateRange | null {
const value = this._refWhen.value?.value;
if (!value || Array.isArray(value)) {
return null;
}
const now = new Date();
switch (value) {
case MediaFilterCoreWhen.Today:
return { start: startOfToday(), end: endOfToday() };
case MediaFilterCoreWhen.Yesterday:
return { start: startOfYesterday(), end: endOfYesterday() };
case MediaFilterCoreWhen.PastWeek:
return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) };
case MediaFilterCoreWhen.PastMonth:
return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) };
default:
return this._stringToDateRange(value);
}
}
protected async _valueChangedHandler(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_ev: CustomEvent<{ value: unknown }>,
): Promise<void> {
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (!this.hass || !cameras || !this.cameraManager || !this.view) {
return;
}
const getArrayValueAsSet = (val?: SelectValues): Set<string> | null => {
// The reported value may be '' if the field is clearable (i.e. the user
// can click 'x').
if (val && Array.isArray(val) && val.length && !val.includes('')) {
return new Set([...val]);
}
return null;
};
const cameraIDs =
getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(cameras.keys());
const mediaType = this._refMediaType.value?.value as
| MediaFilterMediaType
| undefined;
const when = this._getWhen();
const favorite = this._refFavorite.value?.value
? this._refFavorite.value.value === MediaFilterCoreFavoriteSelection.Favorite
: null;
// A note on views:
// - In the below, if the user selects a camera to view media for, the main
// view camera is also set to that value (e.g. a user browsing the
// gallery, chooses a different camera in the media filter, then
// subsequently chooses the live button -- they would expect the live view
// for that filtered camera not the prior camera).
// - Similarly, if the user chooses clips or snapshots, set the actual view
// to 'clips' or 'snapshots' in order to ensure the right icon is shown as
// selected in the menu.
const limit = this.cardWideConfig?.performance?.features.media_chunk_size;
if (
mediaType === MediaFilterMediaType.Clips ||
mediaType === MediaFilterMediaType.Snapshots
) {
const where = getArrayValueAsSet(this._refWhere.value?.value);
const what = getArrayValueAsSet(this._refWhat.value?.value);
const tags = getArrayValueAsSet(this._refTags.value?.value);
const queries = new EventMediaQueries([
{
type: QueryType.Event,
cameraIDs: cameraIDs,
...(tags && { tags: tags }),
...(what && { what: what }),
...(where && { where: where }),
...(favorite !== null && { favorite: favorite }),
...(when && { start: when.start, end: when.end }),
...(limit && { limit: limit }),
...(mediaType === MediaFilterMediaType.Clips && { hasClip: true }),
...(mediaType === MediaFilterMediaType.Snapshots && {
hasSnapshot: true,
}),
},
]);
(
await executeMediaQueryForView(
this,
this.hass,
this.cameraManager,
this.view,
queries,
{
// See 'A note on views' above for these two arguments.
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
},
)
)?.dispatchChangeEvent(this);
} else if (mediaType === MediaFilterMediaType.Recordings) {
const queries = new RecordingMediaQueries([
{
type: QueryType.Recording,
cameraIDs: cameraIDs,
...(limit && { limit: limit }),
...(when && { start: when.start, end: when.end }),
},
]);
(
await executeMediaQueryForView(
this,
this.hass,
this.cameraManager,
this.view,
queries,
{
// See 'A note on views' above for these two arguments.
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
targetView: 'recordings',
},
)
)?.dispatchChangeEvent(this);
}
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameraManager')) {
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (cameras) {
this._cameraOptions = Array.from(cameras.keys()).map((cameraID) => ({
value: cameraID,
label: this.hass
? this.cameraManager?.getCameraMetadata(this.hass, cameraID)?.title ?? ''
: '',
}));
}
}
if (changedProps.has('cameraManager') && this.hass && this.cameraManager) {
this._mediaMetadataController = new MediaMetadataController(
this,
this.hass,
this.cameraManager,
);
}
// Relative time based options are not pre-computed here to ensure relative
// dates (e.g. 'today') are always calculated when activated not when
// rendered.
this._whenOptions = [
{
value: MediaFilterCoreWhen.Today,
label: localize('media_filter.whens.today'),
},
{
value: MediaFilterCoreWhen.Yesterday,
label: localize('media_filter.whens.yesterday'),
},
{
value: MediaFilterCoreWhen.PastWeek,
label: localize('media_filter.whens.past_week'),
},
{
value: MediaFilterCoreWhen.PastMonth,
label: localize('media_filter.whens.past_month'),
},
...(this._mediaMetadataController?.whenOptions ?? []),
];
if (changedProps.has('view')) {
const newDefaults = this._getDefaultsFromView();
if (!isEqual(newDefaults, this._defaults)) {
this._defaults = newDefaults;
}
}
}
protected _getDefaultsFromView(): MediaFilterCoreDefaults | null {
const queries = this.view?.query?.getQueries();
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (!this.view || !queries || !cameras) {
return null;
}
let mediaType: MediaFilterMediaType | undefined;
let cameraIDs: string[] | undefined;
let what: string[] | undefined;
let where: string[] | undefined;
let favorite: MediaFilterCoreFavoriteSelection | undefined;
let tags: string[] | undefined;
const cameraIDSets = uniqWith(
queries.map((query: DataQuery) => query.cameraIDs),
isEqual,
);
// Special note: If all visible cameras are selected, this is the same as no
// selector at all.
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, cameras)) {
cameraIDs = [...queries[0].cameraIDs];
}
const favoriteValues = uniqWith(
queries.map((query) => query.favorite),
isEqual,
);
if (favoriteValues.length === 1 && queries[0].favorite !== undefined) {
favorite = queries[0].favorite
? MediaFilterCoreFavoriteSelection.Favorite
: MediaFilterCoreFavoriteSelection.NotFavorite;
}
if (MediaQueriesClassifier.areEventQueries(this.view.query)) {
const queries = this.view.query.getQueries();
if (!queries) {
return null;
}
const hasClips = uniqWith(
queries.map((query) => query.hasClip),
isEqual,
);
const hasSnapshots = uniqWith(
queries.map((query) => query.hasSnapshot),
isEqual,
);
if (hasClips.length === 1 && hasSnapshots.length === 1) {
mediaType = !!hasClips[0]
? MediaFilterMediaType.Clips
: !!hasSnapshots[0]
? MediaFilterMediaType.Snapshots
: undefined;
}
const whatSets = uniqWith(
queries.map((query) => query.what),
isEqual,
);
if (whatSets.length === 1 && queries[0].what?.size) {
what = [...queries[0].what];
}
const whereSets = uniqWith(
queries.map((query) => query.where),
isEqual,
);
if (whereSets.length === 1 && queries[0].where?.size) {
where = [...queries[0].where];
}
const tagsSets = uniqWith(
queries.map((query) => query.tags),
isEqual,
);
if (tagsSets.length === 1 && queries[0].tags?.size) {
tags = [...queries[0].tags];
}
} else if (MediaQueriesClassifier.areRecordingQueries(this.view.query)) {
mediaType = MediaFilterMediaType.Recordings;
}
return {
...(mediaType && { mediaType: mediaType }),
...(cameraIDs && { cameraIDs: cameraIDs }),
...(what && { what: what }),
...(where && { where: where }),
...(favorite !== undefined && { favorite: favorite }),
...(tags && { tags: tags })
};
}
protected render(): TemplateResult | void {
if (!this._mediaMetadataController) {
return;
}
const areEvents = !!(
this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query)
);
const areRecordings = !!(
this.view?.query && MediaQueriesClassifier.areRecordingQueries(this.view.query)
);
const managerCapabilities = this.cameraManager?.getAggregateCameraCapabilities();
// Which media controls are shown depends on the view.
const showFavoriteControl = areEvents
? !!managerCapabilities?.canFavoriteEvents
: areRecordings
? !!managerCapabilities?.canFavoriteRecordings
: false;
return html` <frigate-card-select
${ref(this._refMediaType)}
label=${localize('media_filter.media_type')}
placeholder=${localize('media_filter.select_media_type')}
.options=${this._mediaTypeOptions}
.value=${this._defaults?.mediaType}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>
<frigate-card-select
${ref(this._refWhen)}
.label=${localize('media_filter.when')}
placeholder=${localize('media_filter.select_when')}
.options=${this._whenOptions}
.value=${this._defaults?.when}
clearable
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>
<frigate-card-select
${ref(this._refCamera)}
.label=${localize('media_filter.camera')}
placeholder=${localize('media_filter.select_camera')}
.options=${this._cameraOptions}
.value=${this._defaults?.cameraIDs}
clearable
multiple
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>
${areEvents && this._mediaMetadataController.whatOptions.length
? html` <frigate-card-select
${ref(this._refWhat)}
label=${localize('media_filter.what')}
placeholder=${localize('media_filter.select_what')}
clearable
multiple
.options=${this._mediaMetadataController.whatOptions}
.value=${this._defaults?.what}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>`
: ''}
${areEvents && this._mediaMetadataController.tagsOptions.length
? html` <frigate-card-select
${ref(this._refTags)}
label=${localize('media_filter.tag')}
placeholder=${localize('media_filter.select_tag')}
clearable
multiple
.options=${this._mediaMetadataController.tagsOptions}
.value=${this._defaults?.tags}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>`
: ''}
${areEvents && this._mediaMetadataController.whereOptions.length
? html` <frigate-card-select
${ref(this._refWhere)}
label=${localize('media_filter.where')}
placeholder=${localize('media_filter.select_where')}
clearable
multiple
.options=${this._mediaMetadataController.whereOptions}
.value=${this._defaults?.where}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>`
: ''}
${showFavoriteControl
? html`
<frigate-card-select
${ref(this._refFavorite)}
label=${localize('media_filter.favorite')}
placeholder=${localize('media_filter.select_favorite')}
.options=${this._favoriteOptions}
.value=${this._defaults?.favorite}
clearable
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
>
</frigate-card-select>
`
: ''}`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(mediaFilterStyle);
}
}
export class MediaMetadataController implements ReactiveController {
protected _host: ReactiveControllerHost;
protected _hass: HomeAssistant;
protected _cameraManager: CameraManager;
public tagsOptions: SelectOption[] = [];
public whenOptions: SelectOption[] = [];
public whatOptions: SelectOption[] = [];
public whereOptions: SelectOption[] = [];
constructor(
host: ReactiveControllerHost,
hass: HomeAssistant,
cameraManager: CameraManager,
) {
this._host = host;
this._hass = hass;
this._cameraManager = cameraManager;
host.addController(this);
}
protected _dateRangeToString(when: DateRange): string {
return `${formatDate(when.start)},${formatDate(when.end)}`;
}
async hostConnected() {
let metadata: MediaMetadata | null;
try {
metadata = await this._cameraManager.getMediaMetadata(this._hass);
} catch (e) {
errorToConsole(e as Error);
return;
}
if (!metadata) {
return;
}
if (metadata.what) {
this.whatOptions = [...metadata.what]
.sort()
.map((what) => ({ value: what, label: prettifyTitle(what) }));
}
if (metadata.where) {
this.whereOptions = [...metadata.where]
.sort()
.map((where) => ({ value: where, label: prettifyTitle(where) }));
}
if (metadata.tags) {
this.tagsOptions = [...metadata.tags]
.sort()
.map((tag) => ({ value: tag, label: prettifyTitle(tag) }));
}
if (metadata.days) {
const yearMonths: Set<string> = new Set();
[...metadata.days].forEach((day) => {
// An efficient conversion: "2023-01-26" -> "2023-01"
yearMonths.add(day.substring(0, 7));
});
const monthStarts: Date[] = [];
yearMonths.forEach((yearMonth) => {
monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date()));
});
this.whenOptions = orderBy(monthStarts, (date) => date.getTime(), 'desc').map(
(monthStart) => ({
label: format(monthStart, 'MMMM yyyy'),
value: this._dateRangeToString({
start: monthStart,
end: endOfMonth(monthStart),
}),
}),
);
}
this._host.requestUpdate();
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-media-filter': FrigateCardMediaFilter;
}
}
+19 -14
View File
@@ -5,7 +5,7 @@ import {
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
@@ -19,17 +19,18 @@ import type {
MenuButton, MenuButton,
MenuConfig, MenuConfig,
MenuItem, MenuItem,
StateParameters StateParameters,
} from '../types.js'; } from '../types.js';
import { import {
convertActionToFrigateCardCustomAction, convertActionToFrigateCardCustomAction,
frigateCardHandleActionConfig, frigateCardHandleActionConfig,
frigateCardHasAction, frigateCardHasAction,
getActionConfigGivenAction getActionConfigGivenAction,
} from '../utils/action.js'; } from '../utils/action.js';
import { FRIGATE_ICON_SVG_PATH } from '../utils/frigate.js'; import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
import { refreshDynamicStateParameters } from '../utils/ha'; import { refreshDynamicStateParameters } from '../utils/ha';
import './submenu.js'; import './submenu.js';
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
@@ -64,6 +65,9 @@ export class FrigateCardMenu extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public buttons: MenuButton[] = []; public buttons: MenuButton[] = [];
@property({ attribute: false })
public entityRegistryManager?: EntityRegistryManager;
/** /**
* Determine if a given menu configuration is a hiding menu. * Determine if a given menu configuration is a hiding menu.
* @param menuConfig The menu configuration. * @param menuConfig The menu configuration.
@@ -226,9 +230,6 @@ export class FrigateCardMenu extends LitElement {
* @returns A rendered template or void. * @returns A rendered template or void.
*/ */
protected _renderButton(button: MenuButton): TemplateResult | void { protected _renderButton(button: MenuButton): TemplateResult | void {
if (button.enabled === false) {
return;
}
if (button.type === 'custom:frigate-card-menu-submenu') { if (button.type === 'custom:frigate-card-menu-submenu') {
return html` <frigate-card-submenu return html` <frigate-card-submenu
.hass=${this.hass} .hass=${this.hass}
@@ -240,12 +241,13 @@ export class FrigateCardMenu extends LitElement {
return html` <frigate-card-submenu-select return html` <frigate-card-submenu-select
.hass=${this.hass} .hass=${this.hass}
.submenuSelect=${button} .submenuSelect=${button}
.entityRegistryManager=${this.entityRegistryManager}
@action=${this._actionHandler.bind(this)} @action=${this._actionHandler.bind(this)}
> >
</frigate-card-submenu-select>`; </frigate-card-submenu-select>`;
} }
let stateParameters: StateParameters = { ...button }; let stateParameters = { ...button } as StateParameters;
const svgPath = const svgPath =
stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : ''; stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
@@ -306,16 +308,19 @@ export class FrigateCardMenu extends LitElement {
} }
// If the hidden menu isn't expanded, only show the Frigate button. // If the hidden menu isn't expanded, only show the Frigate button.
const matchingButtons = const matchingButtons = (
style !== 'hidden' || this.expanded style !== 'hidden' || this.expanded
? this.buttons.filter( ? this.buttons.filter(
(button) => !button.alignment || button.alignment === 'matching', (button) => !button.alignment || button.alignment === 'matching',
) )
: this.buttons.filter((button) => button.icon === FRIGATE_BUTTON_MENU_ICON); : this.buttons.filter((button) => button.icon === FRIGATE_BUTTON_MENU_ICON)
).filter((button) => button.enabled !== false);
const opposingButtons = const opposingButtons =
style !== 'hidden' || this.expanded style !== 'hidden' || this.expanded
? this.buttons.filter((button) => button.alignment === 'opposing') ? this.buttons.filter(
(button) => button.alignment === 'opposing' && button.enabled !== false,
)
: []; : [];
const matchingStyle = { const matchingStyle = {
@@ -342,7 +347,7 @@ export class FrigateCardMenu extends LitElement {
} }
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"frigate-card-menu": FrigateCardMenu 'frigate-card-menu': FrigateCardMenu;
} }
} }
+37 -10
View File
@@ -1,10 +1,11 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { ClassInfo, classMap } from 'lit/directives/class-map.js';
import { ref, Ref } from 'lit/directives/ref.js';
import { TROUBLESHOOTING_URL } from '../const.js'; import { TROUBLESHOOTING_URL } from '../const.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import messageStyle from '../scss/message.scss'; import messageStyle from '../scss/message.scss';
import { FrigateCardError, Message, MessageType } from '../types.js'; import { CardWideConfig, FrigateCardError, Message, MessageType } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
@customElement('frigate-card-message') @customElement('frigate-card-message')
@@ -28,7 +29,7 @@ export class FrigateCardMessage extends LitElement {
dotdotdot: !!this.dotdotdot, dotdotdot: !!this.dotdotdot,
}; };
return html` <div class="wrapper"> return html` <div class="wrapper">
<div class="message"> <div class="message padded">
<div class="icon"> <div class="icon">
<ha-icon icon="${icon}"> </ha-icon> <ha-icon icon="${icon}"> </ha-icon>
</div> </div>
@@ -77,16 +78,25 @@ export class FrigateCardErrorMessage extends LitElement {
} }
} }
type FrigateCardProgressIndicatorSize = 'tiny' | 'small' | 'medium' | 'large';
@customElement('frigate-card-progress-indicator') @customElement('frigate-card-progress-indicator')
export class FrigateCardProgressIndicator extends LitElement { export class FrigateCardProgressIndicator extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public message: string | TemplateResult = ''; public message: string | TemplateResult = '';
@property({ attribute: false })
public animated = false;
@property({ attribute: false })
public size: FrigateCardProgressIndicatorSize = 'large';
protected render(): TemplateResult { protected render(): TemplateResult {
return html` <div class="message vertical"> return html` <div class="message vertical">
<span> ${this.animated
<ha-circular-progress active="true" size="large"> </ha-circular-progress> ? html`<ha-circular-progress active="true" size="${this.size}">
</span> </ha-circular-progress>`
: html`<ha-icon icon="mdi:timer-sand"></ha-icon>`}
${this.message ? html`<span>${this.message}</span>` : html``} ${this.message ? html`<span>${this.message}</span>` : html``}
</div>`; </div>`;
} }
@@ -112,9 +122,22 @@ export function renderMessage(message: Message): TemplateResult {
return html``; return html``;
} }
export function renderProgressIndicator(message?: string): TemplateResult { export function renderProgressIndicator(options?: {
message?: string;
cardWideConfig?: CardWideConfig;
componentRef?: Ref<HTMLElement>;
classes?: ClassInfo;
size?: FrigateCardProgressIndicatorSize;
}): TemplateResult {
return html` return html`
<frigate-card-progress-indicator .message=${message || ''}> <frigate-card-progress-indicator
class="${classMap(options?.classes ?? {})}"
.size=${options?.size}
${options?.componentRef ? ref(options.componentRef) : ''}
.message=${options?.message || ''}
.animated=${options?.cardWideConfig?.performance?.features
.animated_progress_indicator ?? true}
>
</frigate-card-progress-indicator> </frigate-card-progress-indicator>
`; `;
} }
@@ -167,9 +190,13 @@ export function dispatchErrorMessageEvent(
*/ */
export function dispatchFrigateCardErrorEvent( export function dispatchFrigateCardErrorEvent(
element: EventTarget, element: EventTarget,
error: FrigateCardError, error: unknown,
): void { ): void {
dispatchErrorMessageEvent(element, error.message, { context: error.context }); if (error instanceof Error) {
dispatchErrorMessageEvent(element, error.message, {
...(error instanceof FrigateCardError && { context: error.context }),
});
}
} }
declare global { declare global {
+5 -5
View File
@@ -52,9 +52,9 @@ export class FrigateCardNextPreviousControl extends LitElement {
const classes = { const classes = {
controls: true, controls: true,
previous: this.direction == 'previous', previous: this.direction === 'previous',
next: this.direction == 'next', next: this.direction === 'next',
thumbnails: this._controlConfig.style == 'thumbnails', thumbnails: this._controlConfig.style === 'thumbnails',
icons: ['chevrons', 'icons'].includes(this._controlConfig.style), icons: ['chevrons', 'icons'].includes(this._controlConfig.style),
button: ['chevrons', 'icons'].includes(this._controlConfig.style), button: ['chevrons', 'icons'].includes(this._controlConfig.style),
}; };
@@ -62,7 +62,7 @@ export class FrigateCardNextPreviousControl extends LitElement {
if (['chevrons', 'icons'].includes(this._controlConfig.style)) { if (['chevrons', 'icons'].includes(this._controlConfig.style)) {
let icon: string; let icon: string;
if (this._controlConfig.style === 'chevrons') { if (this._controlConfig.style === 'chevrons') {
icon = this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right'; icon = this.direction === 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right';
} else { } else {
if (!this.icon) { if (!this.icon) {
return html``; return html``;
@@ -91,7 +91,7 @@ export class FrigateCardNextPreviousControl extends LitElement {
aria-label="${this.label}" aria-label="${this.label}"
/>` />`
: html``, : html``,
() => html`<div class=${classMap(classes)}></div>`, { inProgressFunc: () => html`<div class=${classMap(classes)}></div>` },
); );
} }
+89
View File
@@ -0,0 +1,89 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import selectStyle from '../scss/select.scss';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic';
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
import { grSelectElements } from '../scoped-elements/gr-select';
import isEqual from 'lodash-es/isEqual';
import '../scoped-elements/gr-select';
export interface SelectOption {
label: string;
value: string;
}
export type SelectValues = string | string[];
type SelectElement = HTMLElement & {
value: SelectValues;
};
export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
@property({ attribute: false, hasChanged: contentsChanged })
public options?: SelectOption[];
@property({ attribute: false, hasChanged: contentsChanged })
public value?: SelectValues;
@property({ attribute: true })
public label?: string;
@property({ attribute: true })
public placeholder?: string;
@property({ attribute: true, type: Boolean })
public multiple?: boolean = false;
@property({ attribute: true, type: Boolean })
public clearable?: boolean = false;
protected _previouslyReportedValue?: SelectValues;
protected _refSelect: Ref<SelectElement> = createRef();
static elementDefinitions = {
...grSelectElements,
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _valueChangedHandler(_ev: CustomEvent<{ value: unknown }>): void {
const value: SelectValues | undefined = this._refSelect.value?.value;
// The underlying gr-select element is very sensitive and occasionally fires
// the change event even if the value has not actually changed. Prevent that
// from propagating upwards.
if (value !== undefined && !isEqual(this.value, value)) {
this.value = value;
dispatchFrigateCardEvent(this, 'select:change', value);
}
}
protected render(): TemplateResult | void {
return html` <gr-select
${ref(this._refSelect)}
label=${this.label ?? ''}
placeholder=${this.placeholder ?? ''}
size="small"
?multiple=${this.multiple}
?clearable=${this.clearable}
.value=${this.value ?? this._refSelect.value?.value ?? []}
@gr-change=${this._valueChangedHandler.bind(this)}
>
${this.options?.map(
(option) =>
html`<gr-menu-item value="${option.value ?? ''}"
>${option.label}</gr-menu-item
>`,
)}
</gr-select>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(selectStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-select': FrigateCardSelect;
}
}
+53 -19
View File
@@ -7,9 +7,9 @@ import {
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js'; import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js'; import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../action-handler-directive.js'; import { actionHandler } from '../action-handler-directive.js';
import submenuStyle from '../scss/submenu.scss'; import submenuStyle from '../scss/submenu.scss';
import { import {
@@ -23,6 +23,8 @@ import {
stopEventFromActivatingCardWideActions, stopEventFromActivatingCardWideActions,
} from '../utils/action.js'; } from '../utils/action.js';
import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha'; import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha';
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
import { getEntityStateTranslation } from '../utils/ha/entity-state-translation.js';
import { domainIcon } from '../utils/icons/domain-icon.js'; import { domainIcon } from '../utils/icons/domain-icon.js';
@customElement('frigate-card-submenu') @customElement('frigate-card-submenu')
@@ -37,7 +39,7 @@ export class FrigateCardSubmenu extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item }); const stateParameters = refreshDynamicStateParameters(this.hass, { ...item } as StateParameters);
const getIcon = (stateParameters: StateParameters): TemplateResult => { const getIcon = (stateParameters: StateParameters): TemplateResult => {
if (stateParameters.icon) { if (stateParameters.icon) {
return html` <ha-icon return html` <ha-icon
@@ -93,7 +95,7 @@ export class FrigateCardSubmenu extends LitElement {
@click=${(ev) => stopEventFromActivatingCardWideActions(ev)} @click=${(ev) => stopEventFromActivatingCardWideActions(ev)}
> >
<ha-icon-button <ha-icon-button
style="${styleMap(this.submenu.style || {})}" style="${styleMap(this.submenu.style as StyleInfo || {})}"
class="button" class="button"
slot="trigger" slot="trigger"
.label=${this.submenu.title || ''} .label=${this.submenu.title || ''}
@@ -127,6 +129,12 @@ export class FrigateCardSubmenuSelect extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public submenuSelect?: MenuSubmenuSelect; public submenuSelect?: MenuSubmenuSelect;
@property({ attribute: false })
public entityRegistryManager?: EntityRegistryManager;
@state()
protected _optionTitles?: Record<string, string>;
protected _generatedSubmenu?: MenuSubmenu; protected _generatedSubmenu?: MenuSubmenu;
/** /**
@@ -138,12 +146,39 @@ export class FrigateCardSubmenuSelect extends LitElement {
// No need to update the submenu unless the select entity has changed. // No need to update the submenu unless the select entity has changed.
const oldHass = changedProps.get('hass') as HomeAssistant | undefined; const oldHass = changedProps.get('hass') as HomeAssistant | undefined;
return ( return (
changedProps.size != 1 || !changedProps.has('hass') ||
!oldHass ||
!this.submenuSelect || !this.submenuSelect ||
(!!oldHass && isHassDifferent(this.hass, oldHass, [this.submenuSelect.entity])) isHassDifferent(this.hass, oldHass, [this.submenuSelect.entity])
); );
} }
protected async _refreshOptionTitles(): Promise<void> {
if (!this.hass || !this.submenuSelect) {
return;
}
const entityID = this.submenuSelect.entity;
const stateObj = this.hass.states[entityID];
const options = stateObj?.attributes?.options;
const entity =
(await this.entityRegistryManager?.getEntity(this.hass, entityID)) ?? null;
const optionTitles = {};
for (const option of options) {
const title = getEntityStateTranslation(this.hass, entityID, {
...(entity && { entity: entity }),
state: option,
});
if (title) {
optionTitles[option] = title;
}
}
// This will cause a re-render with the updated title if it is
// different.
this._optionTitles = optionTitles;
}
/** /**
* Called when the render function will be called. * Called when the render function will be called.
*/ */
@@ -151,8 +186,13 @@ export class FrigateCardSubmenuSelect extends LitElement {
if (!this.submenuSelect || !this.hass) { if (!this.submenuSelect || !this.hass) {
return; return;
} }
const entity = this.submenuSelect.entity;
const stateObj = this.hass.states[entity]; if (!this._optionTitles) {
this._refreshOptionTitles();
}
const entityID = this.submenuSelect.entity;
const stateObj = this.hass.states[entityID];
const options = stateObj?.attributes?.options; const options = stateObj?.attributes?.options;
if (!stateObj || !options) { if (!stateObj || !options) {
return; return;
@@ -165,7 +205,7 @@ export class FrigateCardSubmenuSelect extends LitElement {
icon: domainIcon('select'), icon: domainIcon('select'),
// Pull out the dynamic properties (like icon, and title) from the state. // Pull out the dynamic properties (like icon, and title) from the state.
...refreshDynamicStateParameters(this.hass, this.submenuSelect), ...refreshDynamicStateParameters(this.hass, this.submenuSelect as StateParameters),
// Override it with anything explicitly set in the submenuSelect. // Override it with anything explicitly set in the submenuSelect.
...this.submenuSelect, ...this.submenuSelect,
@@ -180,26 +220,20 @@ export class FrigateCardSubmenuSelect extends LitElement {
delete submenu['options']; delete submenu['options'];
for (const option of options) { for (const option of options) {
// If there's a device_class there may be a localized translation of the const title = this._optionTitles?.[option] ?? option;
// select title available via HASS.
const title = stateObj.attributes.device_class
? this.hass.localize(
`component.select.state.${stateObj.attributes.device_class}.${option}`,
)
: option;
submenu.items.push({ submenu.items.push({
state_color: true, state_color: true,
selected: stateObj.state === option, selected: stateObj.state === option,
enabled: true, enabled: true,
title: title || option, title: title || option,
...((entity.startsWith('select.') || entity.startsWith('input_select.')) && { ...((entityID.startsWith('select.') || entityID.startsWith('input_select.')) && {
tap_action: { tap_action: {
action: 'call-service', action: 'call-service',
service: entity.startsWith('select.') service: entityID.startsWith('select.')
? 'select.select_option' ? 'select.select_option'
: 'input_select.select_option', : 'input_select.select_option',
service_data: { service_data: {
entity_id: entity, entity_id: entityID,
option: option, option: option,
}, },
}, },
+90
View File
@@ -0,0 +1,90 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { customElement, property } from 'lit/decorators.js';
import { DrawerIcons, FrigateCardDrawer } from './drawer.js';
import './drawer.js';
import surroundBasicStyle from '../scss/surround-basic.scss';
interface FrigateCardDrawerOpen {
drawer: 'left' | 'right';
}
@customElement('frigate-card-surround-basic')
export class FrigateCardSurroundBasic extends LitElement {
@property({ attribute: false })
public drawerIcons?: {
left?: DrawerIcons;
right?: DrawerIcons;
};
protected _refDrawerLeft: Ref<FrigateCardDrawer> = createRef();
protected _refDrawerRight: Ref<FrigateCardDrawer> = createRef();
protected _boundDrawerHandler = this._drawerHandler.bind(this);
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
}
protected _drawerHandler(ev: Event) {
const drawer = (ev as CustomEvent<FrigateCardDrawerOpen>).detail.drawer;
const open = ev.type.endsWith(':open');
if (drawer === 'left' && this._refDrawerLeft.value) {
this._refDrawerLeft.value.open = open;
} else if (drawer === 'right' && this._refDrawerRight.value) {
this._refDrawerRight.value.open = open;
}
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
return html` <slot name="above"></slot>
<slot></slot>
<frigate-card-drawer
${ref(this._refDrawerLeft)}
location="left"
.icons=${this.drawerIcons?.left}
>
<slot name="left"></slot>
</frigate-card-drawer>
<frigate-card-drawer
${ref(this._refDrawerRight)}
location="right"
.icons=${this.drawerIcons?.right}
>
<slot name="right"></slot>
</frigate-card-drawer>
<slot name="below"></slot>`;
}
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(surroundBasicStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-surround-basic': FrigateCardSurroundBasic;
}
}
-194
View File
@@ -1,194 +0,0 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import surroundThumbnailsStyle from '../scss/surround.scss';
import {
BrowseMediaQueryParameters,
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
FrigateCardError,
FrigateCardView,
ThumbnailsControlConfig,
} from '../types.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import {
getFirstTrueMediaChildIndex,
multipleBrowseMediaQueryMerged,
} from '../utils/ha/browse-media';
import { View } from '../view.js';
import { dispatchFrigateCardErrorEvent } from './message.js';
import './surround.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
interface ThumbnailViewContext {
// Whetherr or not to fetch thumbnails.
fetch?: boolean;
}
declare module 'view' {
interface ViewContext {
thumbnails?: ThumbnailViewContext;
}
}
@customElement('frigate-card-surround-thumbnails')
export class FrigateCardSurround extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false, hasChanged: contentsChanged })
public config?: ThumbnailsControlConfig;
@property({ attribute: false })
public targetView?: FrigateCardView;
@property({ attribute: true, type: Boolean })
public fetch?: boolean;
@property({ attribute: false, hasChanged: contentsChanged })
public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[];
@property({ attribute: false })
public cameras?: Map<string, CameraConfig>;
/**
* Fetch thumbnail media when a target is not specified in the view (e.g. for
* the live view).
* @param param Task parameters.
* @returns
*/
protected async _fetchMedia(): Promise<void> {
if (
!this.fetch ||
!this.hass ||
!this.view ||
!this.config ||
this.config.mode === 'none' ||
this.view.target ||
!this.browseMediaParams ||
!(this.view.context?.thumbnails?.fetch ?? true)
) {
return;
}
let parent: FrigateBrowseMediaSource | null;
try {
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
} catch (e) {
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
}
if (getFirstTrueMediaChildIndex(parent) !== null) {
this.view
?.evolve({
...(this.targetView && { view: this.targetView }),
target: parent,
childIndex: null,
// Don't carry over history of this 'empty' view.
previous: null,
})
.dispatchChangeEvent(this);
}
}
/**
* Determine if a drawer is being used.
* @returns `true` if a drawer is used, `false` otherwise.
*/
protected _hasDrawer(): boolean {
return !!this.config && ['left', 'right'].includes(this.config.mode);
}
/**
* Called before each update.
*/
protected willUpdate(changedProperties: PropertyValues): void {
// Once the component will certainly update, dispatch a media request. Only
// do so if properties relevant to the request have changed (as per their
// hasChanged).
if (
['view', 'targetView', 'fetch', 'browseMediaParams'].some((prop) =>
changedProperties.has(prop),
)
) {
this._fetchMedia();
}
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.config) {
return;
}
const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => {
// The event catch/re-dispatch below protect encapsulation: Catches the
// request to view thumbnails and re-dispatches a request to open the drawer
// (if the thumbnails are in a drawer). The new event needs to be dispatched
// from the origin of the inbound event, so it can be handled by
// <frigate-card-surround> .
if (this.config && this._hasDrawer()) {
dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, {
drawer: this.config.mode,
});
}
};
return html` <frigate-card-surround
@frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')}
@frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
>
${this.config && this.config.mode !== 'none'
? html` <frigate-card-thumbnail-carousel
slot=${this.config.mode}
.hass=${this.hass}
.config=${this.config}
.view=${this.view}
.target=${this.view.target}
.selected=${this.view.childIndex}
.cameras=${this.cameras}
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
@frigate-card:thumbnail-carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
// Send the view change from the source of the tap event, so the
// view change will be caught by the handler above (to close the drawer).
this.view
?.evolve({
view: this.targetView || 'media',
target: ev.detail.target,
childIndex: ev.detail.childIndex,
context: null,
})
.dispatchChangeEvent(ev.composedPath()[0]);
}}
>
</frigate-card-thumbnail-carousel>`
: ''}
<slot></slot>
</frigate-card-surround>`;
}
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(surroundThumbnailsStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-surround-thumbnails': FrigateCardSurround;
}
}
+215 -47
View File
@@ -1,49 +1,154 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import {
import { createRef, ref, Ref } from 'lit/directives/ref.js'; CSSResultGroup,
import { customElement } from 'lit/decorators.js'; html,
LitElement,
import { FrigateCardDrawer } from './drawer.js'; PropertyValues,
TemplateResult,
import './drawer.js'; unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import surroundStyle from '../scss/surround.scss'; import surroundStyle from '../scss/surround.scss';
import {
CardWideConfig,
ClipsOrSnapshotsOrAll,
ExtendedHomeAssistant,
MiniTimelineControlConfig,
ThumbnailsControlConfig,
} from '../types.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import { CameraManager } from '../camera-manager/manager.js';
import { View } from '../view/view.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
import './surround-basic.js';
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
import { getAllDependentCameras } from '../utils/camera.js';
import type { DataQuery } from '../camera-manager/types';
interface FrigateCardDrawerOpen { interface ThumbnailViewContext {
drawer: 'left' | 'right'; // Whether or not to fetch thumbnails.
fetch?: boolean;
}
declare module 'view' {
interface ViewContext {
thumbnails?: ThumbnailViewContext;
}
} }
@customElement('frigate-card-surround') @customElement('frigate-card-surround')
export class FrigateCardSurround extends LitElement { export class FrigateCardSurround extends LitElement {
protected _refDrawerLeft: Ref<FrigateCardDrawer> = createRef(); @property({ attribute: false })
protected _refDrawerRight: Ref<FrigateCardDrawer> = createRef(); public hass?: ExtendedHomeAssistant;
protected _boundDrawerHandler = this._drawerHandler.bind(this);
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false, hasChanged: contentsChanged })
public thumbnailConfig?: ThumbnailsControlConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public timelineConfig?: MiniTimelineControlConfig;
// If fetchMedia is not specified, no fetching is done.
@property({ attribute: false, hasChanged: contentsChanged })
public fetchMedia?: ClipsOrSnapshotsOrAll;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _cameraIDsForTimeline?: Set<string>;
/** /**
* Component connected callback. * Fetch thumbnail media when a target is not specified in the view (e.g. for
* the live view).
* @param param Task parameters.
* @returns
*/ */
connectedCallback(): void { protected async _fetchMedia(): Promise<void> {
super.connectedCallback(); if (
this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler); !this.cameraManager ||
this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler); !this.cardWideConfig ||
} !this.fetchMedia ||
!this.hass ||
/** !this.view ||
* Component disconnected callback. this.view.query ||
*/ !this.thumbnailConfig ||
disconnectedCallback(): void { this.thumbnailConfig.mode === 'none' ||
super.disconnectedCallback(); !(this.view.context?.thumbnails?.fetch ?? true)
this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler); ) {
this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler); return;
}
protected _drawerHandler(ev: Event) {
const drawer = (ev as CustomEvent<FrigateCardDrawerOpen>).detail.drawer;
const open = ev.type.endsWith(':open');
if (drawer === 'left' && this._refDrawerLeft.value) {
this._refDrawerLeft.value.open = open;
} else if (drawer === 'right' && this._refDrawerRight.value) {
this._refDrawerRight.value.open = open;
} }
await changeViewToRecentEventsForCameraAndDependents(
this,
this.hass,
this.cameraManager,
this.cardWideConfig,
this.view,
{
targetView: this.view.view,
mediaType: this.fetchMedia,
select: 'latest',
},
);
}
/**
* Determine if a drawer is being used.
* @returns `true` if a drawer is used, `false` otherwise.
*/
protected _hasDrawer(): boolean {
return (
!!this.thumbnailConfig && ['left', 'right'].includes(this.thumbnailConfig.mode)
);
}
/**
* Called before each update.
*/
protected willUpdate(changedProperties: PropertyValues): void {
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
import('./timeline.js');
}
// Only reset the timeline cameraIDs when the media materially changes (and
// not on every view change, since the view will change frequently when the
// user is scrubbing video).
if (
changedProperties.has('view') &&
View.isMajorMediaChange(changedProperties.get('view'), this.view)
) {
this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined;
}
// Once the component will certainly update, dispatch a media request. Only
// do so if properties relevant to the request have changed (as per their
// hasChanged).
if (
['view', 'fetch', 'browseMediaParams'].some((prop) => changedProperties.has(prop))
) {
this._fetchMedia();
}
}
protected _getCameraIDsForTimeline(): Set<string> | null {
if (!this.view) {
return null;
}
if (this.view?.is('live')) {
return getAllDependentCameras(this.cameraManager, this.view.camera);
}
if (this.view.isViewerView()) {
return new Set(
this.view.query
?.getQueries()
?.map((query: DataQuery) => [...query.cameraIDs])
.flat(),
);
}
return null;
} }
/** /**
@@ -51,15 +156,78 @@ export class FrigateCardSurround extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
return html` <slot name="above"></slot> if (!this.hass || !this.view) {
return;
}
const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => {
// The event catch/re-dispatch below protect encapsulation: Catches the
// request to view thumbnails and re-dispatches a request to open the drawer
// (if the thumbnails are in a drawer). The new event needs to be dispatched
// from the origin of the inbound event, so it can be handled by
// <frigate-card-surround> .
if (this.thumbnailConfig && this._hasDrawer()) {
dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, {
drawer: this.thumbnailConfig.mode,
});
}
};
return html` <frigate-card-surround-basic
@frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')}
@frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
>
${this.thumbnailConfig && this.thumbnailConfig.mode !== 'none'
? html` <frigate-card-thumbnail-carousel
slot=${this.thumbnailConfig.mode}
.hass=${this.hass}
.config=${this.thumbnailConfig}
.cameraManager=${this.cameraManager}
.view=${this.view}
.selected=${this.view.queryResults?.getSelectedIndex() ?? undefined}
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
@frigate-card:thumbnail-carousel:tap=${(
ev: CustomEvent<ThumbnailCarouselTap>,
) => {
const media = ev.detail.queryResults.getSelectedResult();
if (media) {
this.view
?.evolve({
view: 'media',
queryResults: ev.detail.queryResults,
...(media.getCameraID() && { camera: media.getCameraID() }),
})
.removeContext('timeline')
// Send the view change from the source of the tap event, so
// the view change will be caught by the handler above (to
// close the drawer).
.dispatchChangeEvent(ev.composedPath()[0]);
}
}}
>
</frigate-card-thumbnail-carousel>`
: ''}
${this.timelineConfig && this.timelineConfig.mode !== 'none'
? html` <frigate-card-timeline-core
slot=${this.timelineConfig.mode}
.hass=${this.hass}
.view=${this.view}
.itemClickAction=${this.view.isViewerView() ||
!this.thumbnailConfig ||
this.thumbnailConfig?.mode === 'none'
? 'play'
: 'select'}
.cameraIDs=${this._cameraIDsForTimeline}
.mini=${true}
.timelineConfig=${this.timelineConfig}
.thumbnailConfig=${this.thumbnailConfig}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-timeline-core>`
: ''}
<slot></slot> <slot></slot>
<frigate-card-drawer ${ref(this._refDrawerLeft)} location="left"> </frigate-card-surround-basic>`;
<slot name="left"></slot>
</frigate-card-drawer>
<frigate-card-drawer ${ref(this._refDrawerRight)} location="right">
<slot name="right"></slot>
</frigate-card-drawer>
<slot name="below"></slot>`;
} }
/** /**
@@ -71,7 +239,7 @@ export class FrigateCardSurround extends LitElement {
} }
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"frigate-card-surround": FrigateCardSurround 'frigate-card-surround': FrigateCardSurround;
} }
} }
+39 -90
View File
@@ -8,29 +8,23 @@ import {
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import { import { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js';
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
ThumbnailsControlConfig,
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { isTrueMedia } from '../utils/ha/browse-media'; import { View } from '../view/view.js';
import { View } from '../view.js'; import { MediaQueriesResults } from '../view/media-queries-results';
import { FrigateCardCarousel } from './carousel.js'; import { FrigateCardCarousel } from './carousel.js';
import './thumbnail.js'; import './thumbnail.js';
import './carousel.js'; import './carousel.js';
import { ifDefined } from 'lit/directives/if-defined.js'; import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../camera-manager/manager.js';
export interface ThumbnailCarouselTap { export interface ThumbnailCarouselTap {
slideIndex: number; queryResults: MediaQueriesResults;
target: FrigateBrowseMediaSource;
childIndex: number;
} }
@customElement('frigate-card-thumbnail-carousel') @customElement('frigate-card-thumbnail-carousel')
@@ -41,13 +35,8 @@ export class FrigateCardThumbnailCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
// Use contentsChanged here to avoid the carousel rebuilding and resetting in
// front of the user, unless the contents have actually changed.
@property({ attribute: false, hasChanged: contentsChanged })
public target?: FrigateBrowseMediaSource | null;
@property({ attribute: false }) @property({ attribute: false })
public cameras?: Map<string, CameraConfig>; public cameraManager?: CameraManager;
protected _refCarousel: Ref<FrigateCardCarousel> = createRef(); protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
@@ -59,10 +48,14 @@ export class FrigateCardThumbnailCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public config?: ThumbnailsControlConfig; public config?: ThumbnailsControlConfig;
@state() @property({ attribute: false })
protected _selected: number | null = null; public selected? = 0;
protected _carouselOptions?: EmblaOptionsType = {
containScroll: 'keepSnaps',
dragFree: true,
};
protected _carouselOptions?: EmblaOptionsType;
protected _carouselPlugins: EmblaPluginType[] = [ protected _carouselPlugins: EmblaPluginType[] = [
WheelGesturesPlugin({ WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel // Whether the carousel is vertical or horizontal, interpret y-axis wheel
@@ -76,15 +69,6 @@ export class FrigateCardThumbnailCarousel extends LitElement {
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
} }
@property({ attribute: false })
set selected(selected: number | null) {
this._selected = selected;
this.style.setProperty(
'--frigate-card-carousel-thumbnail-opacity',
selected === null ? '1.0' : '0.4',
);
}
/** /**
* Handle gallery resize. * Handle gallery resize.
*/ */
@@ -108,31 +92,20 @@ export class FrigateCardThumbnailCarousel extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
} }
/**
* Get the Embla options to use.
* @returns An EmblaOptionsType object or undefined for no options.
*/
protected _getOptions(): EmblaOptionsType {
return {
containScroll: 'keepSnaps',
dragFree: true,
startIndex: this._selected ?? 0,
};
}
/** /**
* Get slides to include in the render. * Get slides to include in the render.
* @returns The slides to include in the render. * @returns The slides to include in the render.
*/ */
protected _getSlides(): TemplateResult[] { protected _getSlides(): TemplateResult[] {
if (!this.target || !this.target.children || !this.target.children.length) { if (!this.view?.query || !this.view.queryResults?.hasResults()) {
return []; return [];
} }
const slides: TemplateResult[] = []; const slides: TemplateResult[] = [];
for (let i = 0; i < this.target.children.length; ++i) { for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
const thumbnail = this._renderThumbnail(this.target, i, slides.length); const thumbnail = this._renderThumbnail(i);
if (thumbnail) { if (thumbnail) {
slides.push(thumbnail); slides[i] = thumbnail;
} }
} }
return slides; return slides;
@@ -155,28 +128,11 @@ export class FrigateCardThumbnailCarousel extends LitElement {
} }
} }
if (!this._carouselOptions) { if (changedProps.has('selected')) {
// Want to set the initial carousel options just before the first render this.style.setProperty(
// in order to get the startIndex correct in the options. It is not safe '--frigate-card-carousel-thumbnail-opacity',
// to rely on carouselScrollTo() post update, since the nested carousel this.selected === undefined ? '1.0' : '0.4',
// may not yet be actual rendered/created. );
this._carouselOptions = this._getOptions();
}
}
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has('_selected')) {
this.updateComplete.then(() => {
if (this._selected !== null) {
this._refCarousel.value?.carouselScrollTo(this._selected);
}
});
} }
} }
@@ -185,44 +141,36 @@ export class FrigateCardThumbnailCarousel extends LitElement {
* @param mediaToRender The media item to render. * @param mediaToRender The media item to render.
* @returns A template or void if the item could not be rendered. * @returns A template or void if the item could not be rendered.
*/ */
protected _renderThumbnail( protected _renderThumbnail(index: number): TemplateResult | void {
parent: FrigateBrowseMediaSource, const media = this.view?.queryResults?.getResult(index) ?? null;
childIndex: number, if (!media || !this.view) {
slideIndex: number,
): TemplateResult | void {
if (
!parent.children ||
!parent.children.length ||
!isTrueMedia(parent.children[childIndex])
) {
return; return;
} }
const classes = { const classes = {
embla__slide: true, embla__slide: true,
'slide-selected': this._selected === childIndex, 'slide-selected': this.selected === index,
}; };
const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null; const seekTarget = this.view?.context?.mediaViewer?.seek;
return html` <frigate-card-thumbnail return html` <frigate-card-thumbnail
class="${classMap(classes)}"
.cameraManager=${this.cameraManager}
.hass=${this.hass} .hass=${this.hass}
.media=${media}
.view=${this.view} .view=${this.view}
.target=${parent} .seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
.childIndex=${childIndex} ?details=${!!this.config?.show_details}
.clientID=${cameraConfig?.frigate.client_id}
?details=${this.config?.show_details}
?show_favorite_control=${this.config?.show_favorite_control} ?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control} ?show_timeline_control=${this.config?.show_timeline_control}
class="${classMap(classes)}" ?show_download_control=${this.config?.show_download_control}
@click=${(ev) => { @click=${(ev: Event) => {
if (this._refCarousel.value?.carouselClickAllowed()) { if (this.view && this.view.queryResults) {
dispatchFrigateCardEvent<ThumbnailCarouselTap>( dispatchFrigateCardEvent<ThumbnailCarouselTap>(
this, this,
'thumbnail-carousel:tap', 'thumbnail-carousel:tap',
{ {
slideIndex: slideIndex, queryResults: this.view.queryResults.clone().selectResult(index),
target: parent,
childIndex: childIndex,
}, },
); );
} }
@@ -258,6 +206,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
return html`<frigate-card-carousel return html`<frigate-card-carousel
${ref(this._refCarousel)} ${ref(this._refCarousel)}
direction=${ifDefined(this._getDirection())} direction=${ifDefined(this._getDirection())}
.selected=${this.selected ?? 0}
.carouselOptions=${this._carouselOptions} .carouselOptions=${this._carouselOptions}
.carouselPlugins=${this._carouselPlugins} .carouselPlugins=${this._carouselPlugins}
> >
+376 -184
View File
@@ -1,5 +1,12 @@
import { format, fromUnixTime } from 'date-fns'; import format from 'date-fns/format';
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import {
CSSResult,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
@@ -7,19 +14,24 @@ import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss'; import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss'; import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
import thumbnailStyle from '../scss/thumbnail.scss'; import thumbnailStyle from '../scss/thumbnail.scss';
import type {
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
FrigateEvent,
FrigateRecording,
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { errorToConsole, prettifyTitle } from '../utils/basic.js'; import {
import { retainEvent } from '../utils/frigate.js'; errorToConsole,
import { getEventDurationString } from '../utils/ha/browse-media.js'; formatDateAndTime,
getDurationString,
prettifyTitle,
} from '../utils/basic.js';
import { renderTask } from '../utils/task.js'; import { renderTask } from '../utils/task.js';
import { createFetchThumbnailTask } from '../utils/thumbnail.js'; import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.js';
import { View } from '../view.js'; import { View } from '../view/view.js';
import { Task, TaskStatus } from '@lit-labs/task';
import type { ExtendedHomeAssistant } from '../types.js';
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
import { CameraManager } from '../camera-manager/manager.js';
import { ViewMediaClassifier } from '../view/media-classifier.js';
import { downloadMedia } from '../utils/download.js';
import { dispatchFrigateCardErrorEvent } from './message.js';
// The minimum width of a thumbnail with details enabled. // The minimum width of a thumbnail with details enabled.
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
@@ -32,28 +44,80 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public hass?: ExtendedHomeAssistant; public hass?: ExtendedHomeAssistant;
protected _embedThumbnailTask = createFetchThumbnailTask( protected _embedThumbnailTask?: Task<FetchThumbnailTaskArgs, string | null>;
this,
() => this.hass, // Only load thumbnails on view in case there is a very large number of them.
() => this.thumbnail, protected _intersectionObserver: IntersectionObserver;
);
constructor() {
super();
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
}
/**
* Component connected callback.
*/
connectedCallback(): void {
this._intersectionObserver.observe(this);
super.connectedCallback();
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this._intersectionObserver.disconnect();
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('thumbnail')) {
this._embedThumbnailTask = createFetchThumbnailTask(
this,
() => this.hass,
() => this.thumbnail,
false,
);
// Reset the observer so the initial intersection handler call will set
// the visibility correctly.
this._intersectionObserver.unobserve(this);
this._intersectionObserver.observe(this);
}
}
/**
* Called when the live view intersects with the viewport.
* @param entries The IntersectionObserverEntry entries (should be only 1).
*/
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
if (
this._embedThumbnailTask?.status === TaskStatus.INITIAL &&
entries.some((entry) => entry.isIntersecting)
) {
this._embedThumbnailTask?.run();
}
}
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
return html` if (!this._embedThumbnailTask) {
${this.thumbnail return;
? renderTask( }
this, const imageOff = html`<ha-icon
this._embedThumbnailTask, icon="mdi:image-off"
(embeddedThumbnail: string | null) => title=${localize('thumbnail.no_thumbnail')}
embeddedThumbnail ></ha-icon> `;
? html`<img src="${embeddedThumbnail}" />`
: html`` return html`${this.thumbnail
) ? renderTask(
: html`<ha-icon this,
icon="mdi:image-off" this._embedThumbnailTask,
title=${localize('thumbnail.no_thumbnail')} (embeddedThumbnail: string | null) =>
></ha-icon> `} embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
`; { inProgressFunc: () => imageOff },
)
: imageOff} `;
} }
static get styles(): CSSResult { static get styles(): CSSResult {
@@ -66,6 +130,9 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public date?: Date; public date?: Date;
@property({ attribute: false })
public cameraTitle?: string;
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.date) { if (!this.date) {
return; return;
@@ -73,6 +140,7 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement {
return html` return html`
<div class="title">${format(this.date, 'HH:mm')}</div> <div class="title">${format(this.date, 'HH:mm')}</div>
<div class="subtitle">${format(this.date, 'MMM do')}</div> <div class="subtitle">${format(this.date, 'MMM do')}</div>
${this.cameraTitle ? html`<div class="camera">${this.cameraTitle}</div>` : html``}
`; `;
} }
@@ -84,27 +152,97 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement {
@customElement('frigate-card-thumbnail-details-event') @customElement('frigate-card-thumbnail-details-event')
export class FrigateCardThumbnailDetailsEvent extends LitElement { export class FrigateCardThumbnailDetailsEvent extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public event?: FrigateEvent; public media?: EventViewMedia;
@property({ attribute: false })
public seek?: Date;
@property({ attribute: false })
public cameraTitle?: string;
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.event) { if (!this.media) {
return; return;
} }
const score = (this.event.top_score * 100).toFixed(2) + '%'; const rawScore = this.media.getScore();
return html`<div class="left"> const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null;
<div class="larger">${prettifyTitle(this.event.label)}</div> const rawStartTime = this.media.getStartTime();
<div> const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
<span class="heading">${localize('event.start')}:</span>
<span>${format(fromUnixTime(this.event.start_time), 'HH:mm:ss')}</span> const rawEndTime = this.media.getEndTime();
</div> const duration =
<div> rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
<span class="heading">${localize('event.duration')}:</span> const inProgress = this.media.inProgress() ? localize('event.in_progress') : null;
<span>${getEventDurationString(this.event)}</span>
</div> const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null;
const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null;
const tags = prettifyTitle(this.media.getTags()?.join(', ')) ?? null;
const whatWithTags =
what || tags ? (what ?? '') + (what && tags ? ': ' : '') + (tags ?? '') : null;
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
return html`
${whatWithTags
? html` <div class="title">
<span title=${whatWithTags}>${whatWithTags}</span>
${score ? html`<span title="${score}">${score}</span>` : ''}
</div>`
: ``}
<div class="details">
${startTime
? html` <div>
<ha-icon
title=${localize('event.start')}
.icon=${'mdi:calendar-clock-outline'}
></ha-icon>
<span title="${startTime}">${startTime}</span>
</div>
${duration || inProgress
? html` <div>
<ha-icon
title=${localize('event.duration')}
.icon=${'mdi:clock-outline'}
></ha-icon>
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
${inProgress
? html`<span title="${inProgress}">${inProgress}</span>`
: ''}
</div>`
: ''}`
: ''}
${this.cameraTitle
? html` <div>
<ha-icon title=${localize('event.camera')} .icon=${'mdi:cctv'}></ha-icon>
<span title="${this.cameraTitle}">${this.cameraTitle}</span>
</div>`
: ''}
${where
? html` <div>
<ha-icon
title=${localize('event.where')}
.icon=${'mdi:map-marker-outline'}
></ha-icon>
<span title="${where}">${where}</span>
</div>`
: html``}
${tags
? html` <div>
<ha-icon title=${localize('event.tag')} .icon=${'mdi:tag'}></ha-icon>
<span title="${tags}">${tags}</span>
</div>`
: html``}
${seek
? html` <div>
<ha-icon
title=${localize('event.seek')}
.icon=${'mdi:clock-fast'}
></ha-icon>
<span title="${seek}">${seek}</span>
</div>`
: html``}
</div> </div>
<div class="right"> `;
<span class="larger">${score}</span>
</div>`;
} }
static get styles(): CSSResult { static get styles(): CSSResult {
@@ -115,25 +253,77 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
@customElement('frigate-card-thumbnail-details-recording') @customElement('frigate-card-thumbnail-details-recording')
export class FrigateCardThumbnailDetailsRecording extends LitElement { export class FrigateCardThumbnailDetailsRecording extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public recording?: FrigateRecording; public media?: RecordingViewMedia;
@property({ attribute: false })
public seek?: Date;
@property({ attribute: false })
public cameraTitle?: string;
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.recording) { if (!this.media) {
return; return;
} }
return html`<div class="left"> const rawStartTime = this.media.getStartTime();
<div class="larger">${prettifyTitle(this.recording.camera) || ''}</div> const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
${this.recording.seek_time
const rawEndTime = this.media.getEndTime();
const duration =
rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
const inProgress = this.media.inProgress() ? localize('recording.in_progress') : null;
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
const eventCount = this.media.getEventCount();
return html`
${this.cameraTitle
? html` <div class="title">
<span title="${this.cameraTitle}">${this.cameraTitle}</span>
</div>`
: ``}
<div class="details">
${startTime
? html` <div> ? html` <div>
<span class="heading">${localize('recording.seek')}</span> <ha-icon
<span>${format(fromUnixTime(this.recording.seek_time), 'HH:mm:ss')}</span> title=${localize('recording.start')}
.icon=${'mdi:calendar-clock-outline'}
></ha-icon>
<span title="${startTime}">${startTime}</span>
</div>
${duration || inProgress
? html` <div>
<ha-icon
title=${localize('recording.duration')}
.icon=${'mdi:clock-outline'}
></ha-icon>
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
${inProgress
? html`<span title="${inProgress}">${inProgress}</span>`
: ''}
</div>`
: ''}`
: ''}
${seek
? html` <div>
<ha-icon
title=${localize('event.seek')}
.icon=${'mdi:clock-fast'}
></ha-icon>
<span title="${seek}">${seek}</span>
</div>` </div>`
: html``} : html``}
${eventCount !== null
? html`<div>
<ha-icon
title=${localize('recording.events')}
.icon=${'mdi:shield-alert'}
></ha-icon>
<span title="${eventCount}">${eventCount}</span>
</div>`
: ``}
</div> </div>
<div class="right"> `;
<span class="larger">${this.recording.events}</span>
<span>${localize('recording.events')}</span>
</div>`;
} }
static get styles(): CSSResult { static get styles(): CSSResult {
@@ -143,6 +333,17 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
@customElement('frigate-card-thumbnail') @customElement('frigate-card-thumbnail')
export class FrigateCardThumbnail extends LitElement { export class FrigateCardThumbnail extends LitElement {
// HomeAssistant object may be required for thumbnail signing (for Frigate
// events).
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: true })
public media?: ViewMedia;
@property({ attribute: true, type: Boolean }) @property({ attribute: true, type: Boolean })
public details = false; public details = false;
@@ -152,159 +353,150 @@ export class FrigateCardThumbnail extends LitElement {
@property({ attribute: true, type: Boolean }) @property({ attribute: true, type: Boolean })
public show_timeline_control = false; public show_timeline_control = false;
// ====================== @property({ attribute: true, type: Boolean })
// Target-based interface public show_download_control = false;
// ======================
@property({ attribute: false })
public target?: FrigateBrowseMediaSource | null;
@property({ attribute: false }) @property({ attribute: false })
public childIndex?: number; public seek?: Date;
// ===================================================
// Raw interface (can override target-based interface)
// ===================================================
@property({ attribute: true })
public thumbnail?: string;
@property({ attribute: true })
public label?: string;
@property({ attribute: false })
public event?: FrigateEvent;
// ================================
// Optional parameters for controls
// ================================
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public clientID?: string;
/** /**
* Render the element. * Render the element.
* @returns A template to display to the user. * @returns A template to display to the user.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
let event: FrigateEvent | null = null; if (!this.media || !this.cameraManager || !this.hass) {
let recording: FrigateRecording | null = null;
let thumbnail: string | null = null;
let label: string | null = null;
// Take the event / thumbnail / label from the data-bound media (if specified).
if (this.target && this.target.children && this.childIndex !== undefined) {
const media = this.target.children[this.childIndex];
event = media.frigate?.event ?? null;
recording = media.frigate?.recording ?? null;
thumbnail = media.thumbnail;
label = media.title;
}
// Always give the overrides preference (if specified).
if (this.event) {
event = this.event;
}
thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
label = this.label ? this.label : label;
if (!event && !recording) {
return; return;
} }
const thumbnail = this.media.getThumbnail();
const title = this.media.getTitle() ?? '';
const starClasses = { const starClasses = {
star: true, star: true,
starred: !!event?.retain_indefinitely, starred: !!this.media?.isFavorite(),
}; };
return html` ${event const shouldShowTimelineControl =
? html`<frigate-card-thumbnail-feature-event this.show_timeline_control &&
aria-label="${label ?? ''}" this.view &&
title="${label ?? ''}" (!ViewMediaClassifier.isRecording(this.media) ||
.hass=${this.hass} // Only show timeline control if the recording has a start & end time.
.thumbnail=${thumbnail ?? undefined} (this.media.getStartTime() && this.media.getEndTime()));
.label=${label ?? undefined}
></frigate-card-thumbnail-feature-event>` const mediaCapabilities = this.cameraManager?.getMediaCapabilities(this.media);
: html`<frigate-card-thumbnail-feature-recording
aria-label="${label ?? ''}" const shouldShowFavoriteControl =
title="${label ?? ''}" this.show_favorite_control &&
.date=${recording ? fromUnixTime(recording.start_time) : undefined} this.media &&
></frigate-card-thumbnail-feature-recording>`} this.hass &&
${this.show_favorite_control && event && this.hass && this.clientID mediaCapabilities?.canFavorite;
? html` <ha-icon
const shouldShowDownloadControl =
this.show_download_control &&
this.hass &&
this.media.getID() &&
mediaCapabilities?.canDownload;
const cameraTitle = this.cameraManager.getCameraMetadata(
this.hass,
this.media.getCameraID(),
)?.title;
return html`
${ViewMediaClassifier.isEvent(this.media)
? html`<frigate-card-thumbnail-feature-event
aria-label="${title ?? ''}"
title=${title}
.hass=${this.hass}
.thumbnail=${thumbnail ?? undefined}
></frigate-card-thumbnail-feature-event>`
: ViewMediaClassifier.isRecording(this.media)
? html`<frigate-card-thumbnail-feature-recording
aria-label="${title ?? ''}"
title="${title ?? ''}"
.cameraTitle=${this.details ? undefined : cameraTitle}
.date=${this.media.getStartTime() ?? undefined}
></frigate-card-thumbnail-feature-recording>`
: html``}
${shouldShowFavoriteControl
? html` <ha-icon
class="${classMap(starClasses)}" class="${classMap(starClasses)}"
icon=${event?.retain_indefinitely ? 'mdi:star' : 'mdi:star-outline'} icon=${this.media.isFavorite() ? 'mdi:star' : 'mdi:star-outline'}
title=${localize('thumbnail.retain_indefinitely')} title=${localize('thumbnail.retain_indefinitely')}
@click=${(ev: Event) => { @click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
if (event && this.hass && this.clientID) { if (this.hass && this.media) {
retainEvent( try {
this.hass, await this.cameraManager?.favoriteMedia(
this.clientID, this.hass,
event.id, this.media,
!event.retain_indefinitely, !this.media?.isFavorite(),
) );
.then(() => { } catch (e) {
if (event) { errorToConsole(e as Error);
event.retain_indefinitely = !event.retain_indefinitely; return;
this.requestUpdate(); }
} this.requestUpdate();
})
.catch((e) => {
errorToConsole(e);
});
} }
}} }}
/></ha-icon>` /></ha-icon>`
: ``} : ``}
${this.details && event ${this.details && ViewMediaClassifier.isEvent(this.media)
? html`<frigate-card-thumbnail-details-event ? html`<frigate-card-thumbnail-details-event
.event=${event ?? undefined} .media=${this.media ?? undefined}
></frigate-card-thumbnail-details-event>` .cameraTitle=${cameraTitle}
: this.details && recording .seek=${this.seek}
? html`<frigate-card-thumbnail-details-recording ></frigate-card-thumbnail-details-event>`
.recording=${recording ?? undefined} : this.details && ViewMediaClassifier.isRecording(this.media)
></frigate-card-thumbnail-details-recording>` ? html`<frigate-card-thumbnail-details-recording
: html``} .media=${this.media ?? undefined}
${this.show_timeline_control .cameraTitle=${cameraTitle}
? html`<ha-icon .seek=${this.seek}
class="timeline" ></frigate-card-thumbnail-details-recording>`
icon="mdi:target" : html``}
title=${localize('thumbnail.timeline')} ${shouldShowTimelineControl
@click=${(ev: Event) => { ? html`<ha-icon
stopEventFromActivatingCardWideActions(ev); class="timeline"
if (event) { icon="mdi:target"
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (!this.view || !this.media) {
return;
}
this.view this.view
?.evolve({ .evolve({
view: 'timeline', view: 'timeline',
target: this.target, queryResults: this.view.queryResults
childIndex: this.childIndex ?? null, ?.clone()
.selectResultIfFound((media) => media === this.media),
}) })
.removeContext('timeline') .removeContext('timeline')
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} else if (recording) { }}
this.view ></ha-icon>`
?.evolve({ : ''}
view: 'timeline', ${shouldShowDownloadControl
target: null, ? html` <ha-icon
childIndex: null, class="download"
}) icon=${'mdi:download'}
.mergeInContext({ title=${localize('thumbnail.download')}
timeline: { @click=${async (ev: Event) => {
window: { stopEventFromActivatingCardWideActions(ev);
start: fromUnixTime(recording.start_time), if (this.hass && this.cameraManager && this.media) {
end: fromUnixTime(recording.end_time), try {
}, await downloadMedia(this.hass, this.cameraManager, this.media);
}, } catch (error: unknown) {
}) dispatchFrigateCardErrorEvent(this, error);
.dispatchChangeEvent(this); }
} }
}} }}
></ha-icon>` ></ha-icon>`
: ''}`; : ``}
`;
} }
/** /**
File diff suppressed because it is too large Load Diff
+19 -1302
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -1,7 +1,6 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { TitleControlConfig } from '../types.js'; import { TitleControlConfig } from '../types.js';
import titleStyle from '../scss/title-control.scss'; import titleStyle from '../scss/title-control.scss';
@@ -21,6 +20,9 @@ export class FrigateCardTitleControl extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public fitInto?: HTMLElement; public fitInto?: HTMLElement;
@property({ attribute: false })
public logo?: string;
protected _toastRef: Ref<PaperToast> = createRef(); protected _toastRef: Ref<PaperToast> = createRef();
/** /**
@@ -44,6 +46,7 @@ export class FrigateCardTitleControl extends LitElement {
.text="${this.text}" .text="${this.text}"
.fitInto=${this.fitInto} .fitInto=${this.fitInto}
> >
${this.logo ? html`<img src=${this.logo} />` : ''}
</paper-toast>`; </paper-toast>`;
} }
@@ -58,7 +61,7 @@ export class FrigateCardTitleControl extends LitElement {
/** /**
* Show the toast. * Show the toast.
*/ */
public hide(): void { public hide(): void {
if (this._toastRef.value) { if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets. // Set it to false first, to ensure the timer resets.
this._toastRef.value.opened = false; this._toastRef.value.opened = false;
@@ -85,7 +88,7 @@ export class FrigateCardTitleControl extends LitElement {
} }
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"frigate-card-title-control": FrigateCardTitleControl 'frigate-card-title-control': FrigateCardTitleControl;
} }
} }
+467 -484
View File
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { ConditionState, getOverridesByKey } from '../card-condition';
import viewsStyle from '../scss/views.scss';
import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { View } from '../view/view.js';
import './surround.js';
@customElement('frigate-card-views')
export class FrigateCardViews extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public config?: FrigateCardConfig;
@property({ attribute: false })
public nonOverriddenConfig?: FrigateCardConfig;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
public conditionState?: ConditionState;
@property({ attribute: false })
public cameras?: ConditionState;
@property({ attribute: false })
public hide?: boolean;
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('view') || changedProps.has('config')) {
if (this.view?.is('live') || this._shouldLivePreload()) {
import('./live/live.js');
}
if (this.view?.isGalleryView()) {
import('./gallery.js');
} else if (this.view?.isViewerView()) {
import('./viewer.js');
} else if (this.view?.is('image')) {
import('./image.js');
} else if (this.view?.is('timeline')) {
import('./timeline.js');
}
}
if (changedProps.has('hide')) {
if (this.hide) {
this.setAttribute('hidden', '');
} else {
this.removeAttribute('hidden');
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_: PropertyValues): boolean {
// Future: Updates to `hass` and `conditionState` here will be frequent.
// Throttling here may be necessary if users report performance degradation
// > v5.0.0-beta1 .
//
// These updates are necessary in these cases:
// - conditionState: Required to let `frigate-card-live` calculate its own
// overrides.
// - hass: Required for anything that needs to sign URLs. Of note is
// anything that renders an image (e.g. a thumbnail -- almost everything,
// or the main `frigate-card-image` view).
//
// It should instead be possible to pass conditionState to live only (every
// update required), and pass hass only once / 5 minutes (see
// HASS_REJECTION_CUTOFF_MS).
return true;
}
protected _shouldLivePreload(): boolean {
return !!this.config?.live.preload;
}
protected render(): TemplateResult | void {
// Only essential items should be added to the below list, since we want the
// overall views pane to render in ~almost all cases (e.g. for a camera
// initialization error to display, `view` and `cameraConfig` may both be
// undefined, but we still want to render).
if (!this.hass || !this.config || !this.nonOverriddenConfig) {
return html``;
}
// Render but hide the live view if there's a message, or if it's preload
// mode and the view is not live.
const liveClasses = {
hidden: this._shouldLivePreload() && !this.view?.is('live'),
};
const overallClasses = {
hidden: !!this.hide,
};
const thumbnailConfig = this.view?.is('live')
? this.config.live.controls.thumbnails
: this.view?.isViewerView()
? this.config.media_viewer.controls.thumbnails
: this.view?.is('timeline')
? this.config.timeline.controls.thumbnails
: undefined;
const miniTimelineConfig = this.view?.is('live')
? this.config.live.controls.timeline
: this.view?.isViewerView()
? this.config.media_viewer.controls.timeline
: undefined;
const cameraConfig = this.view
? this.cameraManager?.getStore().getCameraConfig(this.view.camera) ?? null
: null;
return html` <frigate-card-surround
class="${classMap(overallClasses)}"
.hass=${this.hass}
.view=${this.view}
.fetchMedia=${this.view?.is('live')
? this.config.live.controls.thumbnails.media
: undefined}
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
${!this.hide && this.view?.is('image') && cameraConfig
? html` <frigate-card-image
.imageConfig=${this.config.image}
.view=${this.view}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
>
</frigate-card-image>`
: ``}
${!this.hide && this.view?.isGalleryView()
? html` <frigate-card-gallery
.hass=${this.hass}
.view=${this.view}
.galleryConfig=${this.config.media_gallery}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-gallery>`
: ``}
${!this.hide && this.view?.isViewerView()
? html`
<frigate-card-viewer
.hass=${this.hass}
.view=${this.view}
.viewerConfig=${this.config.media_viewer}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-viewer>
`
: ``}
${!this.hide && this.view?.is('timeline')
? html` <frigate-card-timeline
.hass=${this.hass}
.view=${this.view}
.timelineConfig=${this.config.timeline}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-timeline>`
: ``}
${
// Note: Subtle difference in condition below vs the other views in order
// to always render the live view for live.preload mode.
// Note: <frigate-card-live> uses nonOverriddenConfig rather than the
// overriden config as it does it's own overriding as part of the camera
// carousel.
this._shouldLivePreload() || (!this.hide && this.view?.is('live'))
? html`
<frigate-card-live
.hass=${this.hass}
.view=${this.view}
.liveConfig=${this.nonOverriddenConfig.live}
.conditionState=${this.conditionState}
.liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
class="${classMap(liveClasses)}"
>
</frigate-card-live>
`
: ``
}
</frigate-card-surround>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(viewsStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-views': FrigateCardViews;
}
}
+127 -168
View File
@@ -1,22 +1,24 @@
import { cloneDeep, get, isEqual, set } from 'lodash-es'; import cloneDeep from 'lodash-es/cloneDeep';
import get from 'lodash-es/get';
import isEqual from 'lodash-es/isEqual';
import set from 'lodash-es/set';
import { import {
CONF_CAMERAS, CONF_CAMERAS,
CONF_CAMERAS_ARRAY_CAMERA_ENTITY, CONF_CAMERAS_GLOBAL_IMAGE,
CONF_CAMERAS_ARRAY_LIVE_PROVIDER, CONF_CAMERAS_GLOBAL_JSMPEG,
CONF_IMAGE_URL, CONF_CAMERAS_GLOBAL_WEBRTC_CARD,
CONF_ELEMENTS,
CONF_LIVE_AUTO_UNMUTE, CONF_LIVE_AUTO_UNMUTE,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
CONF_LIVE_LAZY_UNLOAD, CONF_LIVE_LAZY_UNLOAD,
CONF_LIVE_PRELOAD, CONF_MEDIA_GALLERY,
CONF_LIVE_WEBRTC_CARD,
CONF_MEDIA_VIEWER, CONF_MEDIA_VIEWER,
CONF_MENU,
CONF_MENU_BUTTONS_CAMERAS, CONF_MENU_BUTTONS_CAMERAS,
CONF_MENU_BUTTONS_CAMERA_UI,
CONF_MENU_BUTTONS_CLIPS, CONF_MENU_BUTTONS_CLIPS,
CONF_MENU_BUTTONS_DOWNLOAD, CONF_MENU_BUTTONS_DOWNLOAD,
CONF_MENU_BUTTONS_FRIGATE, CONF_MENU_BUTTONS_FRIGATE,
CONF_MENU_BUTTONS_FRIGATE_UI,
CONF_MENU_BUTTONS_FULLSCREEN, CONF_MENU_BUTTONS_FULLSCREEN,
CONF_MENU_BUTTONS_IMAGE, CONF_MENU_BUTTONS_IMAGE,
CONF_MENU_BUTTONS_LIVE, CONF_MENU_BUTTONS_LIVE,
@@ -25,22 +27,19 @@ import {
CONF_MENU_POSITION, CONF_MENU_POSITION,
CONF_MENU_STYLE, CONF_MENU_STYLE,
CONF_OVERRIDES, CONF_OVERRIDES,
CONF_VIEW_DEFAULT,
CONF_VIEW_TIMEOUT_SECONDS,
CONF_VIEW_UPDATE_ENTITIES,
} from './const'; } from './const';
import { import {
BUTTON_SIZE_MIN, BUTTON_SIZE_MIN,
RawFrigateCardConfig, RawFrigateCardConfig,
RawFrigateCardConfigArray,
THUMBNAIL_WIDTH_MAX, THUMBNAIL_WIDTH_MAX,
THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MIN,
} from './types'; } from './types';
import { arrayify } from './utils/basic';
/** /**
* Set a configuration value. * Set a configuration value.
* @param obj The configuration. * @param obj The configuration.
* @param key The key to the property to set. * @param keys The key to the property to set.
* @param value The value to set. * @param value The value to set.
*/ */
@@ -55,7 +54,8 @@ export const setConfigValue = (
/** /**
* Get a configuration value. * Get a configuration value.
* @param obj The configuration. * @param obj The configuration.
* @param key The key to the property to retrieve. * @param keys The key to the property to retrieve.
* @param def Default if key not found.
* @returns The property or undefined if not found. * @returns The property or undefined if not found.
*/ */
export const getConfigValue = ( export const getConfigValue = (
@@ -94,7 +94,6 @@ export const upgradeConfig = function (obj: RawFrigateCardConfig): boolean {
for (let i = 0; i < UPGRADES.length; i++) { for (let i = 0; i < UPGRADES.length; i++) {
upgraded = UPGRADES[i](obj) || upgraded; upgraded = UPGRADES[i](obj) || upgraded;
} }
trimConfig(obj);
return upgraded; return upgraded;
}; };
@@ -104,30 +103,7 @@ export const upgradeConfig = function (obj: RawFrigateCardConfig): boolean {
* @returns `true` if the configuration is upgradeable. * @returns `true` if the configuration is upgradeable.
*/ */
export const isConfigUpgradeable = function (obj: RawFrigateCardConfig): boolean { export const isConfigUpgradeable = function (obj: RawFrigateCardConfig): boolean {
const newObj = JSON.parse(JSON.stringify(obj)); return upgradeConfig(copyConfig(obj));
return upgradeConfig(newObj);
};
/**
* Remove empty sections from a configuration.
* @param obj Configuration object.
* @returns `true` if the configuration was modified.
*/
export const trimConfig = function (obj: RawFrigateCardConfig): boolean {
const keys = Object.keys(obj);
let modified = false;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (typeof obj[key] === 'object' && obj[key] != null) {
modified ||= trimConfig(obj[key] as RawFrigateCardConfig);
if (!Object.keys(obj[key] as RawFrigateCardConfig).length) {
delete obj[key];
modified = true;
}
}
}
return modified;
}; };
/** /**
@@ -135,28 +111,10 @@ export const trimConfig = function (obj: RawFrigateCardConfig): boolean {
* @param obj Configuration to copy. * @param obj Configuration to copy.
* @returns A new deeply-copied configuration. * @returns A new deeply-copied configuration.
*/ */
export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardConfig { export const copyConfig = <T>(obj: T): T => {
return cloneDeep(obj); return cloneDeep(obj);
}; };
/**
* Determines if a property is not an object.
* @param value The value.
* @returns `true` is the value is not an object.
*/
const isNotObject = function (value: unknown): unknown | undefined {
return typeof value !== 'object' ? value : undefined;
};
/**
* Converts to a number or return undefined.
* @param value The value.
* @returns A number or undefined.
*/
const toNumberOrIgnore = function (value: unknown): number | undefined {
return isNaN(value as number) ? undefined : Number(value);
};
/** /**
* Create a transform that will cap a numeric value. * Create a transform that will cap a numeric value.
* @param value The value. * @param value The value.
@@ -217,7 +175,7 @@ const deleteProperty = function (_value: unknown): number | null | undefined {
* @param transform An optional transform for the value. * @param transform An optional transform for the value.
* @returns `true` if the configuration was modified. * @returns `true` if the configuration was modified.
*/ */
export const moveConfigValue = ( const moveConfigValue = (
obj: RawFrigateCardConfig, obj: RawFrigateCardConfig,
oldPath: string, oldPath: string,
newPath: string, newPath: string,
@@ -363,39 +321,6 @@ const upgradeArrayValue = function (
}; };
}; };
/**
* Upgrade from a singular camera model to multiple.
* @returns An upgrade function.
*/
const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) => {
return function (obj: RawFrigateCardConfig): boolean {
let modified = false;
const cameras = getConfigValue(obj, CONF_CAMERAS) as RawFrigateCardConfigArray;
// Only do an upgrade if the cameras section does not exist.
if (cameras !== undefined) {
return false;
}
const imports = {
camera_entity: CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
'frigate.camera_name': 'cameras.#.camera_name',
'frigate.client_id': 'cameras.#.client_id',
'frigate.label': 'cameras.#.label',
'frigate.url': 'cameras.#.frigate_url',
'frigate.zone': 'cameras.#.zone',
'live.webrtc.entity': `cameras.#.webrtc.entity`,
'live.webrtc.url': `cameras.#.webrtc.url`,
'live.provider': CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
};
Object.keys(imports).forEach((key) => {
modified =
moveConfigValue(obj, key, getArrayConfigPath(imports[key], 0)) || modified;
});
return modified;
};
};
/** /**
* Upgrade from a menu-mode to a style & position. * Upgrade from a menu-mode to a style & position.
* @returns An upgrade function. * @returns An upgrade function.
@@ -460,40 +385,6 @@ const upgradeMenuModeToStyleAndPosition = (): ((
}; };
}; };
/**
* Upgrade from a condition on the menu (to allow rendering) to a menu mode
* override instead.
* @param key A string key.
* @returns A safe key.
*/
const upgradeMenuConditionToMenuOverride = (): ((
obj: RawFrigateCardConfig,
) => boolean) => {
return function (obj: RawFrigateCardConfig): boolean {
const menuConditions = getConfigValue(
obj,
`${CONF_MENU}.conditions`,
) as RawFrigateCardConfig;
if (menuConditions === undefined) {
return false;
}
const overrides =
(getConfigValue(obj, `${CONF_OVERRIDES}`) as RawFrigateCardConfigArray) || [];
setConfigValue(obj, `${CONF_OVERRIDES}.[${overrides.length}]`, {
conditions: menuConditions,
overrides: {
menu: {
mode: 'none',
},
},
});
deleteConfigValue(obj, `${CONF_MENU}.conditions`);
return true;
};
};
/** /**
* Transform a menu button from a boolean to a priority. * Transform a menu button from a boolean to a priority.
* @param value The boolean true/false for show/hide the switch. * @param value The boolean true/false for show/hide the switch.
@@ -544,48 +435,75 @@ const upgradeThumbnailShowControlsToIndividualControls = (
}; };
}; };
/**
* Recursively upgrade an object.
* @param transform A transform applied to each object recursively.
* @param getObject A function to get the object to be upgraded.
* @returns An upgrade function.
*/
const recursiveUpgradeObject = (
transform: (data: RawFrigateCardConfig) => boolean,
getObject?: (data: RawFrigateCardConfig) => RawFrigateCardConfig | undefined | null,
): ((data: RawFrigateCardConfig) => boolean) => {
const recurse = (data: RawFrigateCardConfig): boolean => {
let result = false;
if (data && typeof data === 'object') {
const object = getObject ? getObject(data) : data;
if (object) {
result = transform(object) || result;
}
if (Array.isArray(data)) {
data
.filter((item) => typeof item === 'object')
.forEach((item: RawFrigateCardConfig) => {
result = recurse(item) || result;
});
} else {
Object.keys(data)
.filter((key) => typeof data[key] === 'object')
.forEach((key) => {
result = recurse(data[key] as RawFrigateCardConfig) || result;
});
}
}
return result;
};
return recurse;
};
/**
* Transform mediaLoaded -> media_loaded
* @param data Input data.
* @returns `true` if the configuration was modified.
*/
const transformConditionMediaLoaded = (data: unknown): boolean => {
if (typeof data === 'object' && data && data['mediaLoaded'] !== undefined) {
data['media_loaded'] = data['mediaLoaded'];
delete data['mediaLoaded'];
return true;
}
return false;
};
/**
* Transform action frigate_ui -> camera_ui
* @param data Input data.
* @returns `true` if the configuration was modified.
*/
const transformFrigateUIAction = (data: unknown): boolean => {
if (
typeof data === 'object' &&
data &&
data['action'] === 'custom:frigate-card-action' &&
data['frigate_card_action'] === 'frigate_ui'
) {
data['frigate_card_action'] = 'camera_ui';
return true;
}
return false;
};
const UPGRADES = [ const UPGRADES = [
// v1.2.1 -> v2.0.0
upgradeMoveTo('frigate_url', 'frigate.url'),
upgradeMoveTo('frigate_client_id', 'frigate.client_id'),
upgradeMoveTo('frigate_camera_name', 'frigate.camera_name'),
upgradeMoveTo('label', 'frigate.label'),
upgradeMoveTo('zone', 'frigate.zone'),
upgradeMoveTo('view_default', CONF_VIEW_DEFAULT),
upgradeMoveTo('view_timeout', 'view.timeout'),
upgradeMoveTo('live_provider', 'live.provider'),
upgradeMoveTo('live_preload', CONF_LIVE_PRELOAD),
upgradeMoveTo('webrtc', 'live.webrtc'),
upgradeMoveTo('autoplay_clip', 'event_viewer.autoplay_clip'),
upgradeMoveTo('controls.nextprev', 'event_viewer.controls.next_previous.style'),
upgradeMoveTo('controls.nextprev_size', 'event_viewer.controls.next_previous.size'),
upgradeMoveTo('menu_mode', 'menu.mode'),
upgradeMoveTo('menu_buttons', 'menu.buttons'),
upgradeMoveTo('menu_button_size', CONF_MENU_BUTTON_SIZE),
upgradeMoveTo('image', 'image.src', { transform: isNotObject }),
// v2.0.0 -> v2.1.0
upgradeMoveTo('update_entities', CONF_VIEW_UPDATE_ENTITIES),
// v2.1.0 -> v3.0.0-rc.1
upgradeToMultipleCameras(),
upgradeMenuConditionToMenuOverride(),
upgradeMoveTo('view.timeout', CONF_VIEW_TIMEOUT_SECONDS, {
transform: toNumberOrIgnore,
}),
upgradeMoveTo('event_viewer.autoplay_clip', 'event_viewer.auto_play'),
// v3.0.0-rc.1 -> v3.0.0-rc.2
upgradeArrayValue(
CONF_CAMERAS,
upgradeWithOverrides('live_provider', (val) =>
val === 'frigate' ? 'ha' : val === 'webrtc' ? 'webrtc-card' : val,
),
),
upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('webrtc', 'webrtc_card')),
upgradeMoveToWithOverrides('live.webrtc', CONF_LIVE_WEBRTC_CARD),
upgradeMoveToWithOverrides('image.src', CONF_IMAGE_URL),
// v3.0.0 -> v4.0.0-rc.1 // v3.0.0 -> v4.0.0-rc.1
upgradeWithOverrides( upgradeWithOverrides(
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
@@ -616,7 +534,7 @@ const UPGRADES = [
upgradeWithOverrides(CONF_MENU_BUTTONS_SNAPSHOTS, menuButtonBooleanToObject), upgradeWithOverrides(CONF_MENU_BUTTONS_SNAPSHOTS, menuButtonBooleanToObject),
upgradeWithOverrides(CONF_MENU_BUTTONS_IMAGE, menuButtonBooleanToObject), upgradeWithOverrides(CONF_MENU_BUTTONS_IMAGE, menuButtonBooleanToObject),
upgradeWithOverrides(CONF_MENU_BUTTONS_DOWNLOAD, menuButtonBooleanToObject), upgradeWithOverrides(CONF_MENU_BUTTONS_DOWNLOAD, menuButtonBooleanToObject),
upgradeWithOverrides(CONF_MENU_BUTTONS_FRIGATE_UI, menuButtonBooleanToObject), upgradeWithOverrides('menu.buttons.frigate_ui', menuButtonBooleanToObject),
upgradeWithOverrides(CONF_MENU_BUTTONS_FULLSCREEN, menuButtonBooleanToObject), upgradeWithOverrides(CONF_MENU_BUTTONS_FULLSCREEN, menuButtonBooleanToObject),
upgrade(CONF_LIVE_LAZY_UNLOAD, (val) => upgrade(CONF_LIVE_LAZY_UNLOAD, (val) =>
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined, typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
@@ -642,4 +560,45 @@ const UPGRADES = [
upgradeThumbnailShowControlsToIndividualControls('media_viewer.controls.thumbnails'), upgradeThumbnailShowControlsToIndividualControls('media_viewer.controls.thumbnails'),
upgradeThumbnailShowControlsToIndividualControls('live.controls.thumbnails'), upgradeThumbnailShowControlsToIndividualControls('live.controls.thumbnails'),
upgradeThumbnailShowControlsToIndividualControls('timeline.controls.thumbnails'), upgradeThumbnailShowControlsToIndividualControls('timeline.controls.thumbnails'),
// v4.0.0 -> v4.1.0
upgradeArrayValue(
CONF_OVERRIDES,
transformConditionMediaLoaded,
(data) => data.conditions as RawFrigateCardConfig | undefined,
),
(data: unknown): boolean => {
return recursiveUpgradeObject(
transformConditionMediaLoaded,
(data) => data.conditions as RawFrigateCardConfig | undefined,
)(typeof data === 'object' && data ? data[CONF_ELEMENTS] : {});
},
upgradeMoveToWithOverrides('event_gallery', CONF_MEDIA_GALLERY),
upgradeMoveToWithOverrides('menu.buttons.frigate_ui', CONF_MENU_BUTTONS_CAMERA_UI),
(data: unknown): boolean => {
return recursiveUpgradeObject(transformFrigateUIAction)(
typeof data === 'object' && data ? <RawFrigateCardConfig>data : {},
);
},
upgradeArrayValue(
CONF_CAMERAS,
upgradeWithOverrides('live_provider', (val) =>
val === 'frigate-jsmpeg' ? 'jsmpeg' : val,
),
),
upgradeMoveToWithOverrides('live.image', CONF_CAMERAS_GLOBAL_IMAGE),
upgradeMoveToWithOverrides('live.jsmpeg', CONF_CAMERAS_GLOBAL_JSMPEG),
upgradeMoveToWithOverrides('live.webrtc_card', CONF_CAMERAS_GLOBAL_WEBRTC_CARD),
upgradeArrayValue(
CONF_CAMERAS,
upgradeMoveToWithOverrides('frigate.zone', 'frigate.zones', {
transform: (zone) => arrayify(zone),
}),
),
upgradeArrayValue(
CONF_CAMERAS,
upgradeMoveToWithOverrides('frigate.label', 'frigate.labels', {
transform: (label) => arrayify(label),
}),
),
]; ];
+107 -25
View File
@@ -1,4 +1,3 @@
export const CAMERA_BIRDSEYE = 'birdseye' as const;
export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const; export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const;
export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const; export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const;
@@ -9,13 +8,31 @@ export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME =
`${CONF_CAMERAS}.#.frigate.camera_name` as const; `${CONF_CAMERAS}.#.frigate.camera_name` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID = export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID =
`${CONF_CAMERAS}.#.frigate.client_id` as const; `${CONF_CAMERAS}.#.frigate.client_id` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_LABEL = export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS =
`${CONF_CAMERAS}.#.frigate.label` as const; `${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_URL = `${CONF_CAMERAS}.#.frigate.url` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_ZONE = `${CONF_CAMERAS}.#.frigate.zone` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES =
export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const; `${CONF_CAMERAS}.#.frigate.zones` as const;
export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` 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_HIDE = `${CONF_CAMERAS}.#.hide` as const;
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` 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_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 = export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY =
`${CONF_CAMERAS}.#.webrtc_card.entity` as const; `${CONF_CAMERAS}.#.webrtc_card.entity` as const;
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL =
@@ -33,14 +50,25 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES = export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
`${CONF_CAMERAS}.#.triggers.entities` as const; `${CONF_CAMERAS}.#.triggers.entities` as const;
export const CONF_VIEW = 'view' as const; const CONF_CAMERAS_GLOBAL = 'cameras_global' as const;
export const CONF_CAMERAS_GLOBAL_IMAGE = `${CONF_CAMERAS_GLOBAL}.image` as const;
export const CONF_CAMERAS_GLOBAL_JSMPEG = `${CONF_CAMERAS_GLOBAL}.jsmpeg` as const;
export const CONF_CAMERAS_GLOBAL_WEBRTC_CARD =
`${CONF_CAMERAS_GLOBAL}.webrtc_card` as const;
export const CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY =
`${CONF_CAMERAS_GLOBAL}.triggers.occupancy` as const;
export const CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS =
`${CONF_CAMERAS_GLOBAL}.image.refresh_seconds` as const;
export const CONF_ELEMENTS = 'elements' as const;
const CONF_VIEW = 'view' as const;
export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const;
export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const; export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const;
export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const;
export const CONF_VIEW_TIMEOUT_SECONDS = `${CONF_VIEW}.timeout_seconds` as const; export const CONF_VIEW_TIMEOUT_SECONDS = `${CONF_VIEW}.timeout_seconds` as const;
export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const; export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const;
export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const;
export const CONF_VIEW_UPDATE_ENTITIES = `${CONF_VIEW}.update_entities` as const;
export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const;
export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const; export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const;
export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const; export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const;
@@ -51,15 +79,19 @@ export const CONF_VIEW_SCAN_UNTRIGGER_RESET =
export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS = export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS =
`${CONF_VIEW_SCAN}.untrigger_seconds` as const; `${CONF_VIEW_SCAN}.untrigger_seconds` as const;
export const CONF_EVENT_GALLERY = 'event_gallery' as const; export const CONF_MEDIA_GALLERY = 'media_gallery' as const;
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE =
`${CONF_EVENT_GALLERY}.controls.thumbnails.show_details` as const; `${CONF_MEDIA_GALLERY}.controls.filter.mode` as const;
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_EVENT_GALLERY}.controls.thumbnails.show_favorite_control` as const; `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_details` as const;
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL =
`${CONF_EVENT_GALLERY}.controls.thumbnails.show_timeline_control` as const; `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_download_control` as const;
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE = export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_EVENT_GALLERY}.controls.thumbnails.size` as const; `${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_SIZE =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.size` as const;
export const CONF_MEDIA_VIEWER = 'media_viewer' as const; export 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_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const;
@@ -68,6 +100,8 @@ export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as c
export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const; export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const;
export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` 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_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 = export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
`${CONF_MEDIA_VIEWER}.transition_effect` as const; `${CONF_MEDIA_VIEWER}.transition_effect` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE = export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
@@ -78,12 +112,27 @@ export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const; `${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS = export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const; `${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 = export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const; `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const; `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE = export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const; `${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_MEDIA =
`${CONF_MEDIA_VIEWER}.controls.timeline.media` 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_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_TITLE_MODE = export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
`${CONF_MEDIA_VIEWER}.controls.title.mode` as const; `${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS = export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
@@ -94,7 +143,7 @@ export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_X =
export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y = export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y =
`${CONF_MEDIA_VIEWER}.layout.position.y` as const; `${CONF_MEDIA_VIEWER}.layout.position.y` as const;
export const CONF_LIVE = 'live' 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_PLAY = `${CONF_LIVE}.auto_play` as const;
export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` 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_MUTE = `${CONF_LIVE}.auto_mute` as const;
@@ -111,10 +160,24 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
`${CONF_LIVE}.controls.thumbnails.size` as const; `${CONF_LIVE}.controls.thumbnails.size` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS = export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_LIVE}.controls.thumbnails.show_details` as const; `${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 = export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const; `${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const; `${CONF_LIVE}.controls.thumbnails.show_timeline_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_MEDIA =
`${CONF_LIVE}.controls.timeline.media` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_MODE =
`${CONF_LIVE}.controls.timeline.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_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const; export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS = export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
`${CONF_LIVE}.controls.title.duration_seconds` as const; `${CONF_LIVE}.controls.title.duration_seconds` as const;
@@ -122,16 +185,14 @@ export const CONF_LIVE_LAYOUT_FIT = `${CONF_LIVE}.layout.fit` as const;
export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const; export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const;
export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const; export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const;
export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const; export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const;
export const CONF_LIVE_JSMPEG = `${CONF_LIVE}.jsmpeg` as const;
export const CONF_LIVE_LAZY_LOAD = `${CONF_LIVE}.lazy_load` 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_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const;
export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` 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_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD = export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
`${CONF_LIVE}.show_image_during_load` as const; `${CONF_LIVE}.show_image_during_load` as const;
export const CONF_LIVE_WEBRTC_CARD = `${CONF_LIVE}.webrtc_card` as const;
export const CONF_IMAGE = 'image' as const; const CONF_IMAGE = 'image' as const;
export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const; export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const;
export const CONF_IMAGE_LAYOUT_POSITION_X = `${CONF_IMAGE}.layout.position.x` as const; export const CONF_IMAGE_LAYOUT_POSITION_X = `${CONF_IMAGE}.layout.position.x` as const;
export const CONF_IMAGE_LAYOUT_POSITION_Y = `${CONF_IMAGE}.layout.position.y` as const; export const CONF_IMAGE_LAYOUT_POSITION_Y = `${CONF_IMAGE}.layout.position.y` as const;
@@ -139,24 +200,27 @@ export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const; export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const; export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
export const CONF_TIMELINE = 'timeline' as const; const CONF_TIMELINE = 'timeline' as const;
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const; export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
export const CONF_TIMELINE_CLUSTERING_THRESHOLD = export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_TIMELINE}.clustering_threshold` as const; `${CONF_TIMELINE}.clustering_threshold` as const;
export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const; export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const;
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` 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 = export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
`${CONF_TIMELINE}.controls.thumbnails.mode` as const; `${CONF_TIMELINE}.controls.thumbnails.mode` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
`${CONF_TIMELINE}.controls.thumbnails.size` as const; `${CONF_TIMELINE}.controls.thumbnails.size` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS = export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_TIMELINE}.controls.thumbnails.show_details` as const; `${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 = export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const; `${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const; `${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const;
export const CONF_MENU = 'menu' as const; const CONF_MENU = 'menu' as const;
export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const; export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const;
export const CONF_MENU_POSITION = `${CONF_MENU}.position` 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_STYLE = `${CONF_MENU}.style` as const;
@@ -167,18 +231,36 @@ export const CONF_MENU_BUTTONS_CAMERAS = `${CONF_MENU}.buttons.cameras` as const
export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const; export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const;
export const CONF_MENU_BUTTONS_DOWNLOAD = `${CONF_MENU}.buttons.download` as const; export const CONF_MENU_BUTTONS_DOWNLOAD = `${CONF_MENU}.buttons.download` as const;
export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const; export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const;
export const CONF_MENU_BUTTONS_FRIGATE_UI = `${CONF_MENU}.buttons.frigate_ui` as const; export const CONF_MENU_BUTTONS_CAMERA_UI = `${CONF_MENU}.buttons.camera_ui` as const;
export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU}.buttons.fullscreen` as const; export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU}.buttons.fullscreen` as const;
export const CONF_MENU_BUTTONS_IMAGE = `${CONF_MENU}.buttons.image` as const; export const CONF_MENU_BUTTONS_IMAGE = `${CONF_MENU}.buttons.image` as const;
export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const; export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const;
export const CONF_MENU_BUTTONS_MEDIA_PLAYER =
`${CONF_MENU}.buttons.media_player` as const;
export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const; export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const;
export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU}.buttons.timeline` as const;
export const CONF_DIMENSIONS = 'dimensions' 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 = `${CONF_DIMENSIONS}.aspect_ratio` as const;
export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = export const CONF_DIMENSIONS_ASPECT_RATIO_MODE =
`${CONF_DIMENSIONS}.aspect_ratio_mode` as const; `${CONF_DIMENSIONS}.aspect_ratio_mode` as const;
export const CONF_DIMENSIONS_MAX_HEIGHT = `${CONF_DIMENSIONS}.max_height` as const;
export const CONF_DIMENSIONS_MIN_HEIGHT = `${CONF_DIMENSIONS}.min_height` as const;
export const CONF_OVERRIDES = 'overrides' 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_MEDIA_CHUNK_SIZE = `${CONF_PERFORMANCE}.features.media_chunk_size`;
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`;
// Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93 // Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93
export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
// 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;
+1
View File
@@ -1,4 +1,5 @@
declare module '*.scss'; declare module '*.scss';
declare module '*.svg';
declare module '*.jpg'; declare module '*.jpg';
declare module 'view' { declare module 'view' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
+819 -273
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
# go2rtc Player
**Link**: https://github.com/AlexxIT/go2rtc/tree/master/www
**Description**: A video player imported from go2rtc.
**Copyright**: [Alexey Khit](https://github.com/AlexxIT)
**License**: [MIT](https://github.com/AlexxIT/go2rtc/blob/master/LICENSE)
+22
View File
@@ -0,0 +1,22 @@
export class VideoRTC extends HTMLElement {
DISCONNECT_TIMEOUT: number;
RECONNECT_TIMEOUT: number;
CODECS: string[];
mode: string;
background: boolean;
visibilityThreshold: number;
visibilityCheck: boolean;
pcConfig: RTCConfiguration;
wsState: number;
pcState: number;
video: HTMLVideoElement;
ws: WebSocket | null;
wsURL: string;
pc: RTCPeerConnection;
connectTS: number;
mseCodecs: string;
src: string | URL;
oninit(): void;
}
+597
View File
@@ -0,0 +1,597 @@
/**
* Video player for go2rtc streaming application.
*
* All modern web technologies are supported in almost any browser except Apple Safari.
*
* Support:
* - RTCPeerConnection for Safari iOS 11.0+
* - IntersectionObserver for Safari iOS 12.2+
*
* Doesn't support:
* - MediaSource for Safari iOS all
* - Customized built-in elements (extends HTMLVideoElement) because all Safari
* - Public class fields because old Safari (before 14.0)
* - Autoplay for Safari
*/
export class VideoRTC extends HTMLElement {
constructor() {
super();
this.DISCONNECT_TIMEOUT = 5000;
this.RECONNECT_TIMEOUT = 30000;
this.CODECS = [
"avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
"avc1.64002A", // H.264 high 4.2 (Chromecast 3rd Gen)
"avc1.640033", // H.264 high 5.1 (Chromecast with Google TV)
"hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra)
"mp4a.40.2", // AAC LC
"mp4a.40.5", // AAC HE
"opus", // OPUS Chrome
];
/**
* [config] Supported modes (webrtc, mse, mp4, mjpeg).
* @type {string}
*/
this.mode = "webrtc,mse,mp4,mjpeg";
/**
* [config] Run stream when not displayed on the screen. Default `false`.
* @type {boolean}
*/
this.background = false;
/**
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
* Default `0` - disable;
* @type {number}
*/
this.visibilityThreshold = 0;
/**
* [config] Run stream only when browser page on the screen. Stop when user change browser
* tab or minimise browser windows.
* @type {boolean}
*/
this.visibilityCheck = true;
/**
* [config] WebRTC configuration
* @type {RTCConfiguration}
*/
this.pcConfig = {
iceServers: [{urls: 'stun:stun.l.google.com:19302'}],
sdpSemantics: 'unified-plan', // important for Chromecast 1
};
/**
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
* @type {number}
*/
this.wsState = WebSocket.CLOSED;
/**
* [info] WebRTC connection state.
* @type {number}
*/
this.pcState = WebSocket.CLOSED;
/**
* @type {HTMLVideoElement}
*/
this.video = null;
/**
* @type {WebSocket}
*/
this.ws = null;
/**
* @type {string|URL}
*/
this.wsURL = "";
/**
* @type {RTCPeerConnection}
*/
this.pc = null;
/**
* @type {number}
*/
this.connectTS = 0;
/**
* @type {string}
*/
this.mseCodecs = "";
/**
* [internal] Disconnect TimeoutID.
* @type {number}
*/
this.disconnectTID = 0;
/**
* [internal] Reconnect TimeoutID.
* @type {number}
*/
this.reconnectTID = 0;
/**
* [internal] Handler for receiving Binary from WebSocket.
* @type {Function}
*/
this.ondata = null;
/**
* [internal] Handlers list for receiving JSON from WebSocket
* @type {Object.<string,Function>}}
*/
this.onmessage = null;
}
/**
* Set video source (WebSocket URL). Support relative path.
* @param {string|URL} value
*/
set src(value) {
if (typeof value !== "string") value = value.toString();
if (value.startsWith("http")) {
value = "ws" + value.substring(4);
} else if (value.startsWith("/")) {
value = "ws" + location.origin.substring(4) + value;
}
this.wsURL = value;
this.onconnect();
}
/**
* Play video. Support automute when autoplay blocked.
* https://developer.chrome.com/blog/autoplay/
*/
play() {
this.video.play().catch(er => {
if (er.name === "NotAllowedError" && !this.video.muted) {
this.video.muted = true;
this.video.play().catch(() => console.debug);
}
});
}
/**
* Send message to server via WebSocket
* @param {Object} value
*/
send(value) {
if (this.ws) this.ws.send(JSON.stringify(value));
}
codecs(type) {
const test = type === "mse"
? codec => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`)
: codec => this.video.canPlayType(`video/mp4; codecs="${codec}"`);
return this.CODECS.filter(test).join();
}
/**
* `CustomElement`. Invoked each time the custom element is appended into a
* document-connected element.
*/
connectedCallback() {
if (this.disconnectTID) {
clearTimeout(this.disconnectTID);
this.disconnectTID = 0;
}
// because video autopause on disconnected from DOM
if (this.video) {
const seek = this.video.seekable;
if (seek.length > 0) {
this.video.currentTime = seek.end(seek.length - 1);
}
this.play();
} else {
this.oninit();
}
this.onconnect();
}
/**
* `CustomElement`. Invoked each time the custom element is disconnected from the
* document's DOM.
*/
disconnectedCallback() {
if (this.background || this.disconnectTID) return;
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
this.disconnectTID = setTimeout(() => {
if (this.reconnectTID) {
clearTimeout(this.reconnectTID);
this.reconnectTID = 0;
}
this.disconnectTID = 0;
this.ondisconnect();
}, this.DISCONNECT_TIMEOUT);
}
/**
* Creates child DOM elements. Called automatically once on `connectedCallback`.
*/
oninit() {
this.video = document.createElement("video");
this.video.controls = true;
this.video.playsInline = true;
this.video.preload = "auto";
this.video.style.display = "block"; // fix bottom margin 4px
this.video.style.width = "100%";
this.video.style.height = "100%"
this.appendChild(this.video);
if (this.background) return;
if ("hidden" in document && this.visibilityCheck) {
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.disconnectedCallback();
} else if (this.isConnected) {
this.connectedCallback();
}
})
}
if ("IntersectionObserver" in window && this.visibilityThreshold) {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) {
this.disconnectedCallback();
} else if (this.isConnected) {
this.connectedCallback();
}
});
}, {threshold: this.visibilityThreshold});
observer.observe(this);
}
}
/**
* Connect to WebSocket. Called automatically on `connectedCallback`.
* @return {boolean} true if the connection has started.
*/
onconnect() {
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
// CLOSED or CONNECTING => CONNECTING
this.wsState = WebSocket.CONNECTING;
this.connectTS = Date.now();
this.ws = new WebSocket(this.wsURL);
this.ws.binaryType = "arraybuffer";
this.ws.addEventListener("open", ev => this.onopen(ev));
this.ws.addEventListener("close", ev => this.onclose(ev));
return true;
}
ondisconnect() {
this.wsState = WebSocket.CLOSED;
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.pcState = WebSocket.CLOSED;
if (this.pc) {
this.pc.close();
this.pc = null;
}
}
/**
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
*/
onopen() {
// CONNECTING => OPEN
this.wsState = WebSocket.OPEN;
this.ws.addEventListener("message", ev => {
if (typeof ev.data === "string") {
const msg = JSON.parse(ev.data);
for (const mode in this.onmessage) {
this.onmessage[mode](msg);
}
} else {
this.ondata(ev.data);
}
});
this.ondata = null;
this.onmessage = {};
const modes = [];
if (this.mode.indexOf("mse") >= 0 && "MediaSource" in window) { // iPhone
modes.push("mse");
this.onmse();
} else if (this.mode.indexOf("mp4") >= 0) {
modes.push("mp4");
this.onmp4();
}
if (this.mode.indexOf("webrtc") >= 0 && "RTCPeerConnection" in window) { // macOS Desktop app
modes.push("webrtc");
this.onwebrtc();
}
if (this.mode.indexOf("mjpeg") >= 0) {
if (modes.length) {
this.onmessage["mjpeg"] = msg => {
if (msg.type !== "error" || msg.value.indexOf(modes[0]) !== 0) return;
this.onmjpeg();
}
} else {
modes.push("mjpeg");
this.onmjpeg();
}
}
return modes;
}
/**
* @return {boolean} true if reconnection has started.
*/
onclose() {
if (this.wsState === WebSocket.CLOSED) return false;
// CONNECTING, OPEN => CONNECTING
this.wsState = WebSocket.CONNECTING;
this.ws = null;
// reconnect no more than once every X seconds
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
this.reconnectTID = setTimeout(() => {
this.reconnectTID = 0;
this.onconnect();
}, delay);
return true;
}
onmse() {
const ms = new MediaSource();
ms.addEventListener("sourceopen", () => {
URL.revokeObjectURL(this.video.src);
this.send({type: "mse", value: this.codecs("mse")});
}, {once: true});
this.video.src = URL.createObjectURL(ms);
this.video.srcObject = null;
this.play();
this.mseCodecs = "";
this.onmessage["mse"] = msg => {
if (msg.type !== "mse") return;
this.mseCodecs = msg.value;
const sb = ms.addSourceBuffer(msg.value);
sb.mode = "segments"; // segments or sequence
sb.addEventListener("updateend", () => {
if (sb.updating) return;
try {
if (bufLen > 0) {
const data = buf.slice(0, bufLen);
bufLen = 0;
sb.appendBuffer(data);
} else if (sb.buffered && sb.buffered.length) {
const end = sb.buffered.end(sb.buffered.length - 1) - 15;
const start = sb.buffered.start(0);
if (end > start) {
sb.remove(start, end);
ms.setLiveSeekableRange(end, end + 15);
}
// console.debug("VideoRTC.buffered", start, end);
}
} catch (e) {
// console.debug(e);
}
});
const buf = new Uint8Array(2 * 1024 * 1024);
let bufLen = 0;
this.ondata = data => {
if (sb.updating || bufLen > 0) {
const b = new Uint8Array(data);
buf.set(b, bufLen);
bufLen += b.byteLength;
// console.debug("VideoRTC.buffer", b.byteLength, bufLen);
} else {
try {
sb.appendBuffer(data);
} catch (e) {
// console.debug(e);
}
}
}
}
}
onwebrtc() {
const pc = new RTCPeerConnection(this.pcConfig);
/** @type {HTMLVideoElement} */
const video2 = document.createElement("video");
video2.addEventListener("loadeddata", ev => this.onpcvideo(ev), {once: true});
pc.addEventListener("icecandidate", ev => {
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : "";
this.send({type: "webrtc/candidate", value: candidate});
});
pc.addEventListener("track", ev => {
// when stream already init
if (video2.srcObject !== null) return;
// when audio track not exist in Chrome
if (ev.streams.length === 0) return;
// when audio track not exist in Firefox
if (ev.streams[0].id[0] === '{') return;
video2.srcObject = ev.streams[0];
});
pc.addEventListener("connectionstatechange", () => {
if (pc.connectionState === "failed" || pc.connectionState === "disconnected") {
pc.close(); // stop next events
this.pcState = WebSocket.CLOSED;
this.pc = null;
this.onconnect();
}
});
this.onmessage["webrtc"] = msg => {
switch (msg.type) {
case "webrtc/candidate":
pc.addIceCandidate({
candidate: msg.value,
sdpMid: "0"
}).catch(() => console.debug);
break;
case "webrtc/answer":
pc.setRemoteDescription({
type: "answer",
sdp: msg.value
}).catch(() => console.debug);
break;
case "error":
if (msg.value.indexOf("webrtc/offer") < 0) return;
pc.close();
}
};
// Safari doesn't support "offerToReceiveVideo"
pc.addTransceiver("video", {direction: "recvonly"});
pc.addTransceiver("audio", {direction: "recvonly"});
pc.createOffer().then(offer => {
pc.setLocalDescription(offer).then(() => {
this.send({type: "webrtc/offer", value: offer.sdp});
});
});
this.pcState = WebSocket.CONNECTING;
this.pc = pc;
}
/**
* @param ev {Event}
*/
onpcvideo(ev) {
if (!this.pc) return;
/** @type {HTMLVideoElement} */
const video2 = ev.target;
const state = this.pc.connectionState;
// Firefox doesn't support pc.connectionState
if (state === "connected" || state === "connecting" || !state) {
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
let rtcPriority = 0, msePriority = 0;
/** @type {MediaStream} */
const ms = video2.srcObject;
if (ms.getVideoTracks().length > 0) rtcPriority += 0x220;
if (ms.getAudioTracks().length > 0) rtcPriority += 0x102;
if (this.mseCodecs.indexOf("hvc1.") >= 0) msePriority += 0x230;
if (this.mseCodecs.indexOf("avc1.") >= 0) msePriority += 0x210;
if (this.mseCodecs.indexOf("mp4a.") >= 0) msePriority += 0x101;
if (rtcPriority >= msePriority) {
this.video.srcObject = ms;
this.play();
this.pcState = WebSocket.OPEN;
this.wsState = WebSocket.CLOSED;
this.ws.close();
this.ws = null;
} else {
this.pcState = WebSocket.CLOSED;
this.pc.close();
this.pc = null;
}
}
video2.srcObject = null;
}
onmjpeg() {
this.ondata = data => {
this.video.controls = false;
this.video.poster = "data:image/jpeg;base64," + VideoRTC.btoa(data);
};
this.send({type: "mjpeg"});
}
onmp4() {
/** @type {HTMLCanvasElement} **/
const canvas = document.createElement("canvas");
/** @type {CanvasRenderingContext2D} */
let context;
/** @type {HTMLVideoElement} */
const video2 = document.createElement("video");
video2.autoplay = true;
video2.playsInline = true;
video2.muted = true;
video2.addEventListener("loadeddata", ev => {
if (!context) {
canvas.width = video2.videoWidth;
canvas.height = video2.videoHeight;
context = canvas.getContext('2d');
}
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
this.video.controls = false;
this.video.poster = canvas.toDataURL("image/jpeg");
});
this.ondata = data => {
video2.src = "data:video/mp4;base64," + VideoRTC.btoa(data);
};
this.send({type: "mp4", value: this.codecs("mp4")});
}
static btoa(buffer) {
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
let binary = "";
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
}
+236 -135
View File
@@ -3,10 +3,7 @@
"frigate_card": "Frigate card", "frigate_card": "Frigate card",
"frigate_card_description": "A Lovelace card for use with Frigate", "frigate_card_description": "A Lovelace card for use with Frigate",
"live": "Live", "live": "Live",
"no_clip": "No recent clip", "no_media": "No media to display",
"no_clips": "No clips",
"no_snapshot": "No recent snapshot",
"no_snapshots": "No snapshots",
"recordings": "Recordings", "recordings": "Recordings",
"version": "Version" "version": "Version"
}, },
@@ -16,39 +13,152 @@
"dependencies": { "dependencies": {
"all_cameras": "Show events for all cameras with this camera", "all_cameras": "Show events for all cameras with this camera",
"cameras": "Show events for specific cameras with this camera", "cameras": "Show events for specific cameras with this camera",
"options": "Dependency Options" "editor_label": "Dependency Options"
},
"engines": {
"editor_label": "Camera engine options"
}, },
"frigate": { "frigate": {
"camera_name": "Frigate camera name (Autodetected from entity)", "camera_name": "Frigate camera name (Autodetected from entity)",
"client_id": "Frigate client id (For >1 Frigate server)", "client_id": "Frigate client id (For >1 Frigate server)",
"label": "Frigate label/object filter", "editor_label": "Frigate Options",
"options": "Frigate Options", "labels": "Frigate labels/object filters",
"url": "Frigate server URL", "url": "Frigate server URL",
"zone": "Frigate zone" "zones": "Frigate zones"
}, },
"go2rtc": {
"editor_label": "go2rtc Options",
"modes": {
"editor_label": "go2rtc Modes",
"mjpeg": "Motion JPEG (MJPEG)",
"mp4": "MPEG-4 (MP4)",
"mse": "Media Source Extensions (MSE)",
"webrtc": "Web Real-Time Communication (WebRTC)"
},
"stream": "go2rtc stream name"
},
"hide": "Hide camera from UI",
"icon": "Icon for this camera (Autodetected from entity)", "icon": "Icon for this camera (Autodetected from entity)",
"id": "Unique id for this camera in this card", "id": "Unique id for this camera in this card",
"image": {
"editor_label": "Image Options",
"refresh_seconds": "Number of seconds after which to refresh live image (0=never)",
"url": "Image URL to use instead of camera entity snapshot"
},
"live_provider": "Live view provider for this camera", "live_provider": "Live view provider for this camera",
"live_provider_options": {
"editor_label": "Live provider options"
},
"live_providers": { "live_providers": {
"auto": "Automatic", "auto": "Automatic",
"frigate-jsmpeg": "Frigate JSMpeg", "go2rtc": "go2rtc",
"ha": "Home Assistant (i.e. HLS, LL-HLS, WebRTC native)", "ha": "Home Assistant video stream (i.e. HLS, LL-HLS, WebRTC via HA)",
"image": "Home Assistant images",
"jsmpeg": "JSMpeg",
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
}, },
"motioneye": {
"editor_label": "MotionEye Options",
"images": {
"directory_pattern": "Images directory pattern",
"file_pattern": "Images file pattern"
},
"movies": {
"directory_pattern": "Movies directory pattern",
"file_pattern": "Movies file pattern"
},
"url": "MotionEye UI URL"
},
"title": "Title for this camera (Autodetected from entity)", "title": "Title for this camera (Autodetected from entity)",
"triggers": { "triggers": {
"editor_label": "Trigger Options",
"entities": "Trigger from other entities", "entities": "Trigger from other entities",
"motion": "Trigger by auto-detecting the motion sensor", "motion": "Trigger by auto-detecting the motion sensor",
"occupancy": "Trigger by auto-detecting the occupancy sensor", "occupancy": "Trigger by auto-detecting the occupancy sensor"
"options": "Trigger Options"
}, },
"webrtc_card": { "webrtc_card": {
"editor_label": "WebRTC Card Options",
"entity": "WebRTC Card Camera Entity (Not a Frigate camera)", "entity": "WebRTC Card Camera Entity (Not a Frigate camera)",
"options": "WebRTC Card Options",
"url": "WebRTC Card Camera URL" "url": "WebRTC Card Camera URL"
} }
}, },
"common": { "common": {
"controls": {
"filter": {
"editor_label": "Media Filter",
"mode": "Filter mode",
"modes": {
"left": "Media filter in a drawer to the left",
"none": "No media filter",
"right": "Media filter in a drawer to the right"
}
},
"next_previous": {
"editor_label": "Next & Previous",
"size": "Next & previous control size in pixels",
"style": "Next & previous control style",
"styles": {
"chevrons": "Chevrons",
"icons": "Icons",
"none": "None",
"thumbnails": "Thumbnails"
}
},
"thumbnails": {
"editor_label": "Thumbnails",
"media": "Whether to show thumbnails of clips or snapshots",
"medias": {
"clips": "Clip thumbnails",
"snapshots": "Snapshot thumbnails"
},
"mode": "Thumbnails mode",
"modes": {
"above": "Thumbnails above",
"below": "Thumbnails below",
"left": "Thumbnails in a drawer to the left",
"none": "No thumbnails",
"right": "Thumbnails in a drawer to the right"
},
"show_details": "Show details with thumbnails",
"show_download_control": "Show download control on thumbnails",
"show_favorite_control": "Show favorite control on thumbnails",
"show_timeline_control": "Show timeline control on thumbnails",
"size": "Thumbnails size in pixels"
},
"timeline": {
"editor_label": "Mini Timeline",
"mode": "Mode",
"modes": {
"above": "Above",
"below": "Below",
"none": "None"
}
},
"title": {
"duration_seconds": "Seconds to display popup title (0=forever)",
"editor_label": "Popup Title Controls",
"mode": "Popup title display mode",
"modes": {
"none": "No title display",
"popup-bottom-left": "Popup on the bottom left",
"popup-bottom-right": "Popup on the bottom right",
"popup-top-left": "Popup on the top left",
"popup-top-right": "Popup on the top right"
}
}
},
"layout": {
"fit": "Layout fit",
"fits": {
"contain": "Media is contained/letterboxed",
"cover": "Media expands proportionally to cover the card",
"fill": "Media is stretched to fill the card"
},
"position": {
"x": "Horizontal placement percentage",
"y": "Vertical placement percentage"
}
},
"media_action_conditions": { "media_action_conditions": {
"all": "All opportunities", "all": "All opportunities",
"hidden": "On browser/tab hiding", "hidden": "On browser/tab hiding",
@@ -57,17 +167,21 @@
"unselected": "On unselection", "unselected": "On unselection",
"visible": "On browser/tab visibility" "visible": "On browser/tab visibility"
}, },
"layout": { "timeline": {
"fit": "Layout fit", "clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
"fits": { "media": "The media the timeline displays",
"cover": "Media expands proportionally to cover the card", "medias": {
"contain": "Media is contained/letterboxed", "all": "All media types",
"fill": "Media is stretched to fill the card" "clips": "Clips",
"snapshots": "Snapshots"
}, },
"position": { "show_recordings": "Show recordings",
"x": "Horizontal placement percentage", "style": "Timeline style",
"y": "Vertical placement percentage" "styles": {
} "ribbon": "Events on a single ribbon",
"stack": "Stacked & clustered events"
},
"window_seconds": "The default length of the timeline view in seconds"
} }
}, },
"dimensions": { "dimensions": {
@@ -77,18 +191,9 @@
"dynamic": "Aspect ratio adjusts to media", "dynamic": "Aspect ratio adjusts to media",
"static": "Static aspect ratio", "static": "Static aspect ratio",
"unconstrained": "Unconstrained aspect ratio" "unconstrained": "Unconstrained aspect ratio"
} },
}, "max_height": "Maximum card height in CSS units (e.g. '100vh')",
"event_gallery": { "min_height": "Minimum card height in CSS units (e.g. '100px')"
"controls": {
"options": "Event Gallery Controls",
"thumbnails": {
"show_details": "Show event details with thumbnails",
"show_favorite_control": "Show favorite control on thumbnails",
"show_timeline_control": "Show timeline control on thumbnails",
"size": "Event Gallery thumbnails size in pixels"
}
}
}, },
"image": { "image": {
"layout": "Image Layout", "layout": "Image Layout",
@@ -107,32 +212,7 @@
"auto_play": "Automatically play live cameras", "auto_play": "Automatically play live cameras",
"auto_unmute": "Automatically unmute live cameras", "auto_unmute": "Automatically unmute live cameras",
"controls": { "controls": {
"next_previous": { "editor_label": "Live Controls"
"size": "Live view next & previous control size in pixels",
"style": "Live view next & previous control style",
"styles": {
"chevrons": "Chevrons",
"icons": "Icons",
"none": "None"
}
},
"options": "Live Controls",
"thumbnails": {
"media": "Whether to show thumbnails of clips or snapshots",
"medias": {
"clips": "Clip thumbnails",
"snapshots": "Snapshot thumbnails"
},
"mode": "Live thumbnails mode",
"show_details": "Show event details with thumbnails",
"show_favorite_control": "Show favorite control on thumbnails",
"show_timeline_control": "Show timeline control on thumbnails",
"size": "Live thumbnails size in pixels"
},
"title": {
"duration_seconds": "Seconds to display popup title (0=forever)",
"mode": "Live media title display mode"
}
}, },
"draggable": "Live cameras view can be dragged/swiped", "draggable": "Live cameras view can be dragged/swiped",
"layout": "Live Layout", "layout": "Live Layout",
@@ -148,45 +228,12 @@
"auto_play": "Automatically play media", "auto_play": "Automatically play media",
"auto_unmute": "Automatically unmute media", "auto_unmute": "Automatically unmute media",
"controls": { "controls": {
"next_previous": { "editor_label": "Media Viewer Controls"
"size": "Media Viewer next & previous control size in pixels",
"style": "Media Viewer next & previous control style",
"styles": {
"chevrons": "Chevrons",
"none": "None",
"thumbnails": "Thumbnails"
}
},
"options": "Media Viewer Controls",
"thumbnails": {
"mode": "Media Viewer thumbnails mode",
"modes": {
"above": "Thumbnails above the media",
"below": "Thumbnails below the media",
"left": "Thumbnails in a drawer left of the media",
"none": "No thumbnails",
"right": "Thumbnails in a drawer right of the media"
},
"show_details": "Show details with thumbnails",
"show_favorite_control": "Show favorite control on thumbnails",
"show_timeline_control": "Show timeline control on thumbnails",
"size": "Media Viewer thumbnails size in pixels"
},
"title": {
"duration_seconds": "Seconds to display popup title (0=forever)",
"mode": "Media Viewer media title display mode",
"modes": {
"none": "No title display",
"popup-bottom-left": "Popup on the bottom left",
"popup-bottom-right": "Popup on the bottom right",
"popup-top-left": "Popup on the top left",
"popup-top-right": "Popup on the top right"
}
}
}, },
"draggable": "Media Viewer can be dragged/swiped", "draggable": "Media Viewer can be dragged/swiped",
"lazy_load": "Media Viewer media is lazily loaded in carousel",
"layout": "Media Viewer Layout", "layout": "Media Viewer Layout",
"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_effect": "Media Viewer transition effect",
"transition_effects": { "transition_effects": {
"none": "No transition", "none": "No transition",
@@ -208,19 +255,22 @@
"matching": "Matching the menu alignment", "matching": "Matching the menu alignment",
"opposing": "Opposing the menu alignment" "opposing": "Opposing the menu alignment"
}, },
"camera_ui": "Camera user interface",
"cameras": "Cameras", "cameras": "Cameras",
"clips": "Clips", "clips": "Clips",
"download": "Download", "download": "Download",
"enabled": "Button enabled", "enabled": "Button enabled",
"expand": "Expand",
"frigate": "Frigate menu / Default view", "frigate": "Frigate menu / Default view",
"frigate_ui": "Frigate user interface",
"fullscreen": "Fullscreen", "fullscreen": "Fullscreen",
"icon": "Icon", "icon": "Icon",
"image": "Image", "image": "Image",
"live": "Live", "live": "Live",
"media_player": "Send to media player", "media_player": "Send to media player",
"priority": "Priority", "priority": "Priority",
"recordings": "Recordings",
"snapshots": "Snapshots", "snapshots": "Snapshots",
"substreams": "Substream(s)",
"timeline": "Timeline" "timeline": "Timeline"
}, },
"position": "Menu position", "position": "Menu position",
@@ -234,6 +284,7 @@
"styles": { "styles": {
"hidden": "Hidden menu", "hidden": "Hidden menu",
"hover": "Hover menu", "hover": "Hover menu",
"hover-card": "Hover menu (card-wide)",
"none": "No menu", "none": "No menu",
"outside": "Outside menu", "outside": "Outside menu",
"overlay": "Overlay menu" "overlay": "Overlay menu"
@@ -242,26 +293,23 @@
"overrides": { "overrides": {
"info": "This card configuration has manually specified overrides configured which may override values shown in the visual editor, please consult the code editor to view/modify these overrides" "info": "This card configuration has manually specified overrides configured which may override values shown in the visual editor, please consult the code editor to view/modify these overrides"
}, },
"timeline": { "performance": {
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)", "features": {
"controls": { "animated_progress_indicator": "Animated Progress Indicator",
"options": "Timeline Controls", "editor_label": "Feature Options",
"thumbnails": { "media_chunk_size": "Media chunk size"
"mode": "Timeline thumbnails mode",
"show_details": "Show event details with thumbnails",
"show_favorite_control": "Show favorite control on thumbnails",
"show_timeline_control": "Show timeline control on thumbnails",
"size": "Timeline thumbnails size in pixels"
}
}, },
"media": "The media the timeline displays", "profile": "Performance profile",
"medias": { "profiles": {
"all": "All media types", "high": "High/full performance",
"clips": "Clips", "low": "Low performance"
"snapshots": "Snapshots"
}, },
"show_recordings": "Show recordings", "style": {
"window_seconds": "The default length of the timeline view in seconds" "border_radius": "Curves",
"box_shadow": "Shadows",
"editor_label": "Style Options"
},
"warning": "This card is in low profile mode so defaults have changed to optimize performance"
}, },
"view": { "view": {
"camera_select": "View for newly selected cameras", "camera_select": "View for newly selected cameras",
@@ -289,6 +337,8 @@
"current": "Current view", "current": "Current view",
"image": "Static image", "image": "Static image",
"live": "Live view", "live": "Live view",
"recording": "Most recent recording",
"recordings": "Recordings gallery",
"snapshot": "Most recent snapshot", "snapshot": "Most recent snapshot",
"snapshots": "Snapshots gallery", "snapshots": "Snapshots gallery",
"timeline": "Timeline view" "timeline": "Timeline view"
@@ -304,12 +354,12 @@
"delete": "Delete", "delete": "Delete",
"dimensions": "Dimensions", "dimensions": "Dimensions",
"dimensions_secondary": "Dimensions & shape options", "dimensions_secondary": "Dimensions & shape options",
"event_gallery": "Event gallery",
"event_gallery_secondary": "Snapshots & clips gallery options",
"image": "Image", "image": "Image",
"image_secondary": "Static image view options", "image_secondary": "Static image view options",
"live": "Live", "live": "Live",
"live_secondary": "Live camera view options", "live_secondary": "Live camera view options",
"media_gallery": "Media gallery",
"media_gallery_secondary": "Media gallery options",
"media_viewer": "Media viewer", "media_viewer": "Media viewer",
"media_viewer_secondary": "Viewer for static media (clips, snapshots or recordings)", "media_viewer_secondary": "Viewer for static media (clips, snapshots or recordings)",
"menu": "Menu", "menu": "Menu",
@@ -318,6 +368,8 @@
"move_up": "Move up", "move_up": "Move up",
"overrides": "Overrides are active", "overrides": "Overrides are active",
"overrides_secondary": "Dynamic configuration overrides detected", "overrides_secondary": "Dynamic configuration overrides detected",
"performance": "Performance",
"performance_secondary": "Card performance options",
"timeline": "Timeline", "timeline": "Timeline",
"timeline_secondary": "Event timeline options", "timeline_secondary": "Event timeline options",
"upgrade": "Upgrade", "upgrade": "Upgrade",
@@ -325,11 +377,21 @@
"view": "View", "view": "View",
"view_secondary": "What the card should show and how to show it" "view_secondary": "What the card should show and how to show it"
}, },
"elements": {
"ptz": {
"down": "Down",
"home": "Home",
"left": "Left",
"right": "Right",
"up": "Up",
"zoom_in": "Zoom In",
"zoom_out": "Zoom Out"
}
},
"error": { "error": {
"could_not_render_elements": "Could not render picture elements", "could_not_render_elements": "Could not render picture elements",
"could_not_resolve": "Could not resolve media URL", "could_not_resolve": "Could not resolve media URL",
"diagnostics": "Card diagnostics. Please review for confidential information prior to sharing", "diagnostics": "Card diagnostics. Please review for confidential information prior to sharing",
"download_no_event_id": "Could not extract Frigate event id from media",
"download_no_media": "No media to download", "download_no_media": "No media to download",
"download_sign_failed": "Could not sign media URL for download", "download_sign_failed": "Could not sign media URL for download",
"duplicate_camera_id": "Duplicate Frigate camera id for the following camera, use the 'id' parameter to uniquely identify cameras", "duplicate_camera_id": "Duplicate Frigate camera id for the following camera, use the 'id' parameter to uniquely identify cameras",
@@ -343,13 +405,16 @@
"invalid_elements_config": "Invalid picture elements configuration", "invalid_elements_config": "Invalid picture elements configuration",
"invalid_response": "Received invalid response from Home Assistant for request", "invalid_response": "Received invalid response from Home Assistant for request",
"jsmpeg_no_player": "Could not start JSMPEG player", "jsmpeg_no_player": "Could not start JSMPEG player",
"jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", "live_camera_no_endpoint": "Could not get camera endpoint for this live provider (incomplete configuration?)",
"live_camera_not_found": "The configured camera_entity was not found", "live_camera_not_found": "The configured camera_entity was not found",
"live_camera_unavailable": "Camera unavailable", "live_camera_unavailable": "Camera unavailable",
"no_camera_engine": "Could not determine suitable engine for camera",
"no_camera_entity": "Could not find camera entity",
"no_camera_entity_for_triggers": "A camera entity is required in order to autodetect triggers",
"no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", "no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually",
"no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'", "no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'",
"no_cameras": "No valid cameras found, you must configure at least one camera entry",
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider", "no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
"reconnecting": "Reconnecting", "reconnecting": "Reconnecting",
"timeline_no_cameras": "No Frigate cameras to show in timeline", "timeline_no_cameras": "No Frigate cameras to show in timeline",
"troubleshooting": "Check troubleshooting", "troubleshooting": "Check troubleshooting",
@@ -359,29 +424,65 @@
"webrtc_card_waiting": "Waiting for WebRTC Card to load ..." "webrtc_card_waiting": "Waiting for WebRTC Card to load ..."
}, },
"event": { "event": {
"camera": "Camera",
"duration": "Duration", "duration": "Duration",
"in_progress": "In Progress", "in_progress": "In Progress",
"score": "Score", "score": "Score",
"start": "Start" "seek": "Seek",
"start": "Start",
"tag": "Tag",
"what": "What",
"where": "Where"
},
"media_filter": {
"all": "All",
"camera": "Camera",
"favorite": "Favorite",
"media_type": "Media Type",
"media_types": {
"clips": "Clips",
"recordings": "Recordings",
"snapshots": "Snapshots"
},
"not_favorite": "Not Favorite",
"select_camera": "Select camera...",
"select_favorite": "Select favorite...",
"select_media_type": "Select media type...",
"select_tag": "Select tag...",
"select_what": "Select what...",
"select_when": "Select when...",
"select_where": "Select where...",
"tag": "Tag",
"what": "What",
"when": "When",
"whens": {
"past_month": "Past Month",
"past_week": "Past Week",
"today": "Today",
"yesterday": "Yesterday"
},
"where": "Where"
}, },
"recording": { "recording": {
"camera": "Camera",
"duration": "Duration",
"events": "Events", "events": "Events",
"seek": "Seek" "in_progress": "In Progress",
"seek": "Seek",
"start": "Start"
}, },
"thumbnail": { "thumbnail": {
"download": "Download media",
"no_thumbnail": "No thumbnail available", "no_thumbnail": "No thumbnail available",
"retain_indefinitely": "Event will be indefinitely retained", "retain_indefinitely": "Media will be indefinitely retained",
"timeline": "See event in timeline" "timeline": "See media in timeline"
}, },
"elements": { "timeline": {
"ptz": { "pan_behavior": {
"up": "Up", "pan": "Pan",
"down": "Down", "seek": "Pan seeks across all media",
"left": "Left", "seek-in-media": "Pan seeks within selected media item only"
"right": "Right", },
"zoom_in": "Zoom In", "select_date": "Choose date"
"zoom_out": "Zoom Out",
"home": "Home"
}
} }
} }
+230 -113
View File
@@ -3,10 +3,7 @@
"frigate_card": "Frigate card", "frigate_card": "Frigate card",
"frigate_card_description": "Una scheda Lovelace per l'uso con Frigate", "frigate_card_description": "Una scheda Lovelace per l'uso con Frigate",
"live": "Live", "live": "Live",
"no_clip": "Nessuna clip recente", "no_media": "Nessun contenuto multimediale da visualizzare",
"no_clips": "Nessun clip",
"no_snapshot": "Nessuna istantanea recente",
"no_snapshots": "Nessuna istantanea",
"recordings": "Registrazioni", "recordings": "Registrazioni",
"version": "Versione" "version": "Versione"
}, },
@@ -16,39 +13,152 @@
"dependencies": { "dependencies": {
"all_cameras": "Mostra eventi per tutte le telecamere con questa telecamera", "all_cameras": "Mostra eventi per tutte le telecamere con questa telecamera",
"cameras": "Mostra eventi per telecamere specifiche con questa telecamera", "cameras": "Mostra eventi per telecamere specifiche con questa telecamera",
"options": "Opzioni di dipendenza" "editor_label": "Opzioni di dipendenza"
},
"engines": {
"editor_label": "Opzioni del motore della fotocamera"
}, },
"frigate": { "frigate": {
"camera_name": "Nome della telecamera frigate (autodificato dall'entità)", "camera_name": "Nome della telecamera frigate (autodificato dall'entità)",
"client_id": "ID client Frigate (per > 1 Frigate server)", "client_id": "ID client Frigate (per > 1 Frigate server)",
"label": "Filtro etichetta/oggetto Frigate", "editor_label": "Frigate Opzione",
"options": "Frigate Opzione", "labels": "Etichette per fregate/filtri per oggetti",
"url": "Frigate URL del server", "url": "Frigate URL del server",
"zone": "Frigate zona" "zones": "Frigate Zone"
}, },
"go2rtc": {
"editor_label": "Opzioni go2rtc",
"modes": {
"editor_label": "Modalità go2rtc",
"mjpeg": "JPEG animato (MJPEG)",
"mp4": "MPEG-4 (MP4)",
"mse": "Estensioni sorgente multimediale (MSE)",
"webrtc": "Comunicazione Web in tempo reale (WebRTC)"
},
"stream": "nome del flusso go2rtc"
},
"hide": "Nascondi la videocamera dall'interfaccia utente",
"icon": "Icona per questa telecamera (Autoidentificato dall'entità)", "icon": "Icona per questa telecamera (Autoidentificato dall'entità)",
"id": "ID univoco per questa telecamera in questa carta", "id": "ID univoco per questa telecamera in questa carta",
"image": {
"editor_label": "Opzioni immagine",
"refresh_seconds": "Numero di secondi dopo i quali aggiornare l'immagine live (0=mai)",
"url": "URL dell'immagine da utilizzare al posto dell'istantanea dell'entità fotocamera"
},
"live_provider": "Provider di visualizzazione dal vivo per questa telecamera", "live_provider": "Provider di visualizzazione dal vivo per questa telecamera",
"live_provider_options": {
"editor_label": "Opzioni del fornitore in tempo reale"
},
"live_providers": { "live_providers": {
"auto": "Automatica", "auto": "Automatica",
"frigate-jsmpeg": "Frigate JSMpeg", "go2rtc": "go2rtc",
"ha": "Home Assistant (ovvero HLS, LL-HLS, WebRTC nativo)", "ha": "Streaming video di Home Assistant (ovvero HLS, LL-HLS, WebRTC tramite HA)",
"image": "Immagini Home Assistant",
"jsmpeg": "JSMpeg",
"webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)" "webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)"
}, },
"motioneye": {
"editor_label": "Opzioni di MotionEye",
"images": {
"directory_pattern": "Modello di directory delle immagini",
"file_pattern": "Modello di file di immagini"
},
"movies": {
"directory_pattern": "Modello di directory dei film",
"file_pattern": "Modello di file di film"
},
"url": "URL dell'interfaccia utente di MotionEye"
},
"title": "Titolo per questa telecamera (Autoidentificato dall'entità)", "title": "Titolo per questa telecamera (Autoidentificato dall'entità)",
"triggers": { "triggers": {
"editor_label": "Trigger Opzioni",
"entities": "Trigger da altre entità", "entities": "Trigger da altre entità",
"motion": "Trigger rilevando automaticamente dal sensore di movimento", "motion": "Trigger rilevando automaticamente dal sensore di movimento",
"occupancy": "Attivare rilevando automatico tramite il sensore di presenza", "occupancy": "Attivare rilevando automatico tramite il sensore di presenza"
"options": "Trigger Opzioni"
}, },
"webrtc_card": { "webrtc_card": {
"editor_label": "Opzioni della scheda WebRTC",
"entity": "Entità della telecamera della scheda WebRTC (non una telecamera Frigate)", "entity": "Entità della telecamera della scheda WebRTC (non una telecamera Frigate)",
"options": "Opzioni della scheda WebRTC",
"url": "URL della telecamera della scheda WebRTC" "url": "URL della telecamera della scheda WebRTC"
} }
}, },
"common": { "common": {
"controls": {
"filter": {
"editor_label": "Filtro multimediale",
"mode": "Modalità filtro",
"modes": {
"left": "Filtro multimediale in un cassetto a sinistra",
"none": "Nessun filtro multimediale",
"right": "Filtro multimediale in un cassetto a destra"
}
},
"next_previous": {
"editor_label": "Successivo e precedente",
"size": "Successiva e Precedenti dimensioni di controllo nei pixel",
"style": "Stile di controllo successivo e precedente",
"styles": {
"chevrons": "Chevrons",
"icons": "Icone",
"none": "Nessuno",
"thumbnails": "Miniature"
}
},
"thumbnails": {
"editor_label": "Miniature",
"media": "Se mostrare miniature di clip o istantanee",
"medias": {
"clips": "Miniature di clip",
"snapshots": "Miniature istantanee"
},
"mode": "Modalità miniatura",
"modes": {
"above": "Miniature sopra",
"below": "Miniature sotto",
"left": "Miniature in un cassetto a sinistra",
"none": "Nessuna miniatura",
"right": "Miniature in un cassetto a destra"
},
"show_details": "Mostra i dettagli con le miniature",
"show_download_control": "Mostra il controllo del download sulle miniature",
"show_favorite_control": "Mostra il controllo preferito sulle miniature",
"show_timeline_control": "Mostra il controllo della sequenza temporale sulle miniature",
"size": "Dimensione delle miniature in pixel"
},
"timeline": {
"editor_label": "Mini Cronologia",
"mode": "Modalità",
"modes": {
"above": "sopra",
"below": "sotto",
"none": "sessuna"
}
},
"title": {
"duration_seconds": "Secondi per visualizzare il titolo popup (0 = per sempre)",
"editor_label": "Controlli titolo popup",
"mode": "Modalità di visualizzazione del titolo",
"modes": {
"none": "Nessuna visualizzazione del titolo",
"popup-bottom-left": "Popup in basso a sinistra",
"popup-bottom-right": "Popup in basso a destra",
"popup-top-left": "Popup in alto a sinistra",
"popup-top-right": "Popup in alto a destra"
}
}
},
"layout": {
"fit": "Adatta al layout",
"fits": {
"contain": "Il supporto è contenuto/in cassetta delle lettere",
"cover": "Il supporto si espande proporzionalmente per coprire la scheda",
"fill": "Il supporto viene allungato per riempire la scheda"
},
"position": {
"x": "Percentuale di posizionamento orizzontale",
"y": "Percentuale di posizionamento verticale"
}
},
"media_action_conditions": { "media_action_conditions": {
"all": "Tutte le opportunità", "all": "Tutte le opportunità",
"hidden": "Sul browser/nascondere le schede", "hidden": "Sul browser/nascondere le schede",
@@ -56,6 +166,22 @@
"selected": "Sulla selezione", "selected": "Sulla selezione",
"unselected": "Sulla non selezione", "unselected": "Sulla non selezione",
"visible": "Sul browser/visibilità della scheda" "visible": "Sul browser/visibilità della scheda"
},
"timeline": {
"clustering_threshold": "Il conteggio degli eventi in cui sono raggruppati (0 = nessun clustering)",
"media": "I media vengono visualizzati la sequenza temporale",
"medias": {
"all": "Tutti i tipi di media",
"clips": "Clip",
"snapshots": "Istantanee"
},
"show_recordings": "Mostra registrazioni",
"style": "",
"styles": {
"ribbon": "",
"stack": ""
},
"window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi"
} }
}, },
"dimensions": { "dimensions": {
@@ -65,20 +191,12 @@
"dynamic": "Le proporzioni si adattano ai media", "dynamic": "Le proporzioni si adattano ai media",
"static": "Proporzioni statiche", "static": "Proporzioni statiche",
"unconstrained": "Proporzioni non vincolate" "unconstrained": "Proporzioni non vincolate"
} },
}, "max_height": "",
"event_gallery": { "min_height": ""
"controls": {
"options": "Controlli della galleria degli eventi",
"thumbnails": {
"show_details": "Mostra i dettagli dell'evento con le miniature",
"show_favorite_control": "Mostra il controllo preferito sulle miniature",
"show_timeline_control": "Mostra il controllo della sequenza temporale sulle miniature",
"size": "Dimensione delle miniature della galleria di eventi nei pixel"
}
}
}, },
"image": { "image": {
"layout": "Disposizione dell'immagine",
"mode": "Modalità Visualizza immagine", "mode": "Modalità Visualizza immagine",
"modes": { "modes": {
"camera": "Istantanea della telecamera di Home Assistant dell'entità telecamera", "camera": "Istantanea della telecamera di Home Assistant dell'entità telecamera",
@@ -94,38 +212,14 @@
"auto_play": "Gioca automaticamente le telecamere dal vivo", "auto_play": "Gioca automaticamente le telecamere dal vivo",
"auto_unmute": "Riattiva automaticamente l'audio delle telecamere live", "auto_unmute": "Riattiva automaticamente l'audio delle telecamere live",
"controls": { "controls": {
"next_previous": { "editor_label": "Controlli dal vivo"
"size": "Vista live Successiva e Precedenti dimensioni di controllo nei pixel",
"style": "Stile di controllo successivo e precedente della vista dal vivo",
"styles": {
"chevrons": "Chevrons",
"icons": "Icone",
"none": "Icone"
}
},
"options": "Controlli dal vivo",
"thumbnails": {
"media": "Se mostrare miniature di clip o istantanee",
"medias": {
"clips": "Miniature di clip",
"snapshots": "Miniature istantanee"
},
"mode": "Modalità di miniatura dal vivo",
"show_details": "Mostra i dettagli dell'evento con le miniature",
"show_favorite_control": "Mostra il controllo preferito sulle miniature",
"show_timeline_control": "Mostra il controllo della sequenza temporale sulle miniature",
"size": "Dimensione delle miniature dal vivo nei pixel"
},
"title": {
"duration_seconds": "Secondi per visualizzare il titolo popup (0 = per sempre)",
"mode": "Modalità di visualizzazione del titolo multimediale dal vivo"
}
}, },
"draggable": "Il Visualizzatore eventi può essere trascinato oppure puoi scorrere", "draggable": "Il Visualizzatore eventi può essere trascinato oppure puoi scorrere",
"layout": "Disposizione dal vivo",
"lazy_load": "Le telecamere dal vivo sono pigramente cariche", "lazy_load": "Le telecamere dal vivo sono pigramente cariche",
"lazy_unload": "Le telecamere dal vivo sono pigramente non caricate", "lazy_unload": "Le telecamere dal vivo sono pigramente non caricate",
"preload": "Precarica Live View in background", "preload": "Precarica Live View in background",
"show_image_during_load": "", "show_image_during_load": "Mostra un'immagine fissa durante il caricamento del live streaming",
"transition_effect": "Effetto di transizione della telecamera dal vivo" "transition_effect": "Effetto di transizione della telecamera dal vivo"
}, },
"media_viewer": { "media_viewer": {
@@ -134,43 +228,10 @@
"auto_play": "Riproduci automaticamente i contenuti multimediali", "auto_play": "Riproduci automaticamente i contenuti multimediali",
"auto_unmute": "Riattiva automaticamente i contenuti multimediali", "auto_unmute": "Riattiva automaticamente i contenuti multimediali",
"controls": { "controls": {
"next_previous": { "editor_label": "Controlli di visualizzatore multimediale"
"size": "Media Viewer successivo e precedente controllo dimensione in pixel",
"style": "Visualizzatore multimediale successivo e stile di controllo precedente",
"styles": {
"chevrons": "chevrons",
"none": "Nessuno",
"thumbnails": "Miniature"
}
},
"options": "Controlli di visualizzatore multimediale",
"thumbnails": {
"mode": "Modalità miniature del visualizzatore multimediale",
"modes": {
"above": "Miniature sopra i media",
"below": "Miniature sotto i media",
"left": "Miniature in un cassetto a sinistra del supporto",
"none": "Nessuna miniatura",
"right": "Miniature in un cassetto a destra dei media"
},
"show_details": "Mostra i dettagli con le miniature",
"show_favorite_control": "Mostra il controllo preferito sulle miniature",
"show_timeline_control": "Mostra il controllo della sequenza temporale sulle miniature",
"size": "Dimensioni delle miniature di Media Viewer in pixel"
},
"title": {
"duration_seconds": "Secondi per visualizzare il titolo popup (0 = per sempre)",
"mode": "Media Viewer modalità di visualizzazione del titolo multimediale",
"modes": {
"none": "Nessuna visualizzazione del titolo",
"popup-bottom-left": "Popup in basso a sinistra",
"popup-bottom-right": "Popup in basso a destra",
"popup-top-left": "Popup in alto a sinistra",
"popup-top-right": "Popup in alto a destra"
}
}
}, },
"draggable": "Il visualizzatore multimediale può essere trascinato oppure può scorrere", "draggable": "Il visualizzatore multimediale può essere trascinato oppure può scorrere",
"layout": "Layout del visualizzatore multimediale",
"lazy_load": "Il media Viewer viene caricato pigramente nel carosello", "lazy_load": "Il media Viewer viene caricato pigramente nel carosello",
"transition_effect": "Effetto di transizione del visualizzatore multimediale", "transition_effect": "Effetto di transizione del visualizzatore multimediale",
"transition_effects": { "transition_effects": {
@@ -193,12 +254,13 @@
"matching": "Corrispondenza con l'allineamento del menu", "matching": "Corrispondenza con l'allineamento del menu",
"opposing": "Contrastare l'allineamento del menu" "opposing": "Contrastare l'allineamento del menu"
}, },
"camera_ui": "Interfaccia utente della fotocamera",
"cameras": "Telecamere", "cameras": "Telecamere",
"clips": "Clip", "clips": "Clip",
"download": "Download", "download": "Download",
"enabled": "Pulsante abilitato", "enabled": "Pulsante abilitato",
"expand": "Espandere",
"frigate": "Frigate menu / Visualizzazione predefinita", "frigate": "Frigate menu / Visualizzazione predefinita",
"frigate_ui": "Frigate interfaccia utente",
"fullscreen": "A schermo intero", "fullscreen": "A schermo intero",
"icon": "Icona", "icon": "Icona",
"image": "Immagine", "image": "Immagine",
@@ -206,6 +268,7 @@
"media_player": "Invia a Media Player", "media_player": "Invia a Media Player",
"priority": "Priorità", "priority": "Priorità",
"snapshots": "Istantanee", "snapshots": "Istantanee",
"substreams": "Flusso/i secondario/i",
"timeline": "Timeline" "timeline": "Timeline"
}, },
"position": "Posizione del menu", "position": "Posizione del menu",
@@ -227,26 +290,23 @@
"overrides": { "overrides": {
"info": "Questa configurazione della scheda ha specificato manualmente le sostituzioni configurate che possono sostituire i valori mostrati nell'editor visivo, consultare l'editor di codice per visualizzare/modificare queste sostituzioni" "info": "Questa configurazione della scheda ha specificato manualmente le sostituzioni configurate che possono sostituire i valori mostrati nell'editor visivo, consultare l'editor di codice per visualizzare/modificare queste sostituzioni"
}, },
"timeline": { "performance": {
"clustering_threshold": "Il conteggio degli eventi in cui sono raggruppati (0 = nessun clustering)", "features": {
"controls": { "animated_progress_indicator": "Indicatore di avanzamento animato",
"options": "Controlli della sequenza temporale", "editor_label": "Opzioni funzionalità",
"thumbnails": { "media_chunk_size": "Dimensione del blocco multimediale"
"mode": "Modalità miniatura della sequenza temporale",
"show_details": "Mostra i dettagli dell'evento con le miniature",
"show_favorite_control": "Mostra il controllo preferito sulle miniature",
"show_timeline_control": "Mostra il controllo della sequenza temporale sulle miniature",
"size": "Dimensione delle miniature della sequenza temporale in pixel"
}
}, },
"media": "I media vengono visualizzati la sequenza temporale", "profile": "Profilo delle prestazioni",
"medias": { "profiles": {
"all": "Tutti i tipi di media", "high": "Prestazioni alte",
"clips": "Clip", "low": "Prestazioni basse"
"snapshots": "Istantanee"
}, },
"show_recordings": "Mostra registrazioni", "style": {
"window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi" "border_radius": "Curve",
"box_shadow": "Ombre",
"editor_label": "Opzione di stile"
},
"warning": "Questa scheda è in modalità basso profilo, quindi le impostazioni predefinite sono state modificate per ottimizzare le prestazioni"
}, },
"view": { "view": {
"camera_select": "Visualizza per le telecamere appena selezionate", "camera_select": "Visualizza per le telecamere appena selezionate",
@@ -289,12 +349,12 @@
"delete": "Elimina", "delete": "Elimina",
"dimensions": "Dimensioni", "dimensions": "Dimensioni",
"dimensions_secondary": "Dimensioni e opzioni di forma", "dimensions_secondary": "Dimensioni e opzioni di forma",
"event_gallery": "Galleria degli eventi",
"event_gallery_secondary": "Opzioni della galleria di istantanee e clips",
"image": "Immagine", "image": "Immagine",
"image_secondary": "Opzioni di visualizzazione dell'immagine statica", "image_secondary": "Opzioni di visualizzazione dell'immagine statica",
"live": "Live", "live": "Live",
"live_secondary": "Opzioni di visualizzazione della telecamera live", "live_secondary": "Opzioni di visualizzazione della telecamera live",
"media_gallery": "Galleria multimediale",
"media_gallery_secondary": "Opzioni della galleria multimediale",
"media_viewer": "Visualizzatore dei media", "media_viewer": "Visualizzatore dei media",
"media_viewer_secondary": "Visualizzatore per supporti statici (clip, istantanee o registrazioni)", "media_viewer_secondary": "Visualizzatore per supporti statici (clip, istantanee o registrazioni)",
"menu": "Menu", "menu": "Menu",
@@ -310,11 +370,21 @@
"view": "Visualizzazione", "view": "Visualizzazione",
"view_secondary": "Cosa dovrebbe mostrare la carta e come mostrarla" "view_secondary": "Cosa dovrebbe mostrare la carta e come mostrarla"
}, },
"elements": {
"ptz": {
"down": "Giù",
"home": "Home",
"left": "Sinistra",
"right": "Destra",
"up": "Su",
"zoom_in": "Ingrandire",
"zoom_out": "Zoom indietro"
}
},
"error": { "error": {
"could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine", "could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine",
"could_not_resolve": "Impossibile risolvere l'URL dei media", "could_not_resolve": "Impossibile risolvere l'URL dei media",
"diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere", "diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere",
"download_no_event_id": "Impossibile estrarre l'evento ID tramite media",
"download_no_media": "Nessun media da scaricare", "download_no_media": "Nessun media da scaricare",
"download_sign_failed": "Impossibile firmare URL multimediale per il download", "download_sign_failed": "Impossibile firmare URL multimediale per il download",
"duplicate_camera_id": "Duplicato ID dellla telecamera Frigate, utilizzare il parametro 'ID' per identificare in modo univoco le telecamere", "duplicate_camera_id": "Duplicato ID dellla telecamera Frigate, utilizzare il parametro 'ID' per identificare in modo univoco le telecamere",
@@ -328,13 +398,16 @@
"invalid_elements_config": "Configurazione degli elementi di immagine non valida", "invalid_elements_config": "Configurazione degli elementi di immagine non valida",
"invalid_response": "Ricevuta una risposta non valida da Home Assistant per la richiesta", "invalid_response": "Ricevuta una risposta non valida da Home Assistant per la richiesta",
"jsmpeg_no_player": "Impossibile avviare JSMPEG Player", "jsmpeg_no_player": "Impossibile avviare JSMPEG Player",
"jsmpeg_no_sign": "Impossibile recuperare o firmare il percorso WebSocket JSMPEG", "live_camera_no_endpoint": "Impossibile ottenere l'endpoint della videocamera per questo provider live (configurazione incompleta?)",
"live_camera_not_found": "La telecamera configurata non è stata trovata", "live_camera_not_found": "La telecamera configurata non è stata trovata",
"live_camera_unavailable": "Telecamera non disponibile", "live_camera_unavailable": "Telecamera non disponibile",
"no_camera_engine": "Impossibile determinare il motore adatto per la fotocamera",
"no_camera_entity": "Impossibile trovare l'entità fotocamera",
"no_camera_entity_for_triggers": "È necessaria un'entità telecamera per rilevare automaticamente i trigger",
"no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'", "no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'",
"no_camera_name": "Impossibile determinare un nome della telecamera in Frigate, si prega di specificare 'camera_enty' o 'camera_name'", "no_camera_name": "Impossibile determinare un nome della telecamera in Frigate, si prega di specificare 'camera_enty' o 'camera_name'",
"no_cameras": "Nessuna telecamera valida trovata, è necessario configurare almeno una voce della telecamera",
"no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live", "no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live",
"no_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta",
"reconnecting": "Riconnessione", "reconnecting": "Riconnessione",
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline", "timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
"troubleshooting": "Controllare la risoluzione dei problemi", "troubleshooting": "Controllare la risoluzione dei problemi",
@@ -344,18 +417,62 @@
"webrtc_card_waiting": "Aspettando che la scheda WebRTC si carichi ..." "webrtc_card_waiting": "Aspettando che la scheda WebRTC si carichi ..."
}, },
"event": { "event": {
"camera": "Camera",
"duration": "Durata", "duration": "Durata",
"in_progress": "In corso", "in_progress": "In corso",
"score": "Punteggio", "score": "Punteggio",
"start": "Avvia" "seek": "Cercare",
"start": "Avvia",
"what": "Che cosa",
"where": "Dove"
},
"media_filter": {
"all": "Tutto",
"camera": "Telecamera",
"favorite": "Preferito",
"media_type": "Tipo di supporto",
"media_types": {
"clips": "Clip",
"recordings": "Registrazioni",
"snapshots": "Istantanee"
},
"not_favorite": "Non preferito",
"select_camera": "Seleziona fotocamera...",
"select_favorite": "Seleziona preferito...",
"select_media_type": "Seleziona il tipo di supporto...",
"select_what": "Seleziona cosa...",
"select_when": "Seleziona quando...",
"select_where": "Seleziona dove...",
"tag": "Tag",
"what": "Che cosa",
"when": "Quando",
"whens": {
"past_month": "Mese scorso",
"past_week": "Settimana scorso",
"today": "Oggi",
"yesterday": "Ieri"
},
"where": "Dove"
}, },
"recording": { "recording": {
"camera": "Camera",
"duration": "Durata",
"events": "Eventi", "events": "Eventi",
"seek": "Cercare" "in_progress": "In corso",
"seek": "Cercare",
"start": "Inizio"
}, },
"thumbnail": { "thumbnail": {
"no_thumbnail": "Nessuna miniatura disponibile", "no_thumbnail": "Nessuna miniatura disponibile",
"retain_indefinitely": "L'evento sarà mantenuto indefinitamente", "retain_indefinitely": "L'evento sarà mantenuto indefinitamente",
"timeline": "Vedi evento nella timeline" "timeline": "Vedi evento nella timeline"
},
"timeline": {
"pan_behavior": {
"pan": "",
"seek": "",
"seek-in-media": ""
},
"select_date": "Scegli la data"
} }
} }
+242 -115
View File
@@ -3,10 +3,7 @@
"frigate_card": "Cartão Frigate", "frigate_card": "Cartão Frigate",
"frigate_card_description": "Um cartão da Lovelace para usar com Frigate", "frigate_card_description": "Um cartão da Lovelace para usar com Frigate",
"live": "Ao Vivo", "live": "Ao Vivo",
"no_clip": "Sem clip recente", "no_media": "Nenhuma mídia para exibir",
"no_clips": "Sem clips",
"no_snapshot": "Sem snapshot recente",
"no_snapshots": "Sem snapshots",
"recordings": "Gravações", "recordings": "Gravações",
"version": "Versão" "version": "Versão"
}, },
@@ -16,39 +13,152 @@
"dependencies": { "dependencies": {
"all_cameras": "Mostrar eventos para todas as câmeras nesta câmera", "all_cameras": "Mostrar eventos para todas as câmeras nesta câmera",
"cameras": "Mostrar eventos para câmeras específicas nesta câmera", "cameras": "Mostrar eventos para câmeras específicas nesta câmera",
"options": "Opções de dependência" "editor_label": "Opções de dependência"
},
"engines": {
"editor_label": "Opções do motor da câmera"
}, },
"frigate": { "frigate": {
"camera_name": "Nome da câmera do Frigate (detectado automaticamente pela entidade)", "camera_name": "Nome da câmera do Frigate (detectado automaticamente pela entidade)",
"client_id": "ID do cliente do Frigate (para >1 servidor Frigate)", "client_id": "ID do cliente do Frigate (para >1 servidor Frigate)",
"label": "Filtro de rótulo/objeto do Frigate", "editor_label": "Opções do Frigate",
"options": "Opções do Frigate", "labels": "Rótulos do Frigate/filtros de objetos",
"url": "URL do servidor Frigate", "url": "URL do servidor Frigate",
"zone": "Zona do Frigate" "zones": "Zonas do Frigate"
}, },
"go2rtc": {
"editor_label": "Opções do go2rtc",
"modes": {
"editor_label": "Modos do go2rtc",
"mjpeg": "Motion JPEG (MJPEG)",
"mp4": "MPEG-4 (MP4)",
"mse": "Media Source Extensions (MSE)",
"webrtc": "Web Real-Time Communication (WebRTC)"
},
"stream": "Nome do stream do go2rtc"
},
"hide": "Ocultar câmera da interface do usuário",
"icon": "Ícone para esta câmera (detectado automaticamente pela entidade)", "icon": "Ícone para esta câmera (detectado automaticamente pela entidade)",
"id": "ID exclusivo para esta câmera nesse cartão", "id": "ID exclusivo para esta câmera nesse cartão",
"image": {
"editor_label": "Opções de Imagem",
"refresh_seconds": "Número de segundos após os quais atualizar a imagem ao vivo (0=nunca)",
"url": "URL da imagem para usar em vez do instantâneo da entidade da câmera"
},
"live_provider": "Provedor de visualização ao vivo para esta câmera", "live_provider": "Provedor de visualização ao vivo para esta câmera",
"live_provider_options": {
"editor_label": "Opções do provedor de visualização ao vivo"
},
"live_providers": { "live_providers": {
"auto": "Automatico", "auto": "Automatico",
"frigate-jsmpeg": "Frigate JSMpeg", "go2rtc": "go2rtc",
"ha": "Home Assistant (HLS, LL-HLS ou WebRTC nativo)", "ha": "Stream de vídeo do Home Assistant (ou seja, HLS, LL-HLS, WebRTC via HA)",
"image": "Imagens do Home Assistant",
"jsmpeg": "JSMpeg",
"webrtc-card": "Cartão WebRTC (de @AlexxIT)" "webrtc-card": "Cartão WebRTC (de @AlexxIT)"
}, },
"motioneye": {
"editor_label": "Opções do MotionEye",
"images": {
"directory_pattern": "Padrão de diretório de imagens",
"file_pattern": "Padrão de arquivo de imagens"
},
"movies": {
"directory_pattern": "Padrão de diretório de filmes",
"file_pattern": "Padrão de arquivo de filmes"
},
"url": "URL da interface de usuário do MotionEye"
},
"title": "Título para esta câmera (detectado automaticamente pela entidade)", "title": "Título para esta câmera (detectado automaticamente pela entidade)",
"triggers": { "triggers": {
"editor_label": "Opções de acionamento",
"entities": "Acionar a partir de outras entidades", "entities": "Acionar a partir de outras entidades",
"motion": "Acionar detectando automaticamente o sensor de movimento", "motion": "Acionar detectando automaticamente o sensor de movimento",
"occupancy": "Acionar detectando automaticamente o sensor de ocupação", "occupancy": "Acionar detectando automaticamente o sensor de ocupação"
"options": "Opções de acionamento"
}, },
"webrtc_card": { "webrtc_card": {
"editor_label": "Opções do cartão WebRTC",
"entity": "Entidade de câmera de cartão WebRTC (não é uma câmera Frigate)", "entity": "Entidade de câmera de cartão WebRTC (não é uma câmera Frigate)",
"options": "Opções do cartão WebRTC",
"url": "URL da câmera do cartão WebRTC" "url": "URL da câmera do cartão WebRTC"
} }
}, },
"common": { "common": {
"controls": {
"filter": {
"editor_label": "Filtro de Mídia",
"mode": "Modo do filtro",
"modes": {
"left": "Filtro de mídia em uma gaveta à esquerda",
"none": "Sem filtro de mídia",
"right": "Filtro de mídia em uma gaveta à direita"
}
},
"next_previous": {
"editor_label": "Próximo",
"size": "Tamanho de controle próximo e anterior",
"style": "Estilo do controle próximo e anterior",
"styles": {
"chevrons": "Setas",
"icons": "Ícones",
"none": "Nenhum",
"thumbnails": "Miniaturas"
}
},
"thumbnails": {
"editor_label": "Miniaturas",
"media": "Se deve mostrar miniaturas de clipes ou snapshots",
"medias": {
"clips": "Miniaturas de clipes",
"snapshots": "Miniaturas de Snapshots"
},
"mode": "Modo de miniaturas",
"modes": {
"above": "Miniaturas acima da mídia",
"below": "Miniaturas abaixo da mídia",
"left": "Miniaturas em uma gaveta à esquerda",
"none": "Sem miniaturas",
"right": "Miniaturas em uma gaveta à direita"
},
"show_details": "Mostrar detalhes com miniaturas",
"show_download_control": "Mostrar controle de download nas miniaturas",
"show_favorite_control": "Mostrar controle de favorito nas miniaturas",
"show_timeline_control": "Mostrar controle da linha do tempo nas miniaturas",
"size": "Tamanho das miniaturas em pixels"
},
"timeline": {
"editor_label": "Controles da linha do tempo",
"mode": "Modo",
"modes": {
"above": "Acima",
"below": "Abaixo",
"none": "Nenhum"
}
},
"title": {
"duration_seconds": "Segundos para exibir o pop-up (0 = para sempre)",
"editor_label": "Controles do pop-up de título",
"mode": "Modo de exibição de título de mídia",
"modes": {
"none": "Sem exibição de título",
"popup-bottom-left": "Pop-up no canto inferior esquerdo",
"popup-bottom-right": "Pop-up no canto inferior direito",
"popup-top-left": "Pop-up no canto superior esquerdo",
"popup-top-right": "Pop-up no canto superior direito"
}
}
},
"layout": {
"fit": "Ajuste de layout",
"fits": {
"contain": "A mídia é contida no cartão",
"cover": "A mídia se expande proporcionalmente para cobrir o cartão",
"fill": "A mídia é esticada para preencher o cartão"
},
"position": {
"x": "Porcentagem do posicionamento horizontal",
"y": "Porcentagem do posicionamento vertical"
}
},
"media_action_conditions": { "media_action_conditions": {
"all": "Todas as oportunidades", "all": "Todas as oportunidades",
"hidden": "Ao ocultar o navegador/aba", "hidden": "Ao ocultar o navegador/aba",
@@ -56,6 +166,22 @@
"selected": "Ao selecionar", "selected": "Ao selecionar",
"unselected": "Ao desselecionar", "unselected": "Ao desselecionar",
"visible": "Ao mostrar o navegador/aba" "visible": "Ao mostrar o navegador/aba"
},
"timeline": {
"clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)",
"media": "A mídia que a linha do tempo exibe",
"medias": {
"all": "Todos os tipos de mídia",
"clips": "Clipes",
"snapshots": "Instantâneos"
},
"show_recordings": "Mostrar gravações",
"style": "",
"styles": {
"ribbon": "",
"stack": ""
},
"window_seconds": "A duração padrão da visualização da linha do tempo em segundos"
} }
}, },
"dimensions": { "dimensions": {
@@ -65,20 +191,12 @@
"dynamic": "A proporção se ajusta à mídia", "dynamic": "A proporção se ajusta à mídia",
"static": "Proporção estática", "static": "Proporção estática",
"unconstrained": "Proporção irrestrita" "unconstrained": "Proporção irrestrita"
} },
}, "max_height": "",
"event_gallery": { "min_height": ""
"controls": {
"options": "Controles da Galeria de Eventos",
"thumbnails": {
"show_details": "Mostrar detalhes do evento com miniaturas",
"show_favorite_control": "Mostrar controle de favorito nas miniaturas",
"show_timeline_control": "Mostrar controle da linha do tempo nas miniaturas",
"size": "Tamanho das miniaturas da Galeria de eventos em pixels"
}
}
}, },
"image": { "image": {
"layout": "Layout da imagem",
"mode": "Modo de visualização de imagem", "mode": "Modo de visualização de imagem",
"modes": { "modes": {
"camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera", "camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera",
@@ -94,38 +212,14 @@
"auto_play": "Reproduzir câmeras ao vivo automaticamente", "auto_play": "Reproduzir câmeras ao vivo automaticamente",
"auto_unmute": "Ativar automaticamente o som das câmeras ao vivo", "auto_unmute": "Ativar automaticamente o som das câmeras ao vivo",
"controls": { "controls": {
"next_previous": { "editor_label": "Controles da visualização ao vivo"
"size": "Tamanho de controle próximo e anterior na visualização ao vivo (por exemplo, '48px')",
"style": "Estilo do controle próximo e anterior na visualização ao vivo",
"styles": {
"chevrons": "Setas",
"icons": "Ícones",
"none": "Nenhum"
}
},
"options": "Controles da visualização ao vivo",
"thumbnails": {
"media": "Se deve mostrar miniaturas de clipes ou snapshots",
"medias": {
"clips": "Miniaturas de clipes",
"snapshots": "Miniaturas de Snapshots"
},
"mode": "Miniaturas do modo ao vivo",
"show_details": "Mostrar detalhes do evento com miniaturas",
"show_favorite_control": "Mostrar controle de favorito nas miniaturas",
"show_timeline_control": "Mostrar controle da linha do tempo nas miniaturas",
"size": "Tamanho das miniaturas ao vivo (e.g. '100px')"
},
"title": {
"duration_seconds": "Segundos para exibir o pop-up na visualização ao vivo (0 = para sempre)",
"mode": "Modo de exibição de título de mídia ao vivo"
}
}, },
"draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada",
"layout": "Layout dinâmico",
"lazy_load": "As câmeras ao vivo são carregadas lentamente", "lazy_load": "As câmeras ao vivo são carregadas lentamente",
"lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente", "lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente",
"preload": "Pré-carregar a visualização ao vivo em segundo plano", "preload": "Pré-carregar a visualização ao vivo em segundo plano",
"show_image_during_load": "", "show_image_during_load": "Mostrar imagem estática enquanto a transmissão ao vivo está carregando",
"transition_effect": "Efeito de transição de câmera ao vivo" "transition_effect": "Efeito de transição de câmera ao vivo"
}, },
"media_viewer": { "media_viewer": {
@@ -134,44 +228,12 @@
"auto_play": "Reproduzir mídia automaticamente", "auto_play": "Reproduzir mídia automaticamente",
"auto_unmute": "Ativar mídia automaticamente", "auto_unmute": "Ativar mídia automaticamente",
"controls": { "controls": {
"next_previous": { "editor_label": "Controles do visualizador de mídia"
"size": "Tamanho do controle próximo e anterior do Visualizador de eventos (por exemplo, '48px')",
"style": "Estilo do controle próximo e anterior do Visualizador de eventos",
"styles": {
"chevrons": "Setas",
"none": "Nenhum",
"thumbnails": "Miniaturas"
}
},
"options": "Controles do visualizador de mídia",
"thumbnails": {
"mode": "Modo de miniaturas do Visualizador de eventos",
"modes": {
"above": "Miniaturas acima da mídia",
"below": "Miniaturas abaixo da mídia",
"left": "Miniaturas em uma gaveta à esquerda da mídia",
"none": "Sem miniaturas",
"right": "Miniaturas em uma gaveta à direita da mídia"
},
"show_details": "Mostrar detalhes com miniaturas",
"show_favorite_control": "Mostrar controle de favorito nas miniaturas",
"show_timeline_control": "Mostrar controle da linha do tempo nas miniaturas",
"size": "Tamanho das miniaturas do Visualizador de eventos (por exemplo, '100px')"
},
"title": {
"duration_seconds": "Segundos de exibição do pop-up no Visualizador de Eventos (0 = para sempre)",
"mode": "Modo de exibição de título de mídia do Visualizador de eventos",
"modes": {
"none": "Sem exibição de título",
"popup-bottom-left": "Pop-up no canto inferior esquerdo",
"popup-bottom-right": "Pop-up no canto inferior direito",
"popup-top-left": "Pop-up no canto superior esquerdo",
"popup-top-right": "Pop-up no canto superior direito"
}
}
}, },
"draggable": "Visualizador de eventos pode ser arrastado/deslizado", "draggable": "Visualizador de eventos pode ser arrastado/deslizado",
"layout": "Layout do visualizador de mídia",
"lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel", "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_effect": "Efeito de transição do Visualizador de eventos",
"transition_effects": { "transition_effects": {
"none": "Sem transição", "none": "Sem transição",
@@ -193,19 +255,22 @@
"matching": "Mesmo alinhamento do menu", "matching": "Mesmo alinhamento do menu",
"opposing": "Opor-se ao alinhamento do menu" "opposing": "Opor-se ao alinhamento do menu"
}, },
"camera_ui": "Interface de usuário da câmera",
"cameras": "Selecionar câmera", "cameras": "Selecionar câmera",
"clips": "Clipes", "clips": "Clipes",
"download": "Baixe a mídia do evento", "download": "Baixe a mídia do evento",
"enabled": "Botão ativado", "enabled": "Botão ativado",
"expand": "Expandir",
"frigate": "Frigate menu / Visualização padrão", "frigate": "Frigate menu / Visualização padrão",
"frigate_ui": "Frigate Interface de usuário",
"fullscreen": "Tela cheia", "fullscreen": "Tela cheia",
"icon": "Ícone", "icon": "Ícone",
"image": "Imagem", "image": "Imagem",
"live": "Ao vivo", "live": "Ao vivo",
"media_player": "Enviar para o reprodutor de mídia", "media_player": "Enviar para o reprodutor de mídia",
"priority": "Prioridade", "priority": "Prioridade",
"recordings": "Gravações",
"snapshots": "Instantâneos", "snapshots": "Instantâneos",
"substreams": "Substream(s)",
"timeline": "Linha do tempo" "timeline": "Linha do tempo"
}, },
"position": "Posição do menu", "position": "Posição do menu",
@@ -219,6 +284,7 @@
"styles": { "styles": {
"hidden": "Menu oculto", "hidden": "Menu oculto",
"hover": "Menu suspenso", "hover": "Menu suspenso",
"hover-card": "Menu suspenso (em todo o cartão)",
"none": "Sem menu", "none": "Sem menu",
"outside": "Menu externo", "outside": "Menu externo",
"overlay": "Menu sobreposto" "overlay": "Menu sobreposto"
@@ -227,26 +293,23 @@
"overrides": { "overrides": {
"info": "Esta configuração do cartão especificou manualmente as substituições configuradas que podem substituir os valores mostrados no editor visual, consulte o editor de código para visualizar/modificar essas substituições" "info": "Esta configuração do cartão especificou manualmente as substituições configuradas que podem substituir os valores mostrados no editor visual, consulte o editor de código para visualizar/modificar essas substituições"
}, },
"timeline": { "performance": {
"clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)", "features": {
"controls": { "animated_progress_indicator": "Indicador de Carregamento Animado",
"options": "Controles de linha do tempo", "editor_label": "Opções de recursos",
"thumbnails": { "media_chunk_size": "Tamanho do bloco de mídia"
"mode": "Modo de miniaturas da linha do tempo",
"show_details": "Mostrar detalhes do evento com miniaturas",
"show_favorite_control": "Mostrar controle de favorito nas miniaturas",
"show_timeline_control": "Mostrar controle da linha do tempo nas miniaturas",
"size": "Tamanho das miniaturas da linha do tempo em pixels"
}
}, },
"media": "A mídia que a linha do tempo exibe", "profile": "Perfil de desempenho",
"medias": { "profiles": {
"all": "Todos os tipos de mídia", "high": "Alto desempenho/completo",
"clips": "Clipes", "low": "Baixo desempenho"
"snapshots": "Instantâneos"
}, },
"show_recordings": "Mostrar gravações", "style": {
"window_seconds": "A duração padrão da visualização da linha do tempo em segundos" "border_radius": "Curvas",
"box_shadow": "Sombras",
"editor_label": "Opções de estilo"
},
"warning": "Este cartão está no modo de baixo desempenho, então os padrões foram alterados para otimizar o desempenho"
}, },
"view": { "view": {
"camera_select": "Visualização de câmeras recém-selecionadas", "camera_select": "Visualização de câmeras recém-selecionadas",
@@ -274,6 +337,8 @@
"current": "Visualização atual", "current": "Visualização atual",
"image": "Imagem estática", "image": "Imagem estática",
"live": "Visualização ao vivo", "live": "Visualização ao vivo",
"recording": "Gravação mais recente",
"recordings": "Galeria de gravações",
"snapshot": "Snapshot mais recente", "snapshot": "Snapshot mais recente",
"snapshots": "Galeria de Snapshots", "snapshots": "Galeria de Snapshots",
"timeline": "Visualização da linha do tempo" "timeline": "Visualização da linha do tempo"
@@ -289,12 +354,12 @@
"delete": "Excluir", "delete": "Excluir",
"dimensions": "Dimensões", "dimensions": "Dimensões",
"dimensions_secondary": "Dimensões e opções de forma", "dimensions_secondary": "Dimensões e opções de forma",
"event_gallery": "Galeria de eventos",
"event_gallery_secondary": "Opções da galeria de Snapshots e clipes",
"image": "Imagem", "image": "Imagem",
"image_secondary": "Opções de visualização de imagem estática", "image_secondary": "Opções de visualização de imagem estática",
"live": "Ao vivo", "live": "Ao vivo",
"live_secondary": "Opções de visualização da câmera ao vivo", "live_secondary": "Opções de visualização da câmera ao vivo",
"media_gallery": "Galeria de mídia",
"media_gallery_secondary": "Opções da galeria de mídia",
"media_viewer": "Visualizador de eventos", "media_viewer": "Visualizador de eventos",
"media_viewer_secondary": "Opções do visualizador de Snapshots e clipes", "media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
"menu": "Menu", "menu": "Menu",
@@ -303,6 +368,8 @@
"move_up": "Subir", "move_up": "Subir",
"overrides": "As substituições estão ativas", "overrides": "As substituições estão ativas",
"overrides_secondary": "Substituições de configuração dinâmica detectadas", "overrides_secondary": "Substituições de configuração dinâmica detectadas",
"performance": "Desempenho",
"performance_secondary": "Opções de desempenho do cartão",
"timeline": "Linha do tempo", "timeline": "Linha do tempo",
"timeline_secondary": "Opções do evento da linha do tempo", "timeline_secondary": "Opções do evento da linha do tempo",
"upgrade": "Upgrade", "upgrade": "Upgrade",
@@ -310,11 +377,21 @@
"view": "Visualizar", "view": "Visualizar",
"view_secondary": "O que o cartão deve mostrar e como mostrá-lo" "view_secondary": "O que o cartão deve mostrar e como mostrá-lo"
}, },
"elements": {
"ptz": {
"down": "Baixo",
"home": "Casa",
"left": "Esquerda",
"right": "Direita",
"up": "Cima",
"zoom_in": "Aumentar Zoom",
"zoom_out": "Reduzir Zoom"
}
},
"error": { "error": {
"could_not_render_elements": "Não foi possível renderizar os elementos da imagem", "could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
"could_not_resolve": "Não foi possível resolver o URL de mídia", "could_not_resolve": "Não foi possível resolver o URL de mídia",
"diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar", "diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar",
"download_no_event_id": "Não foi possível extrair o Frigate ID do evento da mídia",
"download_no_media": "Nenhuma mídia para download", "download_no_media": "Nenhuma mídia para download",
"download_sign_failed": "Não foi possível assinar o URL de mídia para download", "download_sign_failed": "Não foi possível assinar o URL de mídia para download",
"duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras", "duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras",
@@ -328,13 +405,16 @@
"invalid_elements_config": "Configuração de elementos de imagem inválida", "invalid_elements_config": "Configuração de elementos de imagem inválida",
"invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação", "invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação",
"jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG", "jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG",
"jsmpeg_no_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG", "live_camera_no_endpoint": "Não foi possível obter o endereço da câmera para este provedor ao vivo (configuração incompleta?)",
"live_camera_not_found": "", "live_camera_not_found": "A entidade de câmera configurada não foi encontrada",
"live_camera_unavailable": "", "live_camera_unavailable": "Câmera indisponível",
"no_camera_engine": "Não foi possível determinar o motor adequado para a câmera",
"no_camera_entity": "Não foi possível encontrar a entidade da câmera",
"no_camera_entity_for_triggers": "Uma entidade de câmera é necessária para detectar automaticamente os gatilhos",
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente", "no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
"no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir", "no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir",
"no_cameras": "Nenhuma câmera válida encontrada, você deve configurar pelo menos uma câmera",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo", "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo",
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
"reconnecting": "Reconectando", "reconnecting": "Reconectando",
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo", "timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
"troubleshooting": "Verifique a solução de problemas", "troubleshooting": "Verifique a solução de problemas",
@@ -344,18 +424,65 @@
"webrtc_card_waiting": "Aguardando o cartão WebRTC carregar ..." "webrtc_card_waiting": "Aguardando o cartão WebRTC carregar ..."
}, },
"event": { "event": {
"camera": "Câmera",
"duration": "Duração", "duration": "Duração",
"in_progress": "Em andamento", "in_progress": "Em andamento",
"score": "Pontuação", "score": "Pontuação",
"start": "Início" "seek": "Procurar",
"start": "Início",
"tag": "Etiqueta",
"what": "O que",
"where": "Onde"
},
"media_filter": {
"all": "Todos",
"camera": "Câmera",
"favorite": "Favorito",
"media_type": "Tipo de mídia",
"media_types": {
"clips": "Clipes",
"recordings": "Gravações",
"snapshots": "Instantâneos"
},
"not_favorite": "Não favorito",
"select_camera": "Selecione a câmera...",
"select_favorite": "Selecione favorito...",
"select_media_type": "Selecione o tipo de mídia...",
"select_tag": "Selecione a etiqueta...",
"select_what": "Selecione o que...",
"select_when": "Selecione quando...",
"select_where": "Selecione onde...",
"tag": "Etiqueta",
"what": "O que",
"when": "Quando",
"whens": {
"past_month": "Mês passado",
"past_week": "Semana passada",
"today": "Hoje",
"yesterday": "Ontem"
},
"where": "Onde"
}, },
"recording": { "recording": {
"camera": "Câmera",
"duration": "Duração",
"events": "Eventos", "events": "Eventos",
"seek": "Procurar" "in_progress": "Em andamento",
"seek": "Procurar",
"start": "Começar"
}, },
"thumbnail": { "thumbnail": {
"download": "Baixar mídia",
"no_thumbnail": "Nenhuma miniatura disponível", "no_thumbnail": "Nenhuma miniatura disponível",
"retain_indefinitely": "Evento será retido por tempo indeterminado", "retain_indefinitely": "Evento será retido por tempo indeterminado",
"timeline": "Ver evento na linha do tempo" "timeline": "Ver evento na linha do tempo"
},
"timeline": {
"pan_behavior": {
"pan": "",
"seek": "",
"seek-in-media": ""
},
"select_date": "Escolha a data"
} }
} }
+66 -34
View File
@@ -1,56 +1,88 @@
import { HomeAssistant } from 'custom-card-helpers';
import * as en from './languages/en.json'; import * as en from './languages/en.json';
import * as pt_BR from './languages/pt-BR.json';
import * as it from './languages/it.json'; const DEFAULT_LANG = 'en' as const;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const languages: any = { const languages: Record<string, any> = {
en: en, // English as always loaded as it's the fallback language that will be used
pt_BR: pt_BR, // when translations are not found or before they are loaded (via
it: it, // loadLanguages()).
[DEFAULT_LANG]: en,
}; };
export function getLanguage(): string { // The language is calculated and stored once, then re-used to avoid needing to
const canonicalizeLanguage = (language?: string | null): string | null => { // repeat the lookups and to ensure minimal information needs to be plumbed
if (!language) { // through on each localization call.
return null; let frigateCardLanguage: string | undefined;
}
/**
* Get the configured language.
*/
export function getLanguage(hass?: HomeAssistant): string {
const canonicalizeLanguage = (language: string): string => {
return language.replace('-', '_'); return language.replace('-', '_');
}; };
// Try the HA language first... // Try the hass language first...
let lang: string | null = null; const hassLanguage = hass?.language ?? hass?.selectedLanguage;
const HALanguage = localStorage.getItem('selectedLanguage'); if (hassLanguage) {
if (HALanguage) { return canonicalizeLanguage(hassLanguage);
const selectedLanguage = canonicalizeLanguage(JSON.parse(HALanguage)); }
if (selectedLanguage) {
lang = selectedLanguage; // Then the language that hass may have stored locally.
const storageLanguage = localStorage.getItem('selectedLanguage');
if (storageLanguage) {
const parsedLanguage: string | null = JSON.parse(storageLanguage);
if (parsedLanguage) {
return canonicalizeLanguage(parsedLanguage);
} }
} }
// Then fall back to the browser language. // Then fall back to the browser language.
if (!lang) { for (const language of navigator.languages) {
for (const language of navigator.languages) { const canonicalLanguage = canonicalizeLanguage(language);
const canonicalLanguage = canonicalizeLanguage(language); if (canonicalLanguage && canonicalLanguage in languages) {
if (canonicalLanguage && canonicalLanguage in languages) { return canonicalLanguage;
lang = language;
}
} }
} }
return lang || 'en'; return DEFAULT_LANG;
} }
export function localize(string: string, search = '', replace = ''): string { /**
const lang = getLanguage(); * Load required languages.
let translated: string; */
export const loadLanguages = async (hass: HomeAssistant): Promise<void> => {
try { const lang = getLanguage(hass);
translated = string.split('.').reduce((o, i) => o[i], languages[lang]); if (lang === 'it') {
} catch (e) { languages[lang] = await import('./languages/it.json');
translated = string.split('.').reduce((o, i) => o[i], languages['en']); } else if (lang === 'pt_BR') {
languages[lang] = await import('./languages/pt-BR.json');
} }
if (lang) {
frigateCardLanguage = lang;
}
};
/**
* Get a localized version of a given string key.
* @param string The key.
* @param search An optional search key to be used with 'replace'.
* @param replace An optional replacement text to be used with 'search'.
* @returns
*/
export function localize(string: string, search = '', replace = ''): string {
let translated = '';
try {
translated = string
.split('.')
.reduce((o, i) => o[i], languages[frigateCardLanguage ?? DEFAULT_LANG]);
} catch (_) {}
if (!translated) { if (!translated) {
translated = string.split('.').reduce((o, i) => o[i], languages['en']); translated = string.split('.').reduce((o, i) => o[i], languages[DEFAULT_LANG]);
} }
if (search !== '' && replace !== '') { if (search !== '' && replace !== '') {
+14 -31
View File
@@ -28,20 +28,15 @@ customElements.whenDefined('ha-camera-stream').then(() => {
const computeMJPEGStreamUrl = (entity: CameraEntity): string => const computeMJPEGStreamUrl = (entity: CameraEntity): string =>
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`; `/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
const computeObjectId = (entityId: string): string =>
entityId.substr(entityId.indexOf('.') + 1);
const computeStateName = (stateObj: HassEntity): string =>
stateObj.attributes.friendly_name === undefined
? computeObjectId(stateObj.entity_id).replace(/_/g, ' ')
: stateObj.attributes.friendly_name || '';
const STREAM_TYPE_HLS = 'hls'; const STREAM_TYPE_HLS = 'hls';
const STREAM_TYPE_WEB_RTC = 'web_rtc'; const STREAM_TYPE_WEB_RTC = 'web_rtc';
@customElement('frigate-card-ha-camera-stream') @customElement('frigate-card-ha-camera-stream')
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
class FrigateCardHaCameraStream extends customElements.get('ha-camera-stream') { class FrigateCardHaCameraStream
extends customElements.get('ha-camera-stream')
implements FrigateCardMediaPlayer
{
// Due to an obscure behavior when this card is casted, this element needs // Due to an obscure behavior when this card is casted, this element needs
// to use query rather than the ref directive to find the player. // to use query rather than the ref directive to find the player.
@query('#player') @query('#player')
@@ -52,38 +47,27 @@ customElements.whenDefined('ha-camera-stream').then(() => {
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts
// ======================================================================================== // ========================================================================================
/** public async play(): Promise<void> {
* Play the video. return this._player?.play();
*/
public play(): void {
this._player?.play();
} }
/** public async pause(): Promise<void> {
* Pause the video.
*/
public pause(): void {
this._player?.pause(); this._player?.pause();
} }
/** public async mute(): Promise<void> {
* Mute the video.
*/
public mute(): void {
this._player?.mute(); this._player?.mute();
} }
/** public async unmute(): Promise<void> {
* Unmute the video.
*/
public unmute(): void {
this._player?.unmute(); this._player?.unmute();
} }
/** public isMuted(): boolean {
* Seek the video (unsupported). return this._player?.isMuted() ?? true;
*/ }
public seek(seconds: number): void {
public async seek(seconds: number): Promise<void> {
this._player?.seek(seconds); this._player?.seek(seconds);
} }
@@ -105,7 +89,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
.src=${typeof this._connected == 'undefined' || this._connected .src=${typeof this._connected == 'undefined' || this._connected
? computeMJPEGStreamUrl(this.stateObj) ? computeMJPEGStreamUrl(this.stateObj)
: ''} : ''}
.alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
/> />
`; `;
} }
+32 -27
View File
@@ -15,34 +15,33 @@ import { query } from 'lit/decorators/query.js';
import { dispatchErrorMessageEvent } from '../components/message.js'; import { dispatchErrorMessageEvent } from '../components/message.js';
import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../utils/media.js';
import { FrigateCardMediaPlayer } from '../types.js';
customElements.whenDefined('ha-hls-player').then(() => { customElements.whenDefined('ha-hls-player').then(() => {
@customElement('frigate-card-ha-hls-player') @customElement('frigate-card-ha-hls-player')
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
class FrigateCardHaHlsPlayer extends customElements.get('ha-hls-player') { class FrigateCardHaHlsPlayer
extends customElements.get('ha-hls-player')
implements FrigateCardMediaPlayer
{
// Due to an obscure behavior when this card is casted, this element needs // Due to an obscure behavior when this card is casted, this element needs
// to use query rather than the ref directive to find the player. // to use query rather than the ref directive to find the player.
@query('#video') @query('#video')
protected _video: HTMLVideoElement; protected _video: HTMLVideoElement;
/** public async play(): Promise<void> {
* Play the video. return this._video?.play();
*/
public play(): void {
this._video?.play();
} }
/** public async pause(): Promise<void> {
* Pause the video.
*/
public pause(): void {
this._video?.pause(); this._video?.pause();
} }
/** public async mute(): Promise<void> {
* Mute the video.
*/
public mute(): void {
// The muted property is only for the initial muted state. Must explicitly // The muted property is only for the initial muted state. Must explicitly
// set the muted on the video player to make the change dynamic. // set the muted on the video player to make the change dynamic.
if (this._video) { if (this._video) {
@@ -50,21 +49,20 @@ customElements.whenDefined('ha-hls-player').then(() => {
} }
} }
/** public async unmute(): Promise<void> {
* Unmute the video.
*/
public unmute(): void {
// See note in mute(). // See note in mute().
if (this._video) { if (this._video) {
this._video.muted = false; this._video.muted = false;
} }
} }
/** public isMuted(): boolean {
* Seek the video. return this._video?.muted ?? true;
*/ }
public seek(seconds: number): void {
public async seek(seconds: number): Promise<void> {
if (this._video) { if (this._video) {
hideMediaControlsTemporarily(this._video);
this._video.currentTime = seconds; this._video.currentTime = seconds;
} }
} }
@@ -75,8 +73,12 @@ customElements.whenDefined('ha-hls-player').then(() => {
// ===================================================================================== // =====================================================================================
protected render(): TemplateResult { protected render(): TemplateResult {
if (this._error) { if (this._error) {
// Use native Frigate card error handling. if (this._errorIsFatal) {
return dispatchErrorMessageEvent(this, this._error); // Use native Frigate card error handling for fatal errors.
return dispatchErrorMessageEvent(this, this._error);
} else {
console.error(this._error);
}
} }
return html` return html`
<video <video
@@ -85,6 +87,9 @@ customElements.whenDefined('ha-hls-player').then(() => {
.muted=${this.muted} .muted=${this.muted}
?playsinline=${this.playsInline} ?playsinline=${this.playsInline}
?controls=${this.controls} ?controls=${this.controls}
@loadedmetadata=${() => {
hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
}}
@loadeddata=${(e) => { @loadeddata=${(e) => {
dispatchMediaLoadedEvent(this, e); dispatchMediaLoadedEvent(this, e);
}} }}
@@ -112,7 +117,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
}); });
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"frigate-card-ha-hls-player": FrigateCardHaHlsPlayer 'frigate-card-ha-hls-player': FrigateCardHaHlsPlayer;
} }
} }
+24 -24
View File
@@ -9,40 +9,39 @@
// available as compilation time. // available as compilation time.
// ==================================================================== // ====================================================================
import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit'; import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js'; import { customElement } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js'; import { query } from 'lit/decorators/query.js';
import { dispatchErrorMessageEvent } from '../components/message.js'; import { dispatchErrorMessageEvent } from '../components/message.js';
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer } from '../types.js';
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS
} from '../utils/media.js';
customElements.whenDefined('ha-web-rtc-player').then(() => { customElements.whenDefined('ha-web-rtc-player').then(() => {
@customElement('frigate-card-ha-web-rtc-player') @customElement('frigate-card-ha-web-rtc-player')
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
class FrigateCardHaWebRtcPlayer extends customElements.get('ha-web-rtc-player') { class FrigateCardHaWebRtcPlayer
extends customElements.get('ha-web-rtc-player')
implements FrigateCardMediaPlayer
{
// Due to an obscure behavior when this card is casted, this element needs // Due to an obscure behavior when this card is casted, this element needs
// to use query rather than the ref directive to find the player. // to use query rather than the ref directive to find the player.
@query('#remote-stream') @query('#remote-stream')
protected _video: HTMLVideoElement; protected _video: HTMLVideoElement;
/** public async play(): Promise<void> {
* Play the video. return this._video?.play();
*/
public play(): void {
this._video?.play();
} }
/** public async pause(): Promise<void> {
* Pause the video.
*/
public pause(): void {
this._video?.pause(); this._video?.pause();
} }
/** public async mute(): Promise<void> {
* Mute the video.
*/
public mute(): void {
// The muted property is only for the initial muted state. Must explicitly // The muted property is only for the initial muted state. Must explicitly
// set the muted on the video player to make the change dynamic. // set the muted on the video player to make the change dynamic.
if (this._video) { if (this._video) {
@@ -50,20 +49,18 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
} }
} }
/** public async unmute(): Promise<void> {
* Unmute the video.
*/
public unmute(): void {
// See note in mute(). // See note in mute().
if (this._video) { if (this._video) {
this._video.muted = false; this._video.muted = false;
} }
} }
/** public isMuted(): boolean {
* Seek the video. return this._video?.muted ?? true;
*/ }
public seek(seconds: number): void {
public async seek(seconds: number): Promise<void> {
if (this._video) { if (this._video) {
this._video.currentTime = seconds; this._video.currentTime = seconds;
} }
@@ -86,6 +83,9 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
.muted=${this.muted} .muted=${this.muted}
?playsinline=${this.playsInline} ?playsinline=${this.playsInline}
?controls=${this.controls} ?controls=${this.controls}
@loadedmetadata=${() => {
hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
}}
@loadeddata=${(e) => { @loadeddata=${(e) => {
dispatchMediaLoadedEvent(this, e); dispatchMediaLoadedEvent(this, e);
}} }}
+205
View File
@@ -0,0 +1,205 @@
import { deepRemoveDefaults } from './utils/zod.js';
import {
frigateCardConfigSchema,
RawFrigateCardConfig,
PerformanceConfig,
} from './types';
import { getConfigValue, setConfigValue } from './config-mgmt.js';
import {
CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS,
CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY,
CONF_LIVE_AUTO_MUTE,
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_TIMELINE_CONTROL,
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
CONF_LIVE_CONTROLS_TITLE_MODE,
CONF_LIVE_DRAGGABLE,
CONF_LIVE_LAZY_UNLOAD,
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
CONF_LIVE_TRANSITION_EFFECT,
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_TIMELINE_CONTROL,
CONF_MEDIA_VIEWER_AUTO_MUTE,
CONF_MEDIA_VIEWER_AUTO_PAUSE,
CONF_MEDIA_VIEWER_AUTO_PLAY,
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_TIMELINE_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
CONF_MEDIA_VIEWER_DRAGGABLE,
CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP,
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
CONF_MENU_BUTTONS_FRIGATE,
CONF_MENU_BUTTONS_MEDIA_PLAYER,
CONF_MENU_BUTTONS_TIMELINE,
CONF_MENU_STYLE,
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE,
CONF_PERFORMANCE_STYLE_BORDER_RADIUS,
CONF_PERFORMANCE_STYLE_BOX_SHADOW,
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_TIMELINE_CONTROL,
CONF_TIMELINE_SHOW_RECORDINGS,
} from './const.js';
// Caution: These values are applied after parsing (since we cannot know the
// performance profile until afterwards), so there is no validation on these
// defaults.
const LOW_PROFILE_DEFAULTS = {
// Disable thumbnail carousels.
[CONF_LIVE_CONTROLS_THUMBNAILS_MODE]: 'none' as const,
[CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE]: 'none' as const,
[CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE]: 'none' as const,
// Do not show recordings on timelines.
[CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS]: false,
[CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS]: false,
[CONF_TIMELINE_SHOW_RECORDINGS]: false,
// Take no automatic media actions.
[CONF_LIVE_AUTO_MUTE]: 'never' as const,
[CONF_MEDIA_VIEWER_AUTO_PLAY]: 'never' as const,
[CONF_MEDIA_VIEWER_AUTO_PAUSE]: 'never' as const,
[CONF_MEDIA_VIEWER_AUTO_MUTE]: 'never' as const,
// Always unload resources that are lazily loaded.
[CONF_LIVE_LAZY_UNLOAD]: 'all' as const,
// Media carousels do not drag.
[CONF_LIVE_DRAGGABLE]: false,
[CONF_MEDIA_VIEWER_DRAGGABLE]: false,
// Media carousels have no effects.
[CONF_LIVE_TRANSITION_EFFECT]: 'none' as const,
[CONF_MEDIA_VIEWER_TRANSITION_EFFECT]: 'none' as const,
// Do not show image during load.
[CONF_LIVE_SHOW_IMAGE_DURING_LOAD]: false,
// Media player next/previous are chevrons.
[CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE]: 'chevrons' as const,
[CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE]: 'none' as const,
[CONF_LIVE_CONTROLS_TITLE_MODE]: 'none' as const,
// Move the menu to outside to remove the need to interact with it with open.
[CONF_MENU_STYLE]: 'outside',
// Hide several buttons that are otherwise visible by default.
[`${CONF_MENU_BUTTONS_FRIGATE}.enabled`]: false,
[`${CONF_MENU_BUTTONS_TIMELINE}.enabled`]: false,
[`${CONF_MENU_BUTTONS_TIMELINE}.enabled`]: false,
// If the media player button is present media player entity fetches are
// required on initialization.
[`${CONF_MENU_BUTTONS_MEDIA_PLAYER}.enabled`]: false,
// Disable all options in thumbnails.
[CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false,
[CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false,
[CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false,
[CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false,
[CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false,
[CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false,
[CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false,
[CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false,
[CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false,
[CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false,
[CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false,
[CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false,
[CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL]: false,
[CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false,
[CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false,
[CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false,
// Disable all optional performance related features.
[CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR]: false,
// Load fewer media items by default.
[CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE]: 10,
// Disable all expensive CSS features.
[CONF_PERFORMANCE_STYLE_BORDER_RADIUS]: false,
[CONF_PERFORMANCE_STYLE_BOX_SHADOW]: false,
// Clicking on a snapshot should not play a clip.
[CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP]: false,
[CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY]: false,
// Refresh the live camera image every 10 seconds (same as stock Home
// Assistant Picture Glance).
[CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS]: 10,
};
/**
* Set low performance profile mode. Sets flags as defined in
* LOW_PROFILE_DEFAULTS unless they are explicitly overriden in the
* configuration.
* @param inputConfig The raw unparsed input configuration.
* @param outputConfig The output config to write to.
* @returns A changed (in-place) parsed input configuration.
*/
export const setLowPerformanceProfile = <T extends RawFrigateCardConfig>(
inputConfig: RawFrigateCardConfig,
outputConfig: T,
): T => {
const setIfNotSpecified = (
defaultLessConfig: RawFrigateCardConfig,
outputConfig: T,
key: string,
value: unknown,
) => {
if (getConfigValue(defaultLessConfig, key) === undefined) {
setConfigValue(outputConfig, key, value);
}
};
const defaultLessParseResult = deepRemoveDefaults(frigateCardConfigSchema).safeParse(
inputConfig,
);
if (defaultLessParseResult.success) {
const defaultLessConfig = defaultLessParseResult.data;
Object.entries(LOW_PROFILE_DEFAULTS).forEach(([k, v]: [string, unknown]) =>
setIfNotSpecified(defaultLessConfig, outputConfig, k, v),
);
}
return outputConfig;
};
const STYLE_DISABLE_MAP = {
box_shadow: 'none',
border_radius: '0px',
};
/**
* Set card-wide CSS variables for performance.
* @param element The element to set the variables on.
* @param performance The performance configuration.
*/
export const setPerformanceCSSStyles = (
element: HTMLElement,
performance?: PerformanceConfig,
): void => {
const styles = performance?.style ?? {};
for (const configKey of Object.keys(styles)) {
const CSSKey = `--frigate-card-css-${configKey.replaceAll('_', '-')}`;
if (styles[configKey] === false) {
element.style.setProperty(CSSKey, STYLE_DISABLE_MAP[configKey]);
} else {
element.style.removeProperty(CSSKey);
}
}
};
+24
View File
@@ -0,0 +1,24 @@
import { GrSelect } from '@graphiteds/core/components/gr-select';
import { GrMenuItem } from '@graphiteds/core/components/gr-menu-item';
// It was difficult to find a multi-select web component that matches these criteria:
// - Open source.
// - Supports being in a ScopedRegistry out of the box (i.e. does not auto-register with customElements).
// - Looks attractive / compatible with mostly Material elements.
// - Styleable
// - Does not bloat output size considerably.
// Web components evaluated (https://open-wc.org/guides/community/component-libraries/):
// - Material: No multiselect component.
// - Freshwords/@crayon: Considerable bloat in output due to i18n translations
// that are used by _other_ components.
// - Carbon Design System: Workable, but less moderm / Material-like.
// - UI5: Auto-registers globally.
// - Vaadin: Auto-registers globally.
// - Liquid: Not open source.
// - [Many others]: No multiselect component.
export const grSelectElements = {
'gr-select': GrSelect,
'gr-menu-item': GrMenuItem,
};
+64 -11
View File
@@ -11,11 +11,35 @@
// The primary border-radius used is the div.main. This is only useful for // The primary border-radius used is the div.main. This is only useful for
// keeping the background-color within the radius. // keeping the background-color within the radius.
border-radius: var(--ha-card-border-radius, 4px); border-radius: var(--ha-card-border-radius, 4px);
// Necessary to ensure children adhere to height of outer container (without
// this gallery surround is not correctly positioned in the middle of the
// card, but rather the middle of the scrolling gallery container).
max-height: var(--frigate-card-max-height);
min-height: var(--frigate-card-min-height);
// The standard HA header is 56 pixels tall, so that much off the top (header)
// and bottom (to maintain center), before doing the calculation of
// max-height. This matters on small mobile devices in landscape orientation.
--frigate-card-expand-max-height: calc( ( 100vh - (2 * 56px) ) * 0.85 );
--frigate-card-expand-max-width: 85vw;
--frigate-card-expand-width: none;
--frigate-card-expand-height: none;
--frigate-card-expand-aspect-ratio: unset;
--frigate-card-max-height: none;
--frigate-card-min-height: none;
} }
:host([dark]) { :host([dark]) {
filter: brightness(75%); filter: brightness(75%);
} }
:host([panel]) {
// Card always extends to the full allowed height in panel mode (in non-panel
// mode this would cause the card to expand to the height of a column when
// there are multiple cards in the column).
height: 100%;
}
div.main { div.main {
position: relative; position: relative;
@@ -23,8 +47,7 @@ div.main {
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: auto; margin: auto;
display: flex; display: block;
justify-content: center;
// Necessary to get Safari to show border-radius correctly. // Necessary to get Safari to show border-radius correctly.
transform: translateZ(0); transform: translateZ(0);
@@ -58,24 +81,30 @@ div.main.curve-bottom {
border-bottom-right-radius: var(--ha-card-border-radius, 4px); border-bottom-right-radius: var(--ha-card-border-radius, 4px);
} }
/* The 'hover' menu mode is styling applied outside of the menu itself */ /* The 'hover' menu mode is styled applied outside of the menu itself */
frigate-card-menu[data-style='hover'] { frigate-card-menu[data-style*='hover'] {
z-index: 1; z-index: 1;
transition: opacity 0.5s ease; transition: opacity 0.5s ease;
} }
.main + frigate-card-menu[data-style='hover'] { .main + frigate-card-menu[data-style*='hover'] {
opacity: 0; opacity: 0;
} }
frigate-card-menu[data-style='hover']:hover { frigate-card-menu[data-style='hover']:hover {
opacity: 1; opacity: 1;
} }
.main:hover + frigate-card-menu[data-style='hover-card'],
frigate-card-menu[data-style='hover-card']:hover {
opacity: 1;
}
ha-card { ha-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: auto; margin: auto;
border: 0px;
// Some elements (such as submenus) may need to extend beyond the card boundary. // Some elements (such as submenus) may need to extend beyond the card boundary.
overflow: visible; overflow: visible;
width: 100%; width: 100%;
@@ -98,12 +127,6 @@ ha-card.triggered {
animation: warning-pulse 5s infinite; animation: warning-pulse 5s infinite;
} }
frigate-card-live.hidden {
// Live view will be rendered but hidden for live preloading.
display: none;
}
/************ /************
* Fullscreen * Fullscreen
*************/ *************/
@@ -143,3 +166,33 @@ frigate-card-live.hidden {
:host(:-webkit-full-screen) frigate-card-menu { :host(:-webkit-full-screen) frigate-card-menu {
@include fullscreen-no-rounded-corners; @include fullscreen-no-rounded-corners;
} }
/***************
* Expanded mode
***************/
web-dialog {
--dialog-padding: 0px;
--dialog-container-padding: 0px;
--dialog-max-height: var(--frigate-card-expand-max-height);
--dialog-max-width: var(--frigate-card-expand-max-width);
--dialog-width: var(--frigate-card-expand-width);
--dialog-height: var(--frigate-card-expand-height);
// Allow submenus to flow outside the edge of the dialog.
--dialog-overflow-x: visible;
--dialog-overflow-y: visible;
// Required to ensure the dialog is centered vertically.
max-height: 100vh;
}
web-dialog::part(dialog) {
aspect-ratio: var(--frigate-card-expand-aspect-ratio);
// Fixes to render the dialog correctly in Safari.
border-radius: 0px;
background: transparent;
}
+7
View File
@@ -0,0 +1,7 @@
// The lit-flatpickr element itself is hidden as we only want the popup
// calender.
lit-flatpickr {
visibility: hidden;
width: 0px;
height: 0px;
}

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