@@ -0,0 +1,10 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16
|
||||
|
||||
# Install Docker
|
||||
ENV DOCKER_BUILDKIT="1"
|
||||
# https://github.com/microsoft/vscode-dev-containers/commits/main/script-library/docker-debian.sh
|
||||
ARG DOCKER_SCRIPT_VERSION="364972b0d7d20ee5de40c1084e65f3f1bc6d5951"
|
||||
RUN bash -c "$(curl -fsSL "https://raw.githubusercontent.com/microsoft/vscode-dev-containers/${DOCKER_SCRIPT_VERSION}/script-library/docker-debian.sh")" \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ENTRYPOINT ["/usr/local/share/docker-init.sh"]
|
||||
CMD ["sleep", "infinity"]
|
||||
@@ -1,4 +0,0 @@
|
||||
default_config:
|
||||
lovelace:
|
||||
mode: yaml
|
||||
demo:
|
||||
@@ -1,14 +1,26 @@
|
||||
// See https://aka.ms/vscode-remote/devcontainer.json for format details.
|
||||
{
|
||||
"name": "Boilerplate Card Development",
|
||||
"image": "ludeeus/container:monster",
|
||||
"context": "..",
|
||||
"appPort": ["5000:5000", "9123:8123"],
|
||||
"postCreateCommand": "npm install",
|
||||
"runArgs": [
|
||||
"-v",
|
||||
"${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh" // This is added so you can push from inside the container
|
||||
],
|
||||
"dockerComposeFile": "../docker-compose.yml",
|
||||
"service": "dev",
|
||||
"workspaceFolder": "${localWorkspaceFolder}",
|
||||
"shutdownAction": "stopCompose",
|
||||
"portsAttributes": {
|
||||
"10001": {
|
||||
"label": "Rollup",
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"hass:8123": {
|
||||
"label": "Home Assistant",
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"frigate:5000": {
|
||||
"label": "Frigate",
|
||||
"onAutoForward": "silent"
|
||||
}
|
||||
},
|
||||
"forwardPorts": [10001, "hass:8123", "frigate:5000"],
|
||||
"initializeCommand": ".devcontainer/initialize.sh",
|
||||
"postCreateCommand": "yarn install",
|
||||
"extensions": [
|
||||
"github.vscode-pull-request-github",
|
||||
"eamodio.gitlens",
|
||||
@@ -16,17 +28,23 @@
|
||||
"esbenp.prettier-vscode",
|
||||
"bierner.lit-html",
|
||||
"runem.lit-plugin",
|
||||
"auchenberg.vscode-browser-preview",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"redhat.vscode-yaml"
|
||||
"redhat.vscode-yaml",
|
||||
"lokalise.i18n-ally",
|
||||
"ms-azuretools.vscode-docker"
|
||||
],
|
||||
"settings": {
|
||||
"files.eol": "\n",
|
||||
"editor.tabSize": 4,
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"editor.tabSize": 2,
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
"files.trimTrailingWhitespace": true
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
git submodule update --init
|
||||
|
||||
.devcontainer/frigate-hass-integration/.devcontainer/set_dot_env.sh
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace",
|
||||
"data": {
|
||||
"config": {
|
||||
"title": "Frigate Card",
|
||||
"views": [
|
||||
{
|
||||
"theme": "Backend-selected",
|
||||
"title": "Basic",
|
||||
"path": "basic",
|
||||
"badges": [],
|
||||
"cards": [
|
||||
{
|
||||
"type": "custom:frigate-card",
|
||||
"name": "Frigate Card with Frigate Camera",
|
||||
"cameras": [
|
||||
{
|
||||
"camera_entity": "camera.big_buck_bunny",
|
||||
"live_provider": "frigate-jsmpeg",
|
||||
"id": "big_buck_bunny_jsmpeg"
|
||||
},
|
||||
{
|
||||
"camera_entity": "camera.big_buck_bunny",
|
||||
"live_provider": "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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "lovelace_resources",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"url": "http://localhost:10001/frigate-hass-card.js",
|
||||
"type": "module",
|
||||
"id": "00c459541bac4290af01c51bf9c06ba1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
resources:
|
||||
- url: http://127.0.0.1:5000/frigate-card.js
|
||||
type: module
|
||||
views:
|
||||
- cards:
|
||||
- type: custom:frigate-card
|
||||
name: Frigate Card Development
|
||||
@@ -19,8 +19,8 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
yarn install
|
||||
yarn run build
|
||||
|
||||
- name: HACS build validation
|
||||
uses: "hacs/action@21.12.1"
|
||||
|
||||
@@ -15,8 +15,8 @@ jobs:
|
||||
# Build
|
||||
- name: Build the file
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
yarn install
|
||||
yarn run build
|
||||
|
||||
# Upload build file to the releas as an asset.
|
||||
- name: Upload zip to release
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/node_modules/
|
||||
/.rpt2_cache/
|
||||
package-lock.json
|
||||
/dist
|
||||
/dist
|
||||
|
||||
.env
|
||||
.envrc
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "frigate-hass-integration"]
|
||||
path = .devcontainer/frigate-hass-integration
|
||||
url = https://github.com/blakeblackshear/frigate-hass-integration
|
||||
branch = master
|
||||
@@ -6,8 +6,8 @@
|
||||
"esbenp.prettier-vscode",
|
||||
"bierner.lit-html",
|
||||
"runem.lit-plugin",
|
||||
"auchenberg.vscode-browser-preview",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"redhat.vscode-yaml"
|
||||
"redhat.vscode-yaml",
|
||||
"lokalise.i18n-ally"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# An array of strings which contain Language Ids defined by VS Code
|
||||
# You can check avaliable language ids here: https://code.visualstudio.com/docs/languages/overview#_language-id
|
||||
languageIds:
|
||||
- typescript
|
||||
|
||||
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
|
||||
# You should unescape RegEx strings in order to fit in the YAML file
|
||||
# To help with this, you can use https://www.freeformatter.com/json-escape.html
|
||||
usageMatchRegex:
|
||||
# The following example shows how to detect `t("your.i18n.keys")`
|
||||
# the `{key}` will be placed by a proper keypath matching regex,
|
||||
# you can ignore it and use your own matching rules as well
|
||||
- "[^\\w\\d]localize\\(['\"`]({key})['\"`]"
|
||||
|
||||
|
||||
# An array of strings containing refactor templates.
|
||||
# The "$1" will be replaced by the keypath specified.
|
||||
# Optional: uncomment the following two lines to use
|
||||
|
||||
# refactorTemplates:
|
||||
# - i18n.get("$1")
|
||||
|
||||
|
||||
# If set to true, only enables this custom framework (will disable all built-in frameworks)
|
||||
monopoly: true
|
||||
@@ -0,0 +1,127 @@
|
||||
# Review comments generated by i18n-ally. Please commit this file.
|
||||
|
||||
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:
|
||||
locales:
|
||||
it:
|
||||
translation_candidate:
|
||||
source: en
|
||||
text: Mostra l'immagine fissa durante il caricamento del live streaming
|
||||
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:
|
||||
locales:
|
||||
it:
|
||||
translation_candidate:
|
||||
source: en
|
||||
text: Disposizione adatta
|
||||
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:
|
||||
locales:
|
||||
it:
|
||||
translation_candidate:
|
||||
source: en
|
||||
text: I media sono contenuti/incartati
|
||||
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:
|
||||
locales:
|
||||
it:
|
||||
translation_candidate:
|
||||
source: en
|
||||
text: Il supporto si espande proporzionalmente per coprire la scheda
|
||||
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:
|
||||
locales:
|
||||
it:
|
||||
translation_candidate:
|
||||
source: en
|
||||
text: Il supporto viene allungato per riempire la scheda
|
||||
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:
|
||||
locales:
|
||||
it:
|
||||
translation_candidate:
|
||||
source: en
|
||||
text: Percentuale di posizionamento orizzontale
|
||||
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:
|
||||
locales:
|
||||
it:
|
||||
translation_candidate:
|
||||
source: en
|
||||
text: Percentuale di posizionamento verticale
|
||||
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:
|
||||
locales:
|
||||
it:
|
||||
translation_candidate:
|
||||
source: en
|
||||
text: Disposizione dell'immagine
|
||||
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:
|
||||
locales:
|
||||
it:
|
||||
translation_candidate:
|
||||
source: en
|
||||
text: Layout del visualizzatore multimediale
|
||||
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'
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "pwa-chrome",
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome against Home Assistant",
|
||||
"url": "http://localhost:8123",
|
||||
"webRoot": "${workspaceFolder}/dist",
|
||||
"preLaunchTask": "Rollup",
|
||||
"resolveSourceMapLocations": ["http://localhost:1000/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": ["src/localize/languages"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.sortKeys": true,
|
||||
"i18n-ally.keepFulfilled": true,
|
||||
"i18n-ally.editor.preferEditor": true,
|
||||
"i18n-ally.translate.saveAsCandidates": true
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"problemMatcher": [],
|
||||
"label": "npm: start",
|
||||
"detail": "rollup -c --watch"
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Rollup",
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": ".",
|
||||
"file": 1,
|
||||
"location": 2,
|
||||
"message": 3
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "^bundles",
|
||||
"endsPattern": "^created"
|
||||
}
|
||||
},
|
||||
"isBackground": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
version: '3'
|
||||
services:
|
||||
dev:
|
||||
user: node
|
||||
init: true
|
||||
build: .devcontainer
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker-host.sock
|
||||
- .:${LOCAL_WORKSPACE_FOLDER}:cached
|
||||
hass:
|
||||
extends:
|
||||
file: .devcontainer/frigate-hass-integration/docker-compose.yml
|
||||
service: hass
|
||||
volumes:
|
||||
- .devcontainer/preconfig:/preconfig.d/02-card:ro
|
||||
frigate:
|
||||
extends:
|
||||
file: .devcontainer/frigate-hass-integration/docker-compose.yml
|
||||
service: frigate
|
||||
mqtt:
|
||||
extends:
|
||||
file: .devcontainer/frigate-hass-integration/docker-compose.yml
|
||||
service: mqtt
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 759 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 736 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frigate-hass-card",
|
||||
"version": "3.0.0",
|
||||
"version": "4.0.0",
|
||||
"description": "Frigate Lovelace Card for Home Assistant",
|
||||
"keywords": [
|
||||
"frigate",
|
||||
@@ -15,51 +15,64 @@
|
||||
"author": "Dermot Duffy <dermot.duffy@gmail.com>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^5.1.1",
|
||||
"@lit-labs/task": "^1.0.0",
|
||||
"@material/image-list": "^13.0.0",
|
||||
"@material/mwc-menu": "^0.25.3",
|
||||
"@material/rtl": "^13.0.0",
|
||||
"custom-card-helpers": "^1.8.0",
|
||||
"dayjs": "^1.11.0",
|
||||
"embla-carousel": "^6.1.1",
|
||||
"home-assistant-js-websocket": "^6.1.1",
|
||||
"lit": "^2.2.1",
|
||||
"@cycjimmy/jsmpeg-player": "^6.0.4",
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"@lit-labs/task": "^1.1.3",
|
||||
"@types/bluebird": "^3.5.36",
|
||||
"component-emitter": "^1.3.0",
|
||||
"crypto": "^1.0.1",
|
||||
"custom-card-helpers": "^1.9.0",
|
||||
"date-fns": "^2.29.2",
|
||||
"embla-carousel": "^7.0.2",
|
||||
"embla-carousel-wheel-gestures": "^3.0.0",
|
||||
"home-assistant-js-websocket": "^8.0.0",
|
||||
"keycharm": "^0.4.0",
|
||||
"lit": "^2.3.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"propagating-hammerjs": "^2.0.1",
|
||||
"quick-lru": "^6.1.0",
|
||||
"screenfull": "^6.0.1",
|
||||
"zod": "^3.13.4"
|
||||
"screenfull": "^6.0.2",
|
||||
"side-drawer": "^3.1.0",
|
||||
"ts-toolbelt": "^9.6.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vis-data": "^7.1.3",
|
||||
"vis-timeline": "^7.7.0",
|
||||
"vis-util": "^5.0.2",
|
||||
"xss": "^1.0.14",
|
||||
"zod": "^3.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.7",
|
||||
"@babel/plugin-proposal-class-properties": "^7.14.5",
|
||||
"@babel/plugin-proposal-decorators": "^7.15.8",
|
||||
"@babel/core": "^7.19.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-proposal-decorators": "^7.19.0",
|
||||
"@rollup/plugin-babel": "^5.3.1",
|
||||
"@rollup/plugin-commonjs": "^21.0.2",
|
||||
"@rollup/plugin-commonjs": "^22.0.2",
|
||||
"@rollup/plugin-image": "^2.1.1",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^13.1.3",
|
||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||
"@rollup/plugin-replace": "^4.0.0",
|
||||
"@types/lodash-es": "^4.17.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.15.0",
|
||||
"@typescript-eslint/parser": "^5.15.0",
|
||||
"eslint": "^8.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.36.2",
|
||||
"@typescript-eslint/parser": "^5.36.2",
|
||||
"eslint": "^8.23.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"npm-check-updates": "^12.5.3",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"prettier": "^2.6.0",
|
||||
"rollup": "^2.70.1",
|
||||
"rollup-plugin-serve": "^1.1.0",
|
||||
"rollup": "^2.79.0",
|
||||
"rollup-plugin-git-info": "^1.0.0",
|
||||
"rollup-plugin-serve": "^2.0.1",
|
||||
"rollup-plugin-styles": "^4.0.0",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"rollup-plugin-typescript2": "^0.31.2",
|
||||
"sass": "^1.49.9",
|
||||
"typescript": "^4.6.2"
|
||||
"rollup-plugin-typescript2": "^0.33.0",
|
||||
"sass": "^1.54.9",
|
||||
"typescript": "^4.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "rollup -c rollup.config.dev.js --watch",
|
||||
"build": "npm run lint && npm run rollup",
|
||||
"start": "rollup -c --watch",
|
||||
"build": "yarn run lint && yarn run rollup",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"rollup": "rollup -c"
|
||||
}
|
||||
|
||||
@@ -2,24 +2,35 @@ import typescript from 'rollup-plugin-typescript2';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
import babel from '@rollup/plugin-babel';
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
import serve from 'rollup-plugin-serve';
|
||||
import json from '@rollup/plugin-json';
|
||||
import styles from 'rollup-plugin-styles';
|
||||
import image from '@rollup/plugin-image';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import gitInfo from 'rollup-plugin-git-info';
|
||||
|
||||
const dev = process.env.ROLLUP_WATCH;
|
||||
const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1';
|
||||
const dev = watch || process.env.DEV === 'true' || process.env.DEV === '1';
|
||||
|
||||
/**
|
||||
* @type {import('rollup-plugin-serve').ServeOptions}
|
||||
*/
|
||||
const serveopts = {
|
||||
contentBase: ['./dist'],
|
||||
host: '0.0.0.0',
|
||||
port: 5000,
|
||||
port: 10001,
|
||||
allowCrossOrigin: true,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('rollup').RollupOptions['plugins']}
|
||||
*/
|
||||
const plugins = [
|
||||
gitInfo({ enableBuildDate: true, updateVersion: false }),
|
||||
styles({
|
||||
modules: false,
|
||||
// Behavior of inject mode, without actually injecting style
|
||||
@@ -30,27 +41,47 @@ const plugins = [
|
||||
},
|
||||
}),
|
||||
image(),
|
||||
nodeResolve({}),
|
||||
nodeResolve({
|
||||
browser: true,
|
||||
}),
|
||||
commonjs({
|
||||
include: 'node_modules/**'
|
||||
include: 'node_modules/**',
|
||||
}),
|
||||
typescript(),
|
||||
json(),
|
||||
json({ exclude: 'package.json' }),
|
||||
babel({
|
||||
babelHelpers: 'bundled',
|
||||
exclude: 'node_modules/**',
|
||||
}),
|
||||
dev && serve(serveopts),
|
||||
//!dev && terser(),
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
values: {
|
||||
'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'),
|
||||
},
|
||||
}),
|
||||
watch && serve(serveopts),
|
||||
!dev && terser(),
|
||||
];
|
||||
|
||||
export default [
|
||||
{
|
||||
input: 'src/card.ts',
|
||||
output: {
|
||||
file: 'dist/frigate-hass-card.js',
|
||||
format: 'es',
|
||||
},
|
||||
plugins: [...plugins],
|
||||
/**
|
||||
* @type {import('rollup').RollupOptions}
|
||||
*/
|
||||
const config = {
|
||||
input: 'src/card.ts',
|
||||
output: {
|
||||
file: 'dist/frigate-hass-card.js',
|
||||
format: 'es',
|
||||
...(dev && {
|
||||
sourcemap: true,
|
||||
}),
|
||||
},
|
||||
];
|
||||
plugins: plugins,
|
||||
// These two files use this at the toplevel, which causes rollup warning
|
||||
// spam on build: `this` has been rewritten to `undefined`
|
||||
moduleContext: {
|
||||
'./node_modules/@formatjs/intl-utils/lib/src/diff.js': 'window',
|
||||
'./node_modules/@formatjs/intl-utils/lib/src/resolve-locale.js': 'window',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { fireEvent } from 'custom-card-helpers';
|
||||
import type {
|
||||
ActionHandlerDetail,
|
||||
ActionHandlerOptions,
|
||||
} from 'custom-card-helpers/dist/types.d.js';
|
||||
import { noChange } from 'lit';
|
||||
import {
|
||||
AttributePart,
|
||||
@@ -5,13 +10,7 @@ import {
|
||||
Directive,
|
||||
DirectiveParameters,
|
||||
} from 'lit/directive.js';
|
||||
|
||||
import type {
|
||||
ActionHandlerDetail,
|
||||
ActionHandlerOptions,
|
||||
} from 'custom-card-helpers/dist/types.d.js';
|
||||
import { fireEvent } from 'custom-card-helpers';
|
||||
import { stopEventFromActivatingCardWideActions } from './common';
|
||||
import { stopEventFromActivatingCardWideActions } from './utils/action.js';
|
||||
|
||||
interface ActionHandler extends HTMLElement {
|
||||
holdTime: number;
|
||||
@@ -21,12 +20,6 @@ interface ActionHandlerElement extends HTMLElement {
|
||||
actionHandlerOptions?: FrigateCardActionHandlerOptions;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
action: ActionHandlerDetail;
|
||||
}
|
||||
}
|
||||
|
||||
interface FrigateCardActionHandlerOptions extends ActionHandlerOptions {
|
||||
allowPropagation?: boolean;
|
||||
}
|
||||
@@ -142,10 +135,9 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
||||
};
|
||||
|
||||
const handleEnter = (ev: KeyboardEvent): void => {
|
||||
if (ev.keyCode !== 13) {
|
||||
return;
|
||||
if (ev.key === 'Enter') {
|
||||
end(ev);
|
||||
}
|
||||
end(ev);
|
||||
};
|
||||
|
||||
element.addEventListener('touchstart', start, { passive: true });
|
||||
@@ -195,3 +187,12 @@ export const actionHandler = directive(
|
||||
render(_options?: FrigateCardActionHandlerOptions) {}
|
||||
},
|
||||
);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'action-handler-frigate-card': ActionHandler;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
action: ActionHandlerDetail;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import dayjs from 'dayjs';
|
||||
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js';
|
||||
|
||||
import type {
|
||||
BrowseMediaQueryParameters,
|
||||
BrowseMediaSource,
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
} from './types.js';
|
||||
import { View } from './view.js';
|
||||
import { browseMediaSourceSchema } from './types.js';
|
||||
import {
|
||||
dispatchErrorMessageEvent,
|
||||
dispatchMessageEvent,
|
||||
homeAssistantWSRequest,
|
||||
} from './common.js';
|
||||
import { localize } from './localize/localize.js';
|
||||
|
||||
dayjs.extend(dayjs_custom_parse_format);
|
||||
|
||||
export class BrowseMediaUtil {
|
||||
/**
|
||||
* Return the Frigate event_id given a BrowseMediaSource object.
|
||||
* @param media The event to extract the id from.
|
||||
* @returns The `event_id` or `null` if not successfully parsed.
|
||||
*/
|
||||
static extractEventID(media: BrowseMediaSource): string | null {
|
||||
const result = media.media_content_id.match(
|
||||
/^media-source:\/\/frigate\/.*\/(?<id>[.0-9]+-[a-zA-Z0-9]+)$/,
|
||||
);
|
||||
return result && result.groups ? result.groups['id'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the event start time given a BrowseMediaSource object.
|
||||
* @param browseMedia The media object to extract the start time from.
|
||||
* @returns The start time in unix/epoch time, or null if it cannot be determined.
|
||||
*/
|
||||
static extractEventStartTime(browseMedia: BrowseMediaSource): number | null {
|
||||
// Example: 2021-08-27 20:57:22 [10s, Person 76%]
|
||||
const result = browseMedia.title.match(/^(?<iso_datetime>.+) \[/);
|
||||
if (result && result.groups) {
|
||||
const iso_datetime_str = result.groups['iso_datetime'];
|
||||
if (iso_datetime_str) {
|
||||
const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true);
|
||||
if (iso_datetime.isValid()) {
|
||||
return iso_datetime.unix();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a BrowseMediaSource object is truly a media item (vs a folder).
|
||||
* @param media The media object.
|
||||
* @returns `true` if it's truly a media item, `false` otherwise.
|
||||
*/
|
||||
static isTrueMedia(media: BrowseMediaSource): boolean {
|
||||
return !media.can_expand;
|
||||
}
|
||||
|
||||
/**
|
||||
* From a BrowseMediaSource item extract the first true media item from the
|
||||
* children (i.e. a clip/snapshot, not a folder).
|
||||
* @param media The media object with children.
|
||||
* @returns The first true media item found.
|
||||
*/
|
||||
static getFirstTrueMediaChildIndex(media: BrowseMediaSource | null): number | null {
|
||||
if (!media || !media.children) {
|
||||
return null;
|
||||
}
|
||||
const index = media.children.findIndex((child) => this.isTrueMedia(child));
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse Frigate media with a media content id. May throw.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param media_content_id The media content id to browse.
|
||||
* @returns A BrowseMediaSource object or null on malformed.
|
||||
*/
|
||||
static async browseMedia(
|
||||
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||
media_content_id: string,
|
||||
): Promise<BrowseMediaSource> {
|
||||
const request = {
|
||||
type: 'media_source/browse_media',
|
||||
media_content_id: media_content_id,
|
||||
};
|
||||
return homeAssistantWSRequest(hass, browseMediaSourceSchema, request);
|
||||
}
|
||||
|
||||
// Browse Frigate media with query parameters.
|
||||
|
||||
/**
|
||||
* Browse Frigate media with a media query. May throw.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param params The search parameters to use to search for media.
|
||||
* @returns A BrowseMediaSource object or null on malformed.
|
||||
*/
|
||||
static async browseMediaQuery(
|
||||
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||
params: BrowseMediaQueryParameters,
|
||||
): Promise<BrowseMediaSource> {
|
||||
return this.browseMedia(
|
||||
hass,
|
||||
// Defined in:
|
||||
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
|
||||
[
|
||||
'media-source://frigate',
|
||||
params.clientId,
|
||||
'event-search',
|
||||
params.mediaType,
|
||||
'', // Name/Title to render (not necessary here)
|
||||
params.after ? String(params.after) : '',
|
||||
params.before ? String(params.before) : '',
|
||||
params.cameraName,
|
||||
params.label,
|
||||
params.zone,
|
||||
].join('/'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parameters to search for media.
|
||||
* @returns A BrowseMediaQueryParameters object.
|
||||
*/
|
||||
static getBrowseMediaQueryParameters(
|
||||
mediaType: 'clips' | 'snapshots',
|
||||
cameraConfig?: CameraConfig,
|
||||
): BrowseMediaQueryParameters | undefined {
|
||||
if (!cameraConfig || !cameraConfig.camera_name) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
mediaType: mediaType,
|
||||
clientId: cameraConfig.client_id,
|
||||
cameraName: cameraConfig.camera_name,
|
||||
label: cameraConfig.label,
|
||||
zone: cameraConfig.zone,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parameters to search for media related to the current view.
|
||||
* @returns A BrowseMediaQueryParameters object.
|
||||
*/
|
||||
static getBrowseMediaQueryParametersOrDispatchError(
|
||||
node: HTMLElement,
|
||||
view: View,
|
||||
cameraConfig: CameraConfig,
|
||||
): BrowseMediaQueryParameters | undefined {
|
||||
if (!view.isClipRelatedView() && !view.isSnapshotRelatedView()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Verify there is a camera name, otherwise getBrowseMediaQueryParameters()
|
||||
// will return undefined.
|
||||
if (!cameraConfig.camera_name) {
|
||||
dispatchErrorMessageEvent(
|
||||
node,
|
||||
localize('error.no_camera_name') + `: ${JSON.stringify(cameraConfig)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return BrowseMediaUtil.getBrowseMediaQueryParameters(
|
||||
view.isClipRelatedView() ? 'clips' : 'snapshots',
|
||||
cameraConfig,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest media and dispatch a change view event to reflect the
|
||||
* results. If no media is found a suitable message event will be triggered
|
||||
* instead.
|
||||
* @param node The HTMLElement to dispatch events from.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param view The current view to evolve.
|
||||
* @param browseMediaQueryParameters The media parameters to query with.
|
||||
* @returns
|
||||
*/
|
||||
static async fetchLatestMediaAndDispatchViewChange(
|
||||
node: HTMLElement,
|
||||
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||
view: Readonly<View>,
|
||||
browseMediaQueryParameters: BrowseMediaQueryParameters,
|
||||
): Promise<void> {
|
||||
let parent: BrowseMediaSource | null;
|
||||
try {
|
||||
parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaQueryParameters);
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(node, (e as Error).message);
|
||||
}
|
||||
const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent);
|
||||
if (!parent || !parent.children || childIndex == null) {
|
||||
return dispatchMessageEvent(
|
||||
node,
|
||||
browseMediaQueryParameters.mediaType == 'clips'
|
||||
? localize('common.no_clip')
|
||||
: localize('common.no_snapshot'),
|
||||
browseMediaQueryParameters.mediaType == 'clips'
|
||||
? 'mdi:filmstrip-off'
|
||||
: 'mdi:camera-off',
|
||||
);
|
||||
}
|
||||
|
||||
view
|
||||
.evolve({
|
||||
target: parent,
|
||||
childIndex: childIndex,
|
||||
})
|
||||
.dispatchChangeEvent(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the media of a child BrowseMediaSource object and dispatch a change
|
||||
* view event to reflect the results.
|
||||
* @param node The HTMLElement to dispatch events from.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param view The current view to evolve.
|
||||
* @param child The BrowseMediaSource child to query for.
|
||||
* @returns
|
||||
*/
|
||||
static async fetchChildMediaAndDispatchViewChange(
|
||||
node: HTMLElement,
|
||||
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||
view: Readonly<View>,
|
||||
child: Readonly<BrowseMediaSource>,
|
||||
): Promise<void> {
|
||||
let parent: BrowseMediaSource;
|
||||
try {
|
||||
parent = await BrowseMediaUtil.browseMedia(hass, child.media_content_id);
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(node, (e as Error).message);
|
||||
}
|
||||
|
||||
view
|
||||
.evolve({
|
||||
target: parent,
|
||||
previous: view,
|
||||
})
|
||||
.dispatchChangeEvent(node);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export class CachedValueController<T> implements ReactiveController {
|
||||
* Remove the controller for the host.
|
||||
*/
|
||||
public removeController(): void {
|
||||
this.stopTimer();
|
||||
this._host.removeController(this);
|
||||
}
|
||||
|
||||
@@ -28,11 +29,10 @@ export class CachedValueController<T> implements ReactiveController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cached value (and reset the timer).
|
||||
* Update the cached value.
|
||||
*/
|
||||
public updateValue(): void {
|
||||
this._value = this._callback();
|
||||
this._startTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,22 +40,24 @@ export class CachedValueController<T> implements ReactiveController {
|
||||
*/
|
||||
public clearValue(): void {
|
||||
this._value = undefined;
|
||||
this._stopTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the timer.
|
||||
*/
|
||||
protected _stopTimer(): void {
|
||||
clearInterval(this._timerID);
|
||||
public stopTimer(): void {
|
||||
if (this._timerID !== undefined) {
|
||||
window.clearInterval(this._timerID);
|
||||
}
|
||||
this._timerID = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the timer. Repeated calls will have no effect.
|
||||
*/
|
||||
protected _startTimer(): void {
|
||||
this._stopTimer();
|
||||
public startTimer(): void {
|
||||
this.stopTimer();
|
||||
|
||||
if (this._timerSeconds > 0) {
|
||||
this._timerID = window.setInterval(() => {
|
||||
this.updateValue();
|
||||
@@ -69,6 +71,7 @@ export class CachedValueController<T> implements ReactiveController {
|
||||
*/
|
||||
hostConnected(): void {
|
||||
this.updateValue();
|
||||
this.startTimer();
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -77,5 +80,6 @@ export class CachedValueController<T> implements ReactiveController {
|
||||
*/
|
||||
hostDisconnected(): void {
|
||||
this.clearValue();
|
||||
this.stopTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ import type {
|
||||
OverrideConfigurationKey,
|
||||
RawFrigateCardConfig,
|
||||
} from './types';
|
||||
import { merge, cloneDeep } from 'lodash-es';
|
||||
import { HassEntities } from 'home-assistant-js-websocket';
|
||||
import { cloneDeep, merge } from 'lodash-es';
|
||||
|
||||
export interface ConditionState {
|
||||
view?: string;
|
||||
fullscreen?: boolean;
|
||||
camera?: string;
|
||||
state?: HassEntities;
|
||||
mediaLoaded?: boolean;
|
||||
}
|
||||
|
||||
class ConditionStateRequestEvent extends Event {
|
||||
@@ -34,6 +37,22 @@ export function evaluateCondition(
|
||||
if (condition?.camera?.length) {
|
||||
result &&= !!state.camera && condition.camera.includes(state.camera);
|
||||
}
|
||||
if (condition?.state?.length) {
|
||||
for (const stateTest of condition?.state) {
|
||||
result &&=
|
||||
!!state.state &&
|
||||
((!stateTest.state && !stateTest.state_not) ||
|
||||
(stateTest.entity in state.state &&
|
||||
(!stateTest.state ||
|
||||
state.state[stateTest.entity].state === stateTest.state) &&
|
||||
(!stateTest.state_not ||
|
||||
state.state[stateTest.entity].state !== stateTest.state_not)));
|
||||
}
|
||||
}
|
||||
if (condition?.mediaLoaded !== undefined) {
|
||||
result &&=
|
||||
state.mediaLoaded !== undefined && condition.mediaLoaded == state.mediaLoaded;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -42,7 +61,7 @@ export function evaluateCondition(
|
||||
* @returns A boolean indicating whether the condition is met.
|
||||
*/
|
||||
export function fetchStateAndEvaluateCondition(
|
||||
node: HTMLElement,
|
||||
element: HTMLElement,
|
||||
condition?: FrigateCardCondition,
|
||||
): boolean {
|
||||
if (!condition) {
|
||||
@@ -69,7 +88,7 @@ export function fetchStateAndEvaluateCondition(
|
||||
* synchronously, the state will be added to the event before the flow
|
||||
* proceeds.
|
||||
*/
|
||||
node.dispatchEvent(stateEvent);
|
||||
element.dispatchEvent(stateEvent);
|
||||
return evaluateCondition(condition, stateEvent.conditionState);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
import { HassEntity, MessageBase } from 'home-assistant-js-websocket';
|
||||
import {
|
||||
HomeAssistant,
|
||||
computeStateDomain,
|
||||
handleActionConfig,
|
||||
hasAction,
|
||||
stateIcon,
|
||||
} from 'custom-card-helpers';
|
||||
import { StyleInfo } from 'lit/directives/style-map.js';
|
||||
import { ZodSchema, z } from 'zod';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { localize } from './localize/localize.js';
|
||||
import {
|
||||
Actions,
|
||||
ActionsConfig,
|
||||
ActionType,
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardAction,
|
||||
FrigateCardCustomAction,
|
||||
frigateCardCustomActionSchema,
|
||||
MediaShowInfo,
|
||||
Message,
|
||||
SignedPath,
|
||||
signedPathSchema,
|
||||
StateParameters,
|
||||
} from './types.js';
|
||||
|
||||
const MEDIA_INFO_HEIGHT_CUTOFF = 50;
|
||||
const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
|
||||
|
||||
/**
|
||||
* Get the keys that didn't parse from a ZodError.
|
||||
* @param error The zoderror to extract the keys from.
|
||||
* @returns An array of error keys.
|
||||
*/
|
||||
export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] {
|
||||
const errors = error.format();
|
||||
return Object.keys(errors).filter((v) => !v.startsWith('_'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a HomeAssistant websocket request. May throw.
|
||||
* @param hass The HomeAssistant object to send the request with.
|
||||
* @param schema The expected Zod schema of the response.
|
||||
* @param request The request to make.
|
||||
* @returns The parsed valid response or null on malformed.
|
||||
*/
|
||||
export async function homeAssistantWSRequest<T>(
|
||||
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||
schema: ZodSchema<T>,
|
||||
request: MessageBase,
|
||||
): Promise<T> {
|
||||
const response = await hass.callWS<T>(request);
|
||||
|
||||
if (!response) {
|
||||
const error_message = `${localize('error.empty_response')}: ${JSON.stringify(
|
||||
request,
|
||||
)}`;
|
||||
console.warn(error_message);
|
||||
throw new Error(error_message);
|
||||
}
|
||||
const parseResult = schema.safeParse(response);
|
||||
if (!parseResult.success) {
|
||||
const keys = getParseErrorKeys<T>(parseResult.error);
|
||||
const error_message =
|
||||
`${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` +
|
||||
localize('error.invalid_keys') +
|
||||
`: '${keys}'`;
|
||||
console.warn(error_message);
|
||||
throw new Error(error_message);
|
||||
}
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request that HA sign a path. May throw.
|
||||
* @param hass The HomeAssistant object used to request the signature.
|
||||
* @param path The path to sign.
|
||||
* @param expires An optional number of seconds to sign the path for.
|
||||
* @returns The signed URL, or null if the response was malformed.
|
||||
*/
|
||||
export async function homeAssistantSignPath(
|
||||
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||
path: string,
|
||||
expires?: number,
|
||||
): Promise<string | null> {
|
||||
const request = {
|
||||
type: 'auth/sign_path',
|
||||
path: path,
|
||||
expires: expires,
|
||||
};
|
||||
const response = await homeAssistantWSRequest<SignedPath>(
|
||||
hass,
|
||||
signedPathSchema,
|
||||
request,
|
||||
);
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
return hass.hassUrl(response.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a Frigate Card event.
|
||||
* @param element The element to send the event.
|
||||
* @param name The name of the Frigate card event to send.
|
||||
* @param detail An optional detail object to attach.
|
||||
*/
|
||||
export function dispatchFrigateCardEvent<T>(
|
||||
element: HTMLElement,
|
||||
name: string,
|
||||
detail?: T,
|
||||
): void {
|
||||
element.dispatchEvent(
|
||||
new CustomEvent<T>(`frigate-card:${name}`, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: detail,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MediaShowInfo object.
|
||||
* @param source An event or HTMLElement that should be used as a source.
|
||||
* @returns A new MediaShowInfo object or null if one could not be created.
|
||||
*/
|
||||
export function createMediaShowInfo(source: Event | HTMLElement): MediaShowInfo | null {
|
||||
let target: HTMLElement | EventTarget;
|
||||
if (source instanceof Event) {
|
||||
target = source.composedPath()[0];
|
||||
} else {
|
||||
target = source;
|
||||
}
|
||||
|
||||
if (target instanceof HTMLImageElement) {
|
||||
return {
|
||||
width: (target as HTMLImageElement).naturalWidth,
|
||||
height: (target as HTMLImageElement).naturalHeight,
|
||||
};
|
||||
} else if (target instanceof HTMLVideoElement) {
|
||||
return {
|
||||
width: (target as HTMLVideoElement).videoWidth,
|
||||
height: (target as HTMLVideoElement).videoHeight,
|
||||
};
|
||||
} else if (target instanceof HTMLCanvasElement) {
|
||||
return {
|
||||
width: (target as HTMLCanvasElement).width,
|
||||
height: (target as HTMLCanvasElement).height,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a Frigate card media show event.
|
||||
* @param element The element to send the event.
|
||||
* @param source An event or HTMLElement that should be used as a source.
|
||||
*/
|
||||
export function dispatchMediaShowEvent(
|
||||
element: HTMLElement,
|
||||
source: Event | HTMLElement,
|
||||
): void {
|
||||
const mediaShowInfo = createMediaShowInfo(source);
|
||||
if (mediaShowInfo) {
|
||||
dispatchExistingMediaShowInfoAsEvent(element, mediaShowInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a pre-existing MediaShowInfo object as an event.
|
||||
* @param element The element to send the event.
|
||||
* @param mediaShowInfo The MediaShowInfo object to send.
|
||||
*/
|
||||
export function dispatchExistingMediaShowInfoAsEvent(
|
||||
element: HTMLElement,
|
||||
mediaShowInfo: MediaShowInfo,
|
||||
): void {
|
||||
dispatchFrigateCardEvent<MediaShowInfo>(element, 'media-show', mediaShowInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event with a message to show to the user.
|
||||
* @param element The element to send the event.
|
||||
* @param message The message to show.
|
||||
* @param icon An optional icon to attach to the message.
|
||||
*/
|
||||
export function dispatchMessageEvent(
|
||||
element: HTMLElement,
|
||||
message: string,
|
||||
icon?: string,
|
||||
context?: unknown,
|
||||
): void {
|
||||
dispatchFrigateCardEvent<Message>(element, 'message', {
|
||||
message: message,
|
||||
type: 'info',
|
||||
icon: icon,
|
||||
context: context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event with an error message to show to the user.
|
||||
* @param element The element to send the event.
|
||||
* @param message The message to show.
|
||||
*/
|
||||
export function dispatchErrorMessageEvent(
|
||||
element: HTMLElement,
|
||||
message: string,
|
||||
context?: unknown,
|
||||
): void {
|
||||
dispatchFrigateCardEvent<Message>(element, 'message', {
|
||||
message: message,
|
||||
type: 'error',
|
||||
context: context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the card should be updated based on Home Assistant changes.
|
||||
* @param newHass The new HA object.
|
||||
* @param oldHass The old HA object.
|
||||
* @param entities The entities to examine for changes.
|
||||
* @returns A boolean indicating whether or not to allow an update.
|
||||
*/
|
||||
export function shouldUpdateBasedOnHass(
|
||||
newHass: HomeAssistant | undefined | null,
|
||||
oldHass: HomeAssistant | undefined | null,
|
||||
entities: string[] | null,
|
||||
): boolean {
|
||||
if (!newHass || !entities || !entities.length) {
|
||||
return false;
|
||||
}
|
||||
if (!oldHass) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const entity = entities[i];
|
||||
if (entity && oldHass.states[entity] !== newHass.states[entity]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a MediaShowInfo object is valid/acceptable.
|
||||
* @param info The MediaShowInfo object.
|
||||
* @returns True if the object is valid, false otherwise.
|
||||
*/
|
||||
export function isValidMediaShowInfo(info: MediaShowInfo): boolean {
|
||||
return (
|
||||
info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a generic Action to a FrigateCardCustomAction if it parses correctly.
|
||||
* @param action The generic action configuration.
|
||||
* @returns A FrigateCardCustomAction or null if it cannot be converted.
|
||||
*/
|
||||
export function convertActionToFrigateCardCustomAction(
|
||||
action: ActionType | null,
|
||||
): FrigateCardCustomAction | null {
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
// Parse a custom event as other things could generate ll-custom events that
|
||||
// are not related to Frigate Card.
|
||||
const parseResult = frigateCardCustomActionSchema.safeParse(action);
|
||||
return parseResult.success ? parseResult.data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Frigate card custom action.
|
||||
* @param action The Frigate card action string (e.g. 'fullscreen')
|
||||
* @returns A FrigateCardCustomAction for that action string.
|
||||
*/
|
||||
export function createFrigateCardCustomAction(
|
||||
action: FrigateCardAction,
|
||||
camera?: string,
|
||||
): FrigateCardCustomAction | undefined {
|
||||
if (action == 'camera_select') {
|
||||
if (!camera) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: action,
|
||||
camera: camera,
|
||||
};
|
||||
}
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: action,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an action configuration given a config and an interaction (e.g. 'tap').
|
||||
* @param interaction The interaction: `tap`, `hold` or `double_tap`
|
||||
* @param config The configuration containing multiple actions.
|
||||
* @returns The relevant action configuration or null if none found.
|
||||
*/
|
||||
export function getActionConfigGivenAction(
|
||||
interaction?: string,
|
||||
config?: Actions,
|
||||
): ActionType | ActionType[] | undefined {
|
||||
if (!interaction || !config) {
|
||||
return undefined;
|
||||
}
|
||||
if (interaction == 'tap' && config.tap_action) {
|
||||
return config.tap_action;
|
||||
} else if (interaction == 'hold' && config.hold_action) {
|
||||
return config.hold_action;
|
||||
} else if (interaction == 'double_tap' && config.double_tap_action) {
|
||||
return config.double_tap_action;
|
||||
} else if (interaction == 'end_tap' && config.end_tap_action) {
|
||||
return config.end_tap_action;
|
||||
} else if (interaction == 'start_tap' && config.start_tap_action) {
|
||||
return config.start_tap_action;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a style brightness from a hass state.
|
||||
* Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L296
|
||||
* @param state The hass state object.
|
||||
* @returns A CSS brightness string.
|
||||
*/
|
||||
function computeBrightnessFromState(state: HassEntity): string {
|
||||
if (state.state === 'off' || !state.attributes.brightness) {
|
||||
return '';
|
||||
}
|
||||
const brightness = state.attributes.brightness;
|
||||
return `brightness(${(brightness + 245) / 5}%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a style color from a hass state.
|
||||
* Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L304
|
||||
* @param state The hass state object.
|
||||
* @returns A CSS color string.
|
||||
*/
|
||||
function computeColorFromState(state: HassEntity): string {
|
||||
if (state.state === 'off') {
|
||||
return '';
|
||||
}
|
||||
return state.attributes.rgb_color
|
||||
? `rgb(${state.attributes.rgb_color.join(',')})`
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the style of emphasized menu items.
|
||||
* @returns A StyleInfo.
|
||||
*/
|
||||
function computeStyle(state: HassEntity): StyleInfo {
|
||||
return {
|
||||
color: computeColorFromState(state),
|
||||
filter: computeBrightnessFromState(state),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the string state of a given stateObj.
|
||||
* From: https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_active_state.ts
|
||||
* @param stateObj The HassEntity object from `hass.states`.
|
||||
* @returns A string state, e.g. 'on'.
|
||||
*/
|
||||
export const computeActiveState = (stateObj: HassEntity): string => {
|
||||
const domain = stateObj.entity_id.split('.')[0];
|
||||
let state = stateObj.state;
|
||||
|
||||
if (domain === 'climate') {
|
||||
state = stateObj.attributes.hvac_action;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use Home Assistant state to refresh state parameters for an item to be rendered.
|
||||
* @param hass Home Assistant object.
|
||||
* @param params A StateParameters object to modify in place.
|
||||
* @returns A StateParameters object updated based on HASS state.
|
||||
*/
|
||||
export function refreshDynamicStateParameters(
|
||||
hass: HomeAssistant,
|
||||
params: StateParameters,
|
||||
): StateParameters {
|
||||
if (!params.entity) {
|
||||
return params;
|
||||
}
|
||||
const state = hass.states[params.entity];
|
||||
if (!!state && !!params.state_color) {
|
||||
params.style = { ...computeStyle(state), ...params.style };
|
||||
}
|
||||
params.title = params.title ?? (state?.attributes?.friendly_name || params.entity);
|
||||
params.icon = params.icon ?? stateIcon(state);
|
||||
|
||||
const domain = state ? computeStateDomain(state) : undefined;
|
||||
params.data_domain =
|
||||
params.state_color || (domain === 'light' && params.state_color !== false)
|
||||
? domain
|
||||
: undefined;
|
||||
if (state) {
|
||||
params.data_state = computeActiveState(state);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prettify a Frigate name by converting '_' to spaces and capitalizing words.
|
||||
* @param input The input Frigate (camera/label/zone) name.
|
||||
* @returns A prettified name.
|
||||
*/
|
||||
export function prettifyFrigateName(input?: string): string | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
const words = input.split(/[_\s]+/);
|
||||
return words
|
||||
.map((word) => {
|
||||
return word[0].toUpperCase() + word.substring(1);
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the title of an entity.
|
||||
* @param entity The entity id.
|
||||
* @param hass The Home Assistant object.
|
||||
* @returns The title or undefined.
|
||||
*/
|
||||
export function getEntityTitle(
|
||||
hass?: HomeAssistant,
|
||||
entity?: string,
|
||||
): string | undefined {
|
||||
return entity ? hass?.states[entity]?.attributes?.friendly_name : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon of an entity.
|
||||
* @param entity The entity id.
|
||||
* @param hass The Home Assistant object.
|
||||
* @returns The icon or undefined.
|
||||
*/
|
||||
export function getEntityIcon(
|
||||
hass?: HomeAssistant,
|
||||
entity?: string,
|
||||
): string | undefined {
|
||||
return hass && entity ? stateIcon(hass.states[entity]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a camera text title.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param config The camera config.
|
||||
* @returns A title string.
|
||||
*/
|
||||
export function getCameraTitle(
|
||||
hass?: HomeAssistant,
|
||||
config?: CameraConfig | null,
|
||||
): string {
|
||||
return (
|
||||
config?.title ||
|
||||
(config?.camera_entity ? getEntityTitle(hass, config.camera_entity) : '') ||
|
||||
(config?.camera_name ? prettifyFrigateName(config.camera_name) : '') ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a camera icon.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param config The camera config.
|
||||
* @returns An icon string.
|
||||
*/
|
||||
export function getCameraIcon(
|
||||
hass?: HomeAssistant,
|
||||
config?: CameraConfig | null,
|
||||
): string {
|
||||
return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video';
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an element within an array.
|
||||
* @param target Target array.
|
||||
* @param from From index.
|
||||
* @param to To index.
|
||||
*/
|
||||
export function arrayMove(target: unknown[], from: number, to: number): void {
|
||||
const element = target[from];
|
||||
target.splice(from, 1);
|
||||
target.splice(to, 0, element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the contents of the n(ew) and o(ld) values have changed. For use
|
||||
* in lit web components that may have a value that changes address but not
|
||||
* contents -- and for which a re-render is expensive/jarring.
|
||||
* @param n The new value.
|
||||
* @param o The old value.
|
||||
* @returns `true` is the contents have changed.
|
||||
*/
|
||||
export function contentsChanged(n: unknown, o: unknown): boolean {
|
||||
return !isEqual(n, o);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frigate card custom version of handleAction
|
||||
* (https://github.com/custom-cards/custom-card-helpers/blob/master/src/handle-action.ts)
|
||||
* that handles the custom action events the card supports.
|
||||
* @param node The node that fired the event.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param config The multi-action configuration.
|
||||
* @param action The action string (e.g. 'hold')
|
||||
* @returns Whether or not an action was executed.
|
||||
*/
|
||||
export const frigateCardHandleAction = (
|
||||
node: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
config: ActionsConfig,
|
||||
action: string,
|
||||
): boolean => {
|
||||
return frigateCardHandleActionConfig(
|
||||
node,
|
||||
hass,
|
||||
config,
|
||||
action,
|
||||
getActionConfigGivenAction(action, config),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle an ActionConfig or array of ActionConfigs.
|
||||
* @param node The node that fired the event.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param actionConfig A single action config, array of action configs or
|
||||
* undefined for the default action config for 'tap'.
|
||||
* @param action The action string (e.g. 'hold')
|
||||
* @returns Whether or not an action was executed.
|
||||
*/
|
||||
export const frigateCardHandleActionConfig = (
|
||||
node: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
config: {
|
||||
camera_image?: string;
|
||||
entity?: string;
|
||||
},
|
||||
action: string,
|
||||
actionConfig: ActionType | ActionType[] | undefined,
|
||||
): boolean => {
|
||||
if (actionConfig || action == 'tap') {
|
||||
// Only allow a tap action to use a default non-config (the more-info config).
|
||||
if (Array.isArray(actionConfig)) {
|
||||
actionConfig.forEach((action) => handleActionConfig(node, hass, config, action));
|
||||
} else {
|
||||
handleActionConfig(node, hass, config, actionConfig);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if an action config has a real action. A modified version of
|
||||
* custom-card-helpers hasAction to also work with arrays of action configs.
|
||||
* @param config The action config in question.
|
||||
* @returns `true` if there's a real action defined, `false` otherwise.
|
||||
*/
|
||||
export const frigateCardHasAction = (
|
||||
config?: ActionType | ActionType[] | undefined,
|
||||
): boolean => {
|
||||
if (Array.isArray(config)) {
|
||||
return !!config.find((item) => hasAction(item));
|
||||
}
|
||||
return hasAction(config);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop an event from activating card wide actions.
|
||||
*/
|
||||
export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
@@ -1,37 +1,197 @@
|
||||
import { CSSResultGroup, LitElement, unsafeCSS, PropertyValues } from 'lit';
|
||||
import EmblaCarousel, {
|
||||
EmblaCarouselType,
|
||||
EmblaOptionsType,
|
||||
EmblaPluginType,
|
||||
} from 'embla-carousel';
|
||||
|
||||
import { TransitionEffect } from '../types';
|
||||
import { dispatchFrigateCardEvent } from '../common';
|
||||
|
||||
import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel';
|
||||
import { EmblaNodesType } from 'embla-carousel/components';
|
||||
import {
|
||||
CreatePluginType,
|
||||
EmblaPluginsType,
|
||||
LoosePluginType,
|
||||
} from 'embla-carousel/components/Plugins';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { throttle } from 'lodash-es';
|
||||
import carouselStyle from '../scss/carousel.scss';
|
||||
import { TransitionEffect } from '../types';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
|
||||
export interface CarouselSelect {
|
||||
index: number;
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
export type EmblaCarouselPlugins = CreatePluginType<
|
||||
LoosePluginType,
|
||||
Record<string, unknown>
|
||||
>[];
|
||||
|
||||
@customElement('frigate-card-carousel')
|
||||
export class FrigateCardCarousel extends LitElement {
|
||||
@property({ attribute: true, reflect: true })
|
||||
public direction: 'vertical' | 'horizontal' = 'horizontal';
|
||||
|
||||
@property({ attribute: false })
|
||||
public carouselOptions?: EmblaOptionsType;
|
||||
|
||||
@property({ attribute: false })
|
||||
public carouselPlugins?: EmblaCarouselPlugins;
|
||||
|
||||
@property({ attribute: true })
|
||||
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 _carousel?: EmblaCarouselType;
|
||||
protected _plugins: Record<string, EmblaPluginType> = {};
|
||||
|
||||
// Whether the carousel is actively scrolling.
|
||||
protected _scrolling = false;
|
||||
|
||||
// Whether to reinit the carousel when it settles.
|
||||
protected _reInitOnSettle = false;
|
||||
|
||||
protected _carouselReInitInPlace = throttle(
|
||||
this._carouselReInitInPlaceInternal.bind(this),
|
||||
500,
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
// Guarantee a re-render if the component is reconnected. See note in
|
||||
// disconnectedCallback().
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
// Destroy the carousel when the component is disconnected, which forces the
|
||||
// plugins (which may have registered event handlers) to also be destroyed.
|
||||
// The carousel will automatically reconstruct if the component is re-rendered.
|
||||
this._destroyCarousel({ savePosition: true });
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the carousel if certain properties change.
|
||||
* @param changedProps The changed properties
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
const destroyProperties = [
|
||||
'direction',
|
||||
'carouselOptions',
|
||||
'carouselPlugins',
|
||||
] as const;
|
||||
if (destroyProperties.some((prop) => changedProps.has(prop))) {
|
||||
this._destroyCarousel({ savePosition: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a particular slide.
|
||||
* @param index Slide number.
|
||||
*/
|
||||
carouselScrollTo(index: number): void {
|
||||
this._carousel?.scrollTo(index, this._getTransitionEffect() === 'none');
|
||||
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.
|
||||
* @returns The slide index or undefined if the carousel is not loaded.
|
||||
* @returns A CarouselSelect object (index & element).
|
||||
*/
|
||||
carouselSelected(): number | undefined {
|
||||
return this._carousel?.selectedScrollSnap();
|
||||
public getCarouselSelected(): CarouselSelect | null {
|
||||
const index = this._carousel?.selectedScrollSnap();
|
||||
const element =
|
||||
index !== undefined ? this._carousel?.slideNodes()[index] ?? null : null;
|
||||
if (index !== undefined && element) {
|
||||
return {
|
||||
index: index,
|
||||
element: element,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the carousel.
|
||||
*/
|
||||
public carouselClickAllowed(): boolean {
|
||||
return this._carousel?.clickAllowed() ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the carousel.
|
||||
*/
|
||||
public carousel(): EmblaCarouselType | 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.
|
||||
*/
|
||||
protected _carouselReInitInPlaceInternal(): void {
|
||||
const selected = this.getCarouselSelected();
|
||||
|
||||
this._carouselReInit({
|
||||
...(selected && { startIndex: selected.index }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ReInit the carousel when it is safe to do so without disturbing the
|
||||
* appearance (i.e. cutting off a scroll in progress).
|
||||
*/
|
||||
public carouselReInitWhenSafe(): void {
|
||||
if (this._scrolling) {
|
||||
this._reInitOnSettle = true;
|
||||
} else {
|
||||
this._carouselReInitInPlace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the live carousel plugins.
|
||||
*/
|
||||
public getCarouselPlugins(): EmblaPluginsType | null {
|
||||
return this._carousel?.plugins() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,44 +202,21 @@ export class FrigateCardCarousel extends LitElement {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (!this._carousel) {
|
||||
this.updateComplete.then(() => {
|
||||
// Re-check for the carousel to prevent a double init.
|
||||
if (!this._carousel) {
|
||||
this._initCarousel();
|
||||
}
|
||||
});
|
||||
this._initCarousel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the transition effect to use.
|
||||
* @returns An TransitionEffect object.
|
||||
* Destroy the carousel.
|
||||
* @param options If `savePosition` is set the existing carousel position
|
||||
* will be saved so it can be restored if the carousel is recreated.
|
||||
*/
|
||||
protected _getTransitionEffect(): TransitionEffect | undefined {
|
||||
return 'slide';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla options to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
*/
|
||||
protected _getOptions(): EmblaOptionsType | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla plugins to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
*/
|
||||
protected _getPlugins(): EmblaPluginType[] | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected _destroyCarousel(): void {
|
||||
protected _destroyCarousel(options?: { savePosition: boolean }): void {
|
||||
this._savedStartIndex =
|
||||
(options?.savePosition ? this._carousel?.selectedScrollSnap() : null) ?? null;
|
||||
if (this._carousel) {
|
||||
this._carousel.destroy();
|
||||
}
|
||||
this._plugins = {};
|
||||
this._carousel = undefined;
|
||||
}
|
||||
|
||||
@@ -91,26 +228,88 @@ export class FrigateCardCarousel extends LitElement {
|
||||
'.embla__viewport',
|
||||
) as HTMLElement;
|
||||
|
||||
if (carouselNode) {
|
||||
const plugins = this._getPlugins() ?? [];
|
||||
this._plugins = plugins.reduce((acc, cur) => {
|
||||
acc[cur.name] = cur;
|
||||
return acc;
|
||||
}, {});
|
||||
const nodes: EmblaNodesType = {
|
||||
root: carouselNode,
|
||||
// As the slides are slotted, need to explicitly pull them out and pass
|
||||
// them to Embla.
|
||||
slides: this._refSlot.value?.assignedElements({ flatten: true }) as HTMLElement[],
|
||||
};
|
||||
|
||||
this._carousel = EmblaCarousel(carouselNode, this._getOptions(), plugins);
|
||||
if (carouselNode && nodes.slides) {
|
||||
this._carousel = EmblaCarousel(
|
||||
nodes,
|
||||
{
|
||||
axis: this.direction == 'horizontal' ? 'x' : 'y',
|
||||
speed: 20,
|
||||
...this.carouselOptions,
|
||||
...(this._savedStartIndex && { startIndex: this._savedStartIndex }),
|
||||
},
|
||||
this.carouselPlugins,
|
||||
);
|
||||
this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init'));
|
||||
this._carousel.on('select', () => {
|
||||
const selected = this.carouselSelected();
|
||||
if (selected !== undefined) {
|
||||
dispatchFrigateCardEvent<CarouselSelect>(this, 'carousel:select', {
|
||||
index: selected,
|
||||
});
|
||||
const selected = this.getCarouselSelected();
|
||||
if (selected) {
|
||||
dispatchFrigateCardEvent<CarouselSelect>(this, 'carousel:select', selected);
|
||||
}
|
||||
|
||||
// Make sure every select causes a refresh to allow for re-paint of the
|
||||
// next/previous controls.
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
this._carousel.on('scroll', () => {
|
||||
this._scrolling = true;
|
||||
});
|
||||
this._carousel.on('settle', () => {
|
||||
// Reinitialize the carousel if a request to reinitialize was made
|
||||
// during scrolling (instead the request is handled after the scrolling
|
||||
// has settled).
|
||||
this._scrolling = false;
|
||||
if (this._reInitOnSettle) {
|
||||
this._reInitOnSettle = false;
|
||||
this._carouselReInitInPlace();
|
||||
}
|
||||
});
|
||||
this._carousel.on('settle', () => {
|
||||
const selected = this.getCarouselSelected();
|
||||
if (selected) {
|
||||
dispatchFrigateCardEvent<CarouselSelect>(this, 'carousel:settle', selected);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the slotted children in the carousel change.
|
||||
*/
|
||||
protected _slotChanged(): void {
|
||||
// Cannot just re-init, because the slide elements themselves may have
|
||||
// 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
|
||||
// be abandoned and so the startIndex is reset to whatever the carousel was
|
||||
// originally configured with.
|
||||
this._destroyCarousel({ savePosition: false });
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
|
||||
const currentSlide = this._carousel?.selectedScrollSnap() ?? 0;
|
||||
const showPrevious = this.carouselOptions?.loop || currentSlide > 0;
|
||||
const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length;
|
||||
|
||||
return html` <div class="embla">
|
||||
${showPrevious ? html`<slot name="previous"></slot>` : ``}
|
||||
<div class="embla__viewport">
|
||||
<div class="embla__container">
|
||||
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot>
|
||||
</div>
|
||||
</div>
|
||||
${showNext ? html`<slot name="next"></slot>` : ``}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
@@ -118,3 +317,9 @@ export class FrigateCardCarousel extends LitElement {
|
||||
return unsafeCSS(carouselStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-carousel': FrigateCardCarousel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import 'side-drawer';
|
||||
import { SideDrawer } from 'side-drawer';
|
||||
import drawerInjectStyle from '../scss/drawer-inject.scss';
|
||||
import drawerStyle from '../scss/drawer.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
||||
import { isHoverableDevice } from '../utils/basic';
|
||||
|
||||
@customElement('frigate-card-drawer')
|
||||
export class FrigateCardDrawer extends LitElement {
|
||||
@property({ attribute: true, reflect: true })
|
||||
public location: 'left' | 'right' = 'left';
|
||||
|
||||
@property({ attribute: true, reflect: true, type: Boolean })
|
||||
public control = true;
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: true })
|
||||
public open = false;
|
||||
|
||||
// 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
|
||||
// used rather than display or hidden in order to ensure the contents continue
|
||||
// to have a measurable size.
|
||||
@property({ type: Boolean, reflect: true, attribute: true })
|
||||
public empty = true;
|
||||
|
||||
protected _refDrawer: Ref<HTMLElement & { open: boolean }> = createRef();
|
||||
protected _refSlot: Ref<HTMLSlotElement> = createRef();
|
||||
|
||||
protected _resizeObserver = new ResizeObserver(() => this._hideDrawerIfNecessary());
|
||||
|
||||
protected readonly _isHoverableDevice = isHoverableDevice();
|
||||
|
||||
/**
|
||||
* Called on the first update.
|
||||
* @param changedProps The changed properties.
|
||||
*/
|
||||
protected firstUpdated(changedProps: PropertyValues): void {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
// The `side-drawer` component (and the material drawer for that matter)
|
||||
// only do fixed drawers (i.e. a drawer for the whole viewport). Hackily
|
||||
// override the style to customize the drawer to be absolute within the div.
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = drawerInjectStyle;
|
||||
this._refDrawer.value?.shadowRoot?.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the slotted children in the drawer change.
|
||||
*/
|
||||
protected _slotChanged(): void {
|
||||
const elements = this._refSlot.value?.assignedElements({ flatten: true });
|
||||
|
||||
// Watch all slot children for size changes.
|
||||
this._resizeObserver.disconnect();
|
||||
for (const element of elements ?? []) {
|
||||
this._resizeObserver.observe(element);
|
||||
}
|
||||
this._hideDrawerIfNecessary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the drawer if there is nothing to show.
|
||||
* @returns
|
||||
*/
|
||||
protected _hideDrawerIfNecessary(): void {
|
||||
if (!this._refDrawer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = this._refSlot.value?.assignedElements({ flatten: true });
|
||||
this.empty =
|
||||
!elements ||
|
||||
!elements.length ||
|
||||
elements.every((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return !box.width || !box.height;
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<side-drawer
|
||||
${ref(this._refDrawer)}
|
||||
location="${this.location}"
|
||||
?open=${this.open}
|
||||
@mouseleave=${() => {
|
||||
if (this.open) {
|
||||
this.open = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
${this.control
|
||||
? html`
|
||||
<div
|
||||
class="control-surround"
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
this.open = !this.open;
|
||||
}}
|
||||
>
|
||||
<ha-icon
|
||||
class="control"
|
||||
icon="${this.open ? 'mdi:menu-open' : 'mdi:menu'}"
|
||||
@mouseenter=${() => {
|
||||
// Only open the drawer on mousenter when the device
|
||||
// supports hover (otherwise iOS may end up passing on
|
||||
// subsequent click events to a different element, see:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/801
|
||||
if (this._isHoverableDevice && !this.open) {
|
||||
this.open = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
</ha-icon>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot>
|
||||
</side-drawer>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(drawerStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-drawer': FrigateCardDrawer;
|
||||
'side-drawer': SideDrawer;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,39 @@
|
||||
import { LitElement, TemplateResult, html, CSSResultGroup, unsafeCSS } from 'lit';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
|
||||
import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers';
|
||||
import {
|
||||
ExtendedHomeAssistant,
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import elementsStyle from '../scss/elements.scss';
|
||||
import ptzStyle from '../scss/elements-ptz.scss';
|
||||
import {
|
||||
Actions,
|
||||
ActionsConfig,
|
||||
FrigateCardError,
|
||||
FrigateCardPTZConfig,
|
||||
FrigateConditional,
|
||||
MenuButton,
|
||||
MenuIcon,
|
||||
MenuStateIcon,
|
||||
PictureElements,
|
||||
MenuSubmenu,
|
||||
MenuSubmenuSelect,
|
||||
PictureElements,
|
||||
} from '../types.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { dispatchFrigateCardErrorEvent } from './message.js';
|
||||
import { actionHandler } from '../action-handler-directive.js';
|
||||
import {
|
||||
dispatchErrorMessageEvent,
|
||||
dispatchFrigateCardEvent,
|
||||
} from '../common.js';
|
||||
|
||||
import elementsStyle from '../scss/elements.scss';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js';
|
||||
frigateCardHandleActionConfig,
|
||||
frigateCardHasAction,
|
||||
getActionConfigGivenAction,
|
||||
} from '../utils/action.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
|
||||
/* A note on picture element rendering:
|
||||
*
|
||||
@@ -53,33 +68,29 @@ import { ConditionState, fetchStateAndEvaluateCondition } from '../card-conditio
|
||||
* upper layers to handle correctly.
|
||||
*/
|
||||
|
||||
interface HuiConditionalElement extends HTMLElement {
|
||||
hass: HomeAssistant;
|
||||
setConfig(config: unknown): void;
|
||||
}
|
||||
|
||||
// A small wrapper around a HA conditional element used to render a set of
|
||||
// picture elements.
|
||||
@customElement('frigate-card-elements-core')
|
||||
class FrigateCardElementsCore extends LitElement {
|
||||
export class FrigateCardElementsCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected elements: PictureElements;
|
||||
public elements: PictureElements;
|
||||
|
||||
/**
|
||||
* Need to ensure card re-renders when conditionState changes, hence having it
|
||||
* as a property even though it is not currently directly used by this class.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
protected conditionState?: ConditionState;
|
||||
public conditionState?: ConditionState;
|
||||
|
||||
protected _root: HTMLElement | null = null;
|
||||
protected _hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
protected _root: HuiConditionalElement | null = null;
|
||||
|
||||
/**
|
||||
* Set Home Assistant object.
|
||||
*/
|
||||
set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
|
||||
if (this._root) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this._root as any).hass = hass;
|
||||
}
|
||||
this._hass = hass;
|
||||
}
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
/**
|
||||
* Create a transparent render root.
|
||||
@@ -90,17 +101,16 @@ class FrigateCardElementsCore extends LitElement {
|
||||
|
||||
/**
|
||||
* Create the root node for our picture elements.
|
||||
* @returns
|
||||
* @returns The newly created root.
|
||||
*/
|
||||
protected _createRoot(): HTMLElement {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const elementConstructor = customElements.get('hui-conditional-element') as any;
|
||||
if (!elementConstructor || !this._hass) {
|
||||
protected _createRoot(): HuiConditionalElement {
|
||||
const elementConstructor = customElements.get('hui-conditional-element');
|
||||
if (!elementConstructor || !this.hass) {
|
||||
throw new Error(localize('error.could_not_render_elements'));
|
||||
}
|
||||
|
||||
const element = new elementConstructor();
|
||||
element.hass = this._hass;
|
||||
const element = new elementConstructor() as HuiConditionalElement;
|
||||
element.hass = this.hass;
|
||||
const config = {
|
||||
type: 'conditional',
|
||||
conditions: [],
|
||||
@@ -109,26 +119,43 @@ class FrigateCardElementsCore extends LitElement {
|
||||
try {
|
||||
element.setConfig(config);
|
||||
} catch (e) {
|
||||
console.error(e, (e as Error).stack);
|
||||
throw new Error(localize('error.invalid_elements_config'));
|
||||
console.error(e);
|
||||
throw new FrigateCardError(localize('error.invalid_elements_config'));
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the root as necessary prior to rendering.
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
try {
|
||||
// The root is only created once per elements configuration change, to
|
||||
// avoid the elements being continually re-created & destroyed (for some
|
||||
// elements, e.g. image, recreation causes a flicker).
|
||||
if (this.elements && (!this._root || changedProps.has('elements'))) {
|
||||
this._root = this._createRoot();
|
||||
}
|
||||
} catch (e) {
|
||||
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the elements.
|
||||
* @returns A rendered template or void.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
try {
|
||||
// Recreate the root on each render to ensure conditional ancestors
|
||||
// re-fire events as necessary.
|
||||
this._root = this._createRoot();
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(this, (e as Error).message);
|
||||
}
|
||||
return html`${this._root || ''}`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
if (this.hass && this._root) {
|
||||
// Always update hass. It is used as a trigger to re-evaluate conditions
|
||||
// down the chain, see the note on FrigateCardElementsConditional.
|
||||
this._root.hass = this.hass;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,16 +164,15 @@ class FrigateCardElementsCore extends LitElement {
|
||||
@customElement('frigate-card-elements')
|
||||
export class FrigateCardElements extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected elements: PictureElements;
|
||||
public conditionState?: ConditionState;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected conditionState?: ConditionState;
|
||||
public elements: PictureElements;
|
||||
|
||||
@query('frigate-card-elements-core')
|
||||
_core!: FrigateCardElementsCore;
|
||||
protected _boundMenuRemoveHandler = this._menuRemoveHandler.bind(this);
|
||||
|
||||
/**
|
||||
* Handle a picture element to be removed from the menu.
|
||||
@@ -178,13 +204,10 @@ export class FrigateCardElements extends LitElement {
|
||||
// Ensure listener is only attached 1 time by removing it first.
|
||||
path[0].removeEventListener(
|
||||
'frigate-card:menu-remove',
|
||||
this._menuRemoveHandler.bind(this),
|
||||
this._boundMenuRemoveHandler,
|
||||
);
|
||||
|
||||
path[0].addEventListener(
|
||||
'frigate-card:menu-remove',
|
||||
this._menuRemoveHandler.bind(this),
|
||||
);
|
||||
path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,20 +258,13 @@ export class FrigateCardElements extends LitElement {
|
||||
@customElement('frigate-card-conditional')
|
||||
export class FrigateCardElementsConditional extends LitElement {
|
||||
protected _config?: FrigateConditional;
|
||||
protected _hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
|
||||
@query('frigate-card-elements-core')
|
||||
_core?: FrigateCardElementsCore;
|
||||
|
||||
/**
|
||||
* Set the Home Assistant object.
|
||||
*/
|
||||
set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
|
||||
if (this._core) {
|
||||
this._core.hass = hass;
|
||||
}
|
||||
this._hass = hass;
|
||||
}
|
||||
// Every set of hass is treated as a reason to re-evaluate. Given that this
|
||||
// node may be buried down the DOM (as a descendent of non-Frigate card
|
||||
// elements), the hass object is used as the (only) trigger for condition
|
||||
// re-fetch even if hass itself has not changed.
|
||||
@property({ attribute: false, hasChanged: () => true })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
/**
|
||||
* Set the card configuration.
|
||||
@@ -260,7 +276,7 @@ export class FrigateCardElementsConditional extends LitElement {
|
||||
|
||||
/**
|
||||
* Create a root into which to render. This card is "transparent".
|
||||
* @returns
|
||||
* @returns
|
||||
*/
|
||||
createRenderRoot(): LitElement {
|
||||
return this;
|
||||
@@ -284,7 +300,7 @@ export class FrigateCardElementsConditional extends LitElement {
|
||||
protected render(): TemplateResult | void {
|
||||
if (fetchStateAndEvaluateCondition(this, this._config.conditions)) {
|
||||
return html` <frigate-card-elements-core
|
||||
.hass=${this._hass}
|
||||
.hass=${this.hass}
|
||||
.elements=${this._config.elements}
|
||||
>
|
||||
</frigate-card-elements-core>`;
|
||||
@@ -294,7 +310,7 @@ export class FrigateCardElementsConditional extends LitElement {
|
||||
|
||||
// A base class for rendering menu icons / menu state icons.
|
||||
export class FrigateCardElementsBaseMenuIcon<T> extends LitElement {
|
||||
@property({ attribute: false })
|
||||
@state()
|
||||
protected _config: T | null = null;
|
||||
|
||||
/**
|
||||
@@ -333,4 +349,128 @@ export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon
|
||||
export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon<MenuStateIcon> {}
|
||||
|
||||
@customElement('frigate-card-menu-submenu')
|
||||
export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon<MenuSubmenu> {}
|
||||
export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon<MenuSubmenu> {}
|
||||
|
||||
@customElement('frigate-card-menu-submenu-select')
|
||||
export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuIcon<MenuSubmenuSelect> {}
|
||||
|
||||
@customElement('frigate-card-ptz')
|
||||
export class FrigateCardPTZ extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@state()
|
||||
protected _config: FrigateCardPTZConfig | null = null;
|
||||
|
||||
/**
|
||||
* Set the card config.
|
||||
* @param config The configuration.
|
||||
*/
|
||||
public setConfig(config: FrigateCardPTZConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before each update.
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('_config')) {
|
||||
this.setAttribute('data-orientation', this._config?.orientation ?? 'vertical');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a PTZ action.
|
||||
* @param ev The actionHandler event.
|
||||
* @param config The action configuration.
|
||||
*/
|
||||
protected _actionHandler(
|
||||
ev: HASSDomEvent<{ action: string }>,
|
||||
config?: ActionsConfig,
|
||||
): void {
|
||||
// Nothing else has the configuration for this action, so don't let it
|
||||
// propagate further.
|
||||
ev.stopPropagation();
|
||||
|
||||
const interaction: string = ev.detail.action;
|
||||
const action = getActionConfigGivenAction(interaction, config);
|
||||
if (config && action && this.hass) {
|
||||
frigateCardHandleActionConfig(this, this.hass, config, interaction, action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the elements.
|
||||
* @returns A rendered template or void.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const renderIcon = (
|
||||
name: string,
|
||||
icon: string,
|
||||
actions?: Actions,
|
||||
): TemplateResult => {
|
||||
const hasHold = frigateCardHasAction(actions?.hold_action);
|
||||
const hasDoubleClick = frigateCardHasAction(actions?.double_tap_action);
|
||||
const classes = {
|
||||
[name]: true,
|
||||
disabled: !actions,
|
||||
};
|
||||
|
||||
return html`<ha-icon
|
||||
class=${classMap(classes)}
|
||||
icon=${icon}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasHold,
|
||||
hasDoubleClick: hasDoubleClick,
|
||||
})}
|
||||
.title=${localize(`elements.ptz.${name}`)}
|
||||
@action=${(ev) => this._actionHandler(ev, actions)}
|
||||
></ha-icon>`;
|
||||
};
|
||||
|
||||
return html` <div class="ptz">
|
||||
<div class="ptz-move">
|
||||
${renderIcon('right', 'mdi:arrow-right', this._config.actions_right)}
|
||||
${renderIcon('left', 'mdi:arrow-left', this._config.actions_left)}
|
||||
${renderIcon('up', 'mdi:arrow-up', this._config.actions_up)}
|
||||
${renderIcon('down', 'mdi:arrow-down', this._config.actions_down)}
|
||||
</div>
|
||||
${this._config.actions_zoom_in || this._config.actions_zoom_out
|
||||
? html` <div class="ptz-zoom">
|
||||
${renderIcon('zoom_in', 'mdi:plus', this._config.actions_zoom_in)}
|
||||
${renderIcon('zoom_out', 'mdi:minus', this._config.actions_zoom_out)}
|
||||
</div>`
|
||||
: html``}
|
||||
${this._config.actions_home
|
||||
? html`
|
||||
<div class="ptz-home">
|
||||
${renderIcon('home', 'mdi:home', this._config.actions_home)}
|
||||
</div>
|
||||
`
|
||||
: html``}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(ptzStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-conditional': FrigateCardElementsConditional;
|
||||
'frigate-card-elements': FrigateCardElements;
|
||||
'frigate-card-menu-submenu-select': FrigateCardElementsMenuSubmenuSelect;
|
||||
'frigate-card-menu-submenu': FrigateCardElementsMenuSubmenu;
|
||||
'frigate-card-menu-state-icon': FrigateCardElementsMenuStateIcon;
|
||||
'frigate-card-menu-icon': FrigateCardElementsMenuIcon;
|
||||
'frigate-card-elements-core': FrigateCardElementsCore;
|
||||
'frigate-card-ptz': FrigateCardPTZ;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,65 @@
|
||||
import { EmblaCarouselType, EmblaPluginType } from 'embla-carousel';
|
||||
import { FrigateCardMediaPlayer } from '../../types.js';
|
||||
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
|
||||
import { CreateOptionsType } from 'embla-carousel/components/Options.js';
|
||||
import { CreatePluginType } from 'embla-carousel/components/Plugins.js';
|
||||
import {
|
||||
AutoMuteCondition,
|
||||
AutoPauseCondition,
|
||||
AutoPlayCondition,
|
||||
AutoUnmuteCondition,
|
||||
FrigateCardMediaPlayer,
|
||||
} from '../../types.js';
|
||||
|
||||
export type AutoMediaPluginOptionsType = {
|
||||
playerSelector: string;
|
||||
autoPlayWhenVisible?: boolean;
|
||||
autoUnmuteWhenVisible?: boolean;
|
||||
type OptionsType = CreateOptionsType<{
|
||||
playerSelector?: string;
|
||||
|
||||
// Note: Neither play nor unmute will activate on selection. The caller is
|
||||
// expected to call the `play()` or `unmute()` methods manually when the media
|
||||
// is actually loaded (and not just when the slide is visible -- the browser
|
||||
// cannot play media that is not actually loaded yet, e.g. lazy loading).
|
||||
autoPlayCondition?: AutoPlayCondition;
|
||||
autoUnmuteCondition?: AutoUnmuteCondition;
|
||||
autoPauseCondition?: AutoPauseCondition;
|
||||
autoMuteCondition?: AutoMuteCondition;
|
||||
}>;
|
||||
|
||||
const defaultOptions: OptionsType = {
|
||||
active: true,
|
||||
breakpoints: {},
|
||||
};
|
||||
|
||||
export const defaultOptions: Partial<AutoMediaPluginOptionsType> = {
|
||||
autoPlayWhenVisible: true,
|
||||
autoUnmuteWhenVisible: true,
|
||||
};
|
||||
export type AutoMediaOptionsType = Partial<OptionsType>
|
||||
|
||||
export type AutoMediaPluginType = EmblaPluginType<AutoMediaPluginOptionsType> & {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
mute: () => void;
|
||||
unmute: () => void;
|
||||
export type AutoMediaType = CreatePluginType<
|
||||
{
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
mute: () => void;
|
||||
unmute: () => void;
|
||||
},
|
||||
AutoMediaOptionsType
|
||||
>;
|
||||
|
||||
declare module 'embla-carousel/components/Plugins' {
|
||||
interface EmblaPluginsType {
|
||||
autoMedia?: AutoMediaType
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An Embla plugin to take automated actions on media (e.g. pause, unmute, etc).
|
||||
* @param userOptions
|
||||
* @returns
|
||||
* @param userOptions
|
||||
* @returns
|
||||
*/
|
||||
export function AutoMediaPlugin(
|
||||
userOptions?: AutoMediaPluginOptionsType,
|
||||
): AutoMediaPluginType {
|
||||
const options = Object.assign({}, defaultOptions, userOptions);
|
||||
userOptions?: AutoMediaOptionsType,
|
||||
): AutoMediaType {
|
||||
const optionsHandler = EmblaCarousel.optionsHandler();
|
||||
const optionsBase = optionsHandler.merge(
|
||||
defaultOptions,
|
||||
AutoMediaPlugin.globalOptions,
|
||||
);
|
||||
|
||||
let options: AutoMediaType['options'];
|
||||
let carousel: EmblaCarouselType;
|
||||
let slides: HTMLElement[];
|
||||
|
||||
@@ -37,15 +68,26 @@ export function AutoMediaPlugin(
|
||||
*/
|
||||
function init(embla: EmblaCarouselType): void {
|
||||
carousel = embla;
|
||||
options = optionsHandler.atMedia(self.options);
|
||||
slides = carousel.slideNodes();
|
||||
|
||||
// Frigate card media autoplays when the media loads not necessarily when the
|
||||
// slide is selected, so only pause (and not play/unmute) based on carousel
|
||||
// events.
|
||||
carousel.on('destroy', pause);
|
||||
carousel.on('select', pausePrevious);
|
||||
if (
|
||||
options.autoPauseCondition &&
|
||||
['all', 'unselected'].includes(options.autoPauseCondition)
|
||||
) {
|
||||
carousel.on('select', pausePrevious);
|
||||
}
|
||||
carousel.on('destroy', mute);
|
||||
carousel.on('select', mutePrevious);
|
||||
if (
|
||||
options.autoMuteCondition &&
|
||||
['all', 'unselected'].includes(options.autoMuteCondition)
|
||||
) {
|
||||
carousel.on('select', mutePrevious);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
}
|
||||
@@ -55,9 +97,19 @@ export function AutoMediaPlugin(
|
||||
*/
|
||||
function destroy(): void {
|
||||
carousel.off('destroy', pause);
|
||||
carousel.off('select', pausePrevious);
|
||||
if (
|
||||
options.autoPauseCondition &&
|
||||
['all', 'unselected'].includes(options.autoPauseCondition)
|
||||
) {
|
||||
carousel.off('select', pausePrevious);
|
||||
}
|
||||
carousel.off('destroy', mute);
|
||||
carousel.off('select', mutePrevious);
|
||||
if (
|
||||
options.autoMuteCondition &&
|
||||
['all', 'unselected'].includes(options.autoMuteCondition)
|
||||
) {
|
||||
carousel.off('select', mutePrevious);
|
||||
}
|
||||
|
||||
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||
}
|
||||
@@ -65,15 +117,31 @@ export function AutoMediaPlugin(
|
||||
/**
|
||||
* Handle document visibility changes.
|
||||
*/
|
||||
function visibilityHandler(): void {
|
||||
if (document.visibilityState == 'hidden') {
|
||||
pause();
|
||||
mute();
|
||||
} else if (document.visibilityState == 'visible') {
|
||||
if (options.autoPlayWhenVisible) {
|
||||
function visibilityHandler(): void {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
if (
|
||||
options.autoPauseCondition &&
|
||||
['all', 'hidden'].includes(options.autoPauseCondition)
|
||||
) {
|
||||
pauseAll();
|
||||
}
|
||||
if (
|
||||
options.autoMuteCondition &&
|
||||
['all', 'hidden'].includes(options.autoMuteCondition)
|
||||
) {
|
||||
muteAll();
|
||||
}
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
if (
|
||||
options.autoPlayCondition &&
|
||||
['all', 'visible'].includes(options.autoPlayCondition)
|
||||
) {
|
||||
play();
|
||||
}
|
||||
if (options.autoUnmuteWhenVisible) {
|
||||
}
|
||||
if (
|
||||
options.autoUnmuteCondition &&
|
||||
['all', 'visible'].includes(options.autoUnmuteCondition)
|
||||
) {
|
||||
unmute();
|
||||
}
|
||||
}
|
||||
@@ -85,7 +153,9 @@ export function AutoMediaPlugin(
|
||||
* @returns A FrigateCardMediaPlayer object or `null`.
|
||||
*/
|
||||
function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null {
|
||||
return slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null;
|
||||
return options.playerSelector
|
||||
? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,6 +179,15 @@ export function AutoMediaPlugin(
|
||||
getPlayer(slides[carousel.previousScrollSnap()])?.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause all slides.
|
||||
*/
|
||||
function pauseAll(): void {
|
||||
for (const slide of slides) {
|
||||
getPlayer(slide)?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the current slide.
|
||||
*/
|
||||
@@ -126,13 +205,22 @@ export function AutoMediaPlugin(
|
||||
/**
|
||||
* Mute the previous slide.
|
||||
*/
|
||||
function mutePrevious(): void {
|
||||
function mutePrevious(): void {
|
||||
getPlayer(slides[carousel.previousScrollSnap()])?.mute();
|
||||
}
|
||||
|
||||
const self: AutoMediaPluginType = {
|
||||
name: 'AutoMediaPlugin',
|
||||
options,
|
||||
/**
|
||||
* Mute all slides.
|
||||
*/
|
||||
function muteAll(): void {
|
||||
for (const slide of slides) {
|
||||
getPlayer(slide)?.mute();
|
||||
}
|
||||
}
|
||||
|
||||
const self: AutoMediaType = {
|
||||
name: 'autoMedia',
|
||||
options: optionsHandler.merge(optionsBase, userOptions),
|
||||
init,
|
||||
destroy,
|
||||
play,
|
||||
@@ -142,3 +230,5 @@ export function AutoMediaPlugin(
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
AutoMediaPlugin.globalOptions = <AutoMediaOptionsType | undefined>undefined;
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
import { EmblaCarouselType, EmblaEventType, EmblaPluginType } from 'embla-carousel';
|
||||
import { CreateOptionsType } from 'embla-carousel/components/Options';
|
||||
import { CreatePluginType } from 'embla-carousel/components/Plugins';
|
||||
import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
|
||||
import { LazyUnloadCondition } from '../../types';
|
||||
|
||||
export type LazyloadOptionsType = {
|
||||
export type OptionsType = CreateOptionsType<{
|
||||
// Number of slides to lazyload left/right of selected (0 == only selected
|
||||
// slide).
|
||||
lazyloadCount?: number;
|
||||
lazyLoadCount?: number;
|
||||
lazyUnloadCondition?: LazyUnloadCondition;
|
||||
|
||||
lazyloadCallback?: (index: number, slide: HTMLElement) => void;
|
||||
lazyunloadCallback?: (index: number, slide: HTMLElement) => void;
|
||||
lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
|
||||
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
|
||||
}>;
|
||||
|
||||
export const defaultOptions: OptionsType = {
|
||||
active: true,
|
||||
breakpoints: {},
|
||||
lazyLoadCount: 0,
|
||||
};
|
||||
|
||||
export const defaultOptions: Partial<LazyloadOptionsType> = {
|
||||
lazyloadCount: 0,
|
||||
};
|
||||
export type LazyloadOptionsType = Partial<OptionsType>;
|
||||
|
||||
export type LazyloadType = EmblaPluginType<LazyloadOptionsType> & {
|
||||
hasLazyloaded: (index: number) => boolean;
|
||||
};
|
||||
export type LazyloadType = CreatePluginType<
|
||||
{
|
||||
hasLazyloaded(index: number): boolean;
|
||||
},
|
||||
LazyloadOptionsType
|
||||
>;
|
||||
|
||||
declare module 'embla-carousel/components/Plugins' {
|
||||
interface EmblaPluginsType {
|
||||
lazyload?: LazyloadType;
|
||||
}
|
||||
}
|
||||
|
||||
export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
const options = Object.assign({}, defaultOptions, userOptions);
|
||||
const optionsHandler = EmblaCarousel.optionsHandler();
|
||||
const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions);
|
||||
let options: LazyloadType['options'];
|
||||
|
||||
let carousel: EmblaCarouselType;
|
||||
let slides: HTMLElement[];
|
||||
const isSlideLazyloaded: Record<number, boolean> = {};
|
||||
const lazyLoadedSlides: Set<number> = new Set();
|
||||
|
||||
const loadEvents: EmblaEventType[] = ['init', 'select', 'resize'];
|
||||
const unloadEvents: EmblaEventType[] = ['select'];
|
||||
@@ -32,13 +51,18 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
*/
|
||||
function init(embla: EmblaCarouselType): void {
|
||||
carousel = embla;
|
||||
options = optionsHandler.atMedia(self.options);
|
||||
slides = carousel.slideNodes();
|
||||
|
||||
if (options.lazyloadCallback) {
|
||||
loadEvents.forEach((evt) => carousel.on(evt, lazyloadHandler));
|
||||
if (options.lazyLoadCallback) {
|
||||
loadEvents.forEach((evt) => carousel.on(evt, lazyLoadHandler));
|
||||
}
|
||||
if (options.lazyunloadCallback) {
|
||||
unloadEvents.forEach((evt) => carousel.on(evt, lazyunloadHandler));
|
||||
if (
|
||||
options.lazyUnloadCallback &&
|
||||
options.lazyUnloadCondition &&
|
||||
['all', 'unselected'].includes(options.lazyUnloadCondition)
|
||||
) {
|
||||
unloadEvents.forEach((evt) => carousel.on(evt, lazyUnloadPreviousHandler));
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
}
|
||||
@@ -47,11 +71,11 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
* Destroy the plugin.
|
||||
*/
|
||||
function destroy(): void {
|
||||
if (options.lazyloadCallback) {
|
||||
loadEvents.forEach((evt) => carousel.off(evt, lazyloadHandler));
|
||||
if (options.lazyLoadCallback) {
|
||||
loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler));
|
||||
}
|
||||
if (options.lazyunloadCallback) {
|
||||
unloadEvents.forEach((evt) => carousel.off(evt, lazyunloadHandler));
|
||||
if (options.lazyUnloadCallback) {
|
||||
unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler));
|
||||
}
|
||||
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||
}
|
||||
@@ -60,10 +84,15 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
* Handle document visibility changes.
|
||||
*/
|
||||
function visibilityHandler(): void {
|
||||
if (document.visibilityState == 'hidden' && lazyunloadHandler) {
|
||||
lazyunloadHandler();
|
||||
} else if (document.visibilityState == 'visible' && lazyloadHandler) {
|
||||
lazyloadHandler();
|
||||
if (
|
||||
document.visibilityState === 'hidden' &&
|
||||
options.lazyUnloadCallback &&
|
||||
options.lazyUnloadCondition &&
|
||||
['all', 'hidden'].includes(options.lazyUnloadCondition)
|
||||
) {
|
||||
lazyUnloadAllHandler();
|
||||
} else if (document.visibilityState === 'visible' && options.lazyLoadCallback) {
|
||||
lazyLoadHandler();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,14 +102,14 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
* @returns `true` if the slide has been lazily loaded.
|
||||
*/
|
||||
function hasLazyloaded(index: number): boolean {
|
||||
return !!isSlideLazyloaded[index];
|
||||
return lazyLoadedSlides.has(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily load media in the carousel.
|
||||
*/
|
||||
function lazyloadHandler(): void {
|
||||
const lazyLoadCount = options.lazyloadCount ?? 0;
|
||||
function lazyLoadHandler(): void {
|
||||
const lazyLoadCount = options.lazyLoadCount ?? 0;
|
||||
const currentIndex = carousel.selectedScrollSnap();
|
||||
const slidesToLoad = new Set<number>();
|
||||
|
||||
@@ -94,39 +123,45 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
}
|
||||
|
||||
slidesToLoad.forEach((index) => {
|
||||
// Only lazy load slides that are not already loaded.
|
||||
if (isSlideLazyloaded[index]) {
|
||||
return;
|
||||
}
|
||||
if (options.lazyloadCallback) {
|
||||
isSlideLazyloaded[index] = true;
|
||||
options.lazyloadCallback(index, slides[index]);
|
||||
if (!hasLazyloaded(index) && options.lazyLoadCallback) {
|
||||
lazyLoadedSlides.add(index);
|
||||
options.lazyLoadCallback(index, slides[index]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily unload media in the carousel.
|
||||
* Lazily unload all media in the carousel.
|
||||
*/
|
||||
function lazyunloadHandler(): void {
|
||||
function lazyUnloadAllHandler(): void {
|
||||
lazyLoadedSlides.forEach((index) => {
|
||||
if (options.lazyUnloadCallback) {
|
||||
options.lazyUnloadCallback(index, slides[index]);
|
||||
lazyLoadedSlides.delete(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily unload the previously selected media in the carousel.
|
||||
*/
|
||||
function lazyUnloadPreviousHandler(): void {
|
||||
const index = carousel.previousScrollSnap();
|
||||
|
||||
// Only lazy unload slides that are lazy loaded.
|
||||
if (!isSlideLazyloaded[index]) {
|
||||
return;
|
||||
}
|
||||
if (options.lazyunloadCallback) {
|
||||
options.lazyunloadCallback(index, slides[index]);
|
||||
isSlideLazyloaded[index] = false;
|
||||
if (hasLazyloaded(index) && options.lazyUnloadCallback) {
|
||||
options.lazyUnloadCallback(index, slides[index]);
|
||||
lazyLoadedSlides.delete(index);
|
||||
}
|
||||
}
|
||||
|
||||
const self: LazyloadType = {
|
||||
name: 'Lazyload',
|
||||
options,
|
||||
name: 'lazyload',
|
||||
options: optionsHandler.merge(optionsBase, userOptions),
|
||||
init,
|
||||
destroy,
|
||||
hasLazyloaded,
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
Lazyload.globalOptions = <LazyloadOptionsType | undefined>undefined;
|
||||
|
||||
@@ -1,59 +1,78 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
css,
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import galleryStyle from '../scss/gallery.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
GalleryConfig,
|
||||
frigateCardConfigDefaults,
|
||||
GalleryConfig,
|
||||
THUMBNAIL_WIDTH_MAX,
|
||||
} from '../types.js';
|
||||
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import {
|
||||
fetchChildMediaAndDispatchViewChange,
|
||||
fetchLatestMediaAndDispatchViewChange,
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError,
|
||||
} from '../utils/ha/browse-media';
|
||||
import { View } from '../view.js';
|
||||
import { renderProgressIndicator } from './message.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../common.js';
|
||||
|
||||
import galleryStyle from '../scss/gallery.scss';
|
||||
|
||||
const MAX_THUMBNAIL_WIDTH = 175;
|
||||
import './thumbnail.js';
|
||||
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
|
||||
|
||||
@customElement('frigate-card-gallery')
|
||||
export class FrigateCardGallery extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected cameraConfig?: CameraConfig;
|
||||
public galleryConfig?: GalleryConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected galleryConfig?: GalleryConfig;
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view || !this.cameraConfig) {
|
||||
const mediaType = this.view?.getMediaType();
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.cameras ||
|
||||
!this.view.isGalleryView() ||
|
||||
!mediaType
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.view.target) {
|
||||
const browseMediaQueryParameters =
|
||||
BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError(
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError(
|
||||
this,
|
||||
this.view,
|
||||
this.cameraConfig,
|
||||
this.hass,
|
||||
this.cameras,
|
||||
this.view.camera,
|
||||
mediaType,
|
||||
);
|
||||
|
||||
if (!browseMediaQueryParameters) {
|
||||
return;
|
||||
}
|
||||
|
||||
BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange(
|
||||
fetchLatestMediaAndDispatchViewChange(
|
||||
this,
|
||||
this.hass,
|
||||
this.view,
|
||||
@@ -67,6 +86,7 @@ export class FrigateCardGallery extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.galleryConfig=${this.galleryConfig}
|
||||
.cameras=${this.cameras}
|
||||
>
|
||||
</frigate-card-gallery-core>
|
||||
`;
|
||||
@@ -76,26 +96,32 @@ export class FrigateCardGallery extends LitElement {
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(galleryStyle);
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-gallery-core')
|
||||
export class FrigateCardGalleryCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected galleryConfig?: GalleryConfig;
|
||||
public galleryConfig?: GalleryConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
protected _resizeObserver: ResizeObserver;
|
||||
|
||||
@state()
|
||||
protected _columns = frigateCardConfigDefaults.event_gallery.min_columns;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
|
||||
@@ -117,17 +143,64 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set gallery columns.
|
||||
*/
|
||||
protected _setColumnCount(): void {
|
||||
const thumbnailSize =
|
||||
this.galleryConfig?.controls.thumbnails.size ??
|
||||
frigateCardConfigDefaults.event_gallery.controls.thumbnails.size;
|
||||
const columns = this.galleryConfig?.controls.thumbnails.show_details
|
||||
? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN))
|
||||
: Math.max(
|
||||
1,
|
||||
Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX),
|
||||
Math.ceil(this.clientWidth / thumbnailSize),
|
||||
);
|
||||
|
||||
this.style.setProperty('--frigate-card-gallery-columns', String(columns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle gallery resize.
|
||||
*/
|
||||
protected _resizeHandler(): void {
|
||||
this._columns = Math.max(
|
||||
this.galleryConfig?.min_columns ??
|
||||
frigateCardConfigDefaults.event_gallery.min_columns,
|
||||
Math.ceil(this.clientWidth / MAX_THUMBNAIL_WIDTH),
|
||||
this._setColumnCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the back arrow should be displayed.
|
||||
* @returns `true` if the back arrow should be displayed, `false` otherwise.
|
||||
*/
|
||||
protected _showBackArrow(): boolean {
|
||||
return (
|
||||
!!this.view?.previous &&
|
||||
!!this.view.previous.target &&
|
||||
this.view.previous.view === this.view.view
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an update will occur.
|
||||
* @param changedProps The changed properties
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('galleryConfig')) {
|
||||
if (this.galleryConfig?.controls.thumbnails.show_details) {
|
||||
this.setAttribute('details', '');
|
||||
} else {
|
||||
this.removeAttribute('details');
|
||||
}
|
||||
this._setColumnCount();
|
||||
if (this.galleryConfig?.controls.thumbnails.size) {
|
||||
this.style.setProperty(
|
||||
'--frigate-card-thumbnail-size',
|
||||
`${this.galleryConfig.controls.thumbnails.size}px`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
@@ -138,93 +211,78 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
!(this.view.is('clips') || this.view.is('snapshots'))
|
||||
!(this.view.is('clips') || this.view.is('snapshots')) ||
|
||||
!this.cameras
|
||||
) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const itemStyle = {
|
||||
// Controls the number of columns in the gallery (allows for 5px gutter).
|
||||
width: `calc(${100 / this._columns}% - 5.25px)`,
|
||||
};
|
||||
|
||||
const folderStyle = {
|
||||
// Values derived from experimentation on typical Lovelace card sizes.
|
||||
'font-size': `${Math.min(
|
||||
1.1,
|
||||
(0.6 * (this.clientWidth / this._columns)) / 50.0,
|
||||
)}em`,
|
||||
};
|
||||
|
||||
return html` <ul class="mdc-image-list frigate-card-gallery">
|
||||
${this.view && this.view.previous
|
||||
? html`<li class="mdc-image-list__item" style="${styleMap(itemStyle)}">
|
||||
<div class="mdc-image-list__image-aspect-container">
|
||||
<div class="mdc-image-list__image">
|
||||
<ha-card
|
||||
@click=${(ev) => {
|
||||
if (this.view && this.view.previous) {
|
||||
this.view.previous.dispatchChangeEvent(this);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
outlined=""
|
||||
class="frigate-card-gallery-folder"
|
||||
>
|
||||
<ha-icon .icon=${'mdi:arrow-left'}></ha-icon>
|
||||
</ha-card>
|
||||
</div>
|
||||
</div>
|
||||
</li>`
|
||||
const cameraConfig = this.cameras.get(this.view.camera);
|
||||
return html`
|
||||
${this._showBackArrow()
|
||||
? html` <ha-card
|
||||
@click=${(ev) => {
|
||||
if (this.view && this.view.previous) {
|
||||
this.view.previous.dispatchChangeEvent(this);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
outlined=""
|
||||
>
|
||||
<ha-icon .icon=${'mdi:arrow-left'}></ha-icon>
|
||||
</ha-card>`
|
||||
: ''}
|
||||
${this.view.target.children.map(
|
||||
(child, index) =>
|
||||
html` <li class="mdc-image-list__item" style="${styleMap(itemStyle)}">
|
||||
<div class="mdc-image-list__image-aspect-container">
|
||||
${child.can_expand
|
||||
? html`<div class="mdc-image-list__image">
|
||||
<ha-card
|
||||
@click=${(ev) => {
|
||||
if (this.hass && this.view) {
|
||||
BrowseMediaUtil.fetchChildMediaAndDispatchViewChange(
|
||||
this,
|
||||
this.hass,
|
||||
this.view,
|
||||
child,
|
||||
);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
outlined=""
|
||||
class="frigate-card-gallery-folder"
|
||||
>
|
||||
<div style="${styleMap(folderStyle)}">${child.title}</div>
|
||||
</ha-card>
|
||||
</div>`
|
||||
: child.thumbnail
|
||||
? html`<img
|
||||
aria-label="${child.title}"
|
||||
class="mdc-image-list__image"
|
||||
src="${child.thumbnail}"
|
||||
title="${child.title}"
|
||||
html`
|
||||
${child.can_expand
|
||||
? html`
|
||||
<ha-card
|
||||
@click=${(ev) => {
|
||||
if (this.view) {
|
||||
this.view
|
||||
.evolve({
|
||||
view: this.view.is('clips') ? 'clip' : 'snapshot',
|
||||
childIndex: index,
|
||||
previous: this.view,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
if (this.hass && this.view) {
|
||||
fetchChildMediaAndDispatchViewChange(
|
||||
this,
|
||||
this.hass,
|
||||
this.view,
|
||||
child,
|
||||
);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
/>`
|
||||
: ``}
|
||||
</div>
|
||||
</li>`,
|
||||
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>`
|
||||
: ``}
|
||||
`,
|
||||
)}
|
||||
</ul>`;
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,3 +292,10 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
return unsafeCSS(galleryStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-gallery-core': FrigateCardGalleryCore;
|
||||
'frigate-card-gallery': FrigateCardGallery;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
html,
|
||||
unsafeCSS,
|
||||
unsafeCSS
|
||||
} from 'lit';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { CachedValueController } from '../cached-value-controller.js';
|
||||
import { CameraConfig, ImageViewConfig } from '../types.js';
|
||||
import { View } from '../view.js';
|
||||
import {
|
||||
dispatchErrorMessageEvent,
|
||||
dispatchMediaShowEvent,
|
||||
shouldUpdateBasedOnHass,
|
||||
} from '../common.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
|
||||
import defaultImage from '../images/frigate-bird-in-sky.jpg';
|
||||
|
||||
import { localize } from '../localize/localize.js';
|
||||
import imageStyle from '../scss/image.scss';
|
||||
import { CameraConfig, ImageViewConfig } from '../types.js';
|
||||
import { isHassDifferent } from '../utils/ha';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
||||
import { View } from '../view.js';
|
||||
import { dispatchErrorMessageEvent } from './message.js';
|
||||
import { contentsChanged } from '../utils/basic.js';
|
||||
|
||||
// See: https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py#L101
|
||||
// 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;
|
||||
|
||||
@customElement('frigate-card-image')
|
||||
@@ -32,40 +32,31 @@ export class FrigateCardImage extends LitElement {
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected cameraConfig?: CameraConfig;
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
@state()
|
||||
protected _imageConfig?: ImageViewConfig;
|
||||
// Using contentsChanged to ensure overridden configs (e.g. when the
|
||||
// 'show_image_during_load' option is true for live views, an overridden
|
||||
// config may be used here).
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public imageConfig?: ImageViewConfig;
|
||||
|
||||
@query('img')
|
||||
protected _image?: HTMLImageElement;
|
||||
protected _refImage: Ref<HTMLImageElement> = createRef();
|
||||
|
||||
protected _cachedValueController?: CachedValueController<string>;
|
||||
protected _boundVisibilityHandler = this._visibilityHandler.bind(this);
|
||||
/**
|
||||
* Set the image configuration.
|
||||
*/
|
||||
set imageConfig(imageConfig: ImageViewConfig) {
|
||||
this._imageConfig = imageConfig;
|
||||
if (this._cachedValueController) {
|
||||
this._cachedValueController.removeController();
|
||||
}
|
||||
this._cachedValueController = new CachedValueController(
|
||||
this,
|
||||
this._imageConfig.refresh_seconds,
|
||||
this._getImageSource.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the camera entity for the current camera configuration.
|
||||
* @returns The entity or undefined if no camera entity is available.
|
||||
*/
|
||||
protected _getCameraEntity(): string | undefined {
|
||||
return this.cameraConfig?.camera_entity || this.cameraConfig?.webrtc_card?.entity;
|
||||
protected _getCameraEntity(): string | null {
|
||||
return (
|
||||
(this.cameraConfig?.camera_entity || this.cameraConfig?.webrtc_card?.entity) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,30 +69,14 @@ export class FrigateCardImage extends LitElement {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If camera mode is enabled, reject all updates if hass is older than
|
||||
// HASS_REJECTION_CUTOFF_MS or if HASS is not currently connected. By using
|
||||
// an older hass (even if it is not the property being updated), we run the
|
||||
// risk that the JS has an old access token for the camera, and that results
|
||||
// in a notification on the HA UI about a failed login. See
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/398 .
|
||||
const cameraEntity = this._getCameraEntity();
|
||||
const state = cameraEntity ? this.hass.states[cameraEntity] : undefined;
|
||||
if (
|
||||
this._imageConfig?.mode === 'camera' &&
|
||||
(!this.hass.connected ||
|
||||
!state ||
|
||||
Date.now() - Date.parse(state.last_updated) >= HASS_REJECTION_CUTOFF_MS)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
changedProps.has('hass') &&
|
||||
changedProps.size == 1 &&
|
||||
this._imageConfig?.mode === 'camera' &&
|
||||
this.imageConfig?.mode === 'camera' &&
|
||||
cameraEntity
|
||||
) {
|
||||
if (shouldUpdateBasedOnHass(this.hass, changedProps.get('hass'), [cameraEntity])) {
|
||||
if (isHassDifferent(this.hass, changedProps.get('hass'), [cameraEntity])) {
|
||||
// If the state of the camera entity has changed, remove the cached
|
||||
// value (will be re-calculated in willUpdate). This is important to
|
||||
// ensure a changed access token is immediately used.
|
||||
@@ -117,25 +92,73 @@ export class FrigateCardImage extends LitElement {
|
||||
* Ensure there is a cached value before an update.
|
||||
* @param _changedProps The changed properties
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected willUpdate(_changedProps: PropertyValues): void {
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('imageConfig')) {
|
||||
if (this._cachedValueController) {
|
||||
this._cachedValueController.removeController();
|
||||
}
|
||||
if (this.imageConfig) {
|
||||
this._cachedValueController = new CachedValueController(
|
||||
this,
|
||||
this.imageConfig.refresh_seconds,
|
||||
this._getImageSource.bind(this),
|
||||
);
|
||||
}
|
||||
updateElementStyleFromMediaLayoutConfig(this, this.imageConfig?.layout);
|
||||
}
|
||||
|
||||
// If the camera or view changed, immediately discard the old value (view to
|
||||
// allow pressing of the image button to fetch a fresh image). Likewise, if
|
||||
// the state is not acceptable, discard the old value (to allow a stock or
|
||||
// backup image to be displayed).
|
||||
if (
|
||||
changedProps.has('cameraConfig') ||
|
||||
changedProps.has('view') ||
|
||||
(this.imageConfig?.mode === 'camera' &&
|
||||
!this._getAcceptableState(this._getCameraEntity()))
|
||||
) {
|
||||
this._cachedValueController?.clearValue();
|
||||
}
|
||||
|
||||
if (!this._cachedValueController?.value) {
|
||||
this._cachedValueController?.updateValue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a given entity is acceptable as the basis for an image render
|
||||
* (detects old or disconnected states). Using an old state is problematic as
|
||||
* it runs the risk that the JS has an old access token for the camera, and
|
||||
* that results in a notification on the HA UI about a failed login. See:
|
||||
* https://github.com/dermotduffy/frigate-hass-card/issues/398 .
|
||||
* @param entity The entity.
|
||||
* @returns The state or null if not acceptable.
|
||||
*/
|
||||
protected _getAcceptableState(entity: string | null): HassEntity | null {
|
||||
const state = (entity ? this.hass?.states[entity] : null) ?? null;
|
||||
|
||||
return !!this.hass &&
|
||||
this.hass.connected &&
|
||||
!!state &&
|
||||
Date.now() - Date.parse(state.last_updated) < HASS_REJECTION_CUTOFF_MS
|
||||
? state
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener('visibilitychange', this._boundVisibilityHandler);
|
||||
this._cachedValueController?.startTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
this._cachedValueController?.stopTimer();
|
||||
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -144,7 +167,7 @@ export class FrigateCardImage extends LitElement {
|
||||
* Handle document visibility changes.
|
||||
*/
|
||||
protected _visibilityHandler(): void {
|
||||
if (!this._image) {
|
||||
if (!this._refImage.value) {
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState === 'hidden') {
|
||||
@@ -155,13 +178,15 @@ export class FrigateCardImage extends LitElement {
|
||||
// re-generation of a new URL would generate an unauthorized request
|
||||
// (401), see:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/398
|
||||
this._cachedValueController?.stopTimer();
|
||||
this._cachedValueController?.clearValue();
|
||||
this._forceStockImage();
|
||||
this._forceSafeImage();
|
||||
} else {
|
||||
// If the document is freshly re-visible, immediately re-render it to
|
||||
// restore the image src. If the HASS object is old (i.e. browser tab was
|
||||
// inactive for some time) this update request may be (correctly)
|
||||
// rejected.
|
||||
this._cachedValueController?.startTimer();
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -178,49 +203,53 @@ export class FrigateCardImage extends LitElement {
|
||||
}
|
||||
|
||||
protected _getImageSource(): string {
|
||||
if (this._imageConfig?.mode === 'url' && this._imageConfig?.url) {
|
||||
return this._buildImageURL(this._imageConfig.url);
|
||||
} else if (this.hass && this._imageConfig?.mode === 'camera') {
|
||||
const entity = this._getCameraEntity();
|
||||
if (entity) {
|
||||
const state = this.hass.states[entity];
|
||||
if (state && state.attributes.entity_picture) {
|
||||
return this._buildImageURL(state.attributes.entity_picture);
|
||||
}
|
||||
if (this.hass && this.imageConfig?.mode === 'camera') {
|
||||
const state = this._getAcceptableState(this._getCameraEntity());
|
||||
if (state?.attributes.entity_picture) {
|
||||
return this._buildImageURL(state.attributes.entity_picture);
|
||||
}
|
||||
}
|
||||
if (this.imageConfig?.mode !== 'screensaver' && this.imageConfig?.url) {
|
||||
return this._buildImageURL(this.imageConfig.url);
|
||||
}
|
||||
return defaultImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the img element to the stock image.
|
||||
* Force the img element to a safe image.
|
||||
*/
|
||||
protected _forceStockImage(): void {
|
||||
if (this._image) {
|
||||
this._image.src = defaultImage;
|
||||
protected _forceSafeImage(stockOnly?: boolean): void {
|
||||
if (this._refImage.value) {
|
||||
this._refImage.value.src =
|
||||
!stockOnly && this.imageConfig?.url ? this.imageConfig.url : defaultImage;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const src = this._cachedValueController?.value;
|
||||
// Note the use of live() below to ensure the update will restore the image
|
||||
// src if it's been changed via _forceSafeImage().
|
||||
return src
|
||||
? html` <img
|
||||
src=${src}
|
||||
@load=${(ev) => {
|
||||
dispatchMediaShowEvent(this, ev);
|
||||
${ref(this._refImage)}
|
||||
src=${live(src)}
|
||||
@load=${(ev: Event) => {
|
||||
dispatchMediaLoadedEvent(this, ev);
|
||||
}}
|
||||
@error=${() => {
|
||||
if (this._imageConfig?.mode === 'camera') {
|
||||
if (this.imageConfig?.mode === 'camera') {
|
||||
// In camera mode, the user has likely not made an error, but HA
|
||||
// may be unavailble, so show the stock image.
|
||||
this._forceStockImage();
|
||||
} else if (this._imageConfig?.mode === 'url') {
|
||||
// may be unavailble, so show the stock image. Don't let the URL
|
||||
// override the stock image in this case, as this could create an
|
||||
// error loop if that URL subsequently failed to load.
|
||||
this._forceSafeImage(true);
|
||||
} else if (this.imageConfig?.mode === 'url') {
|
||||
// In url mode, the user likely specified a URL that cannot be
|
||||
// resolved. Show an error message.
|
||||
dispatchErrorMessageEvent(
|
||||
this,
|
||||
localize('error.image_load_error'),
|
||||
this._imageConfig,
|
||||
{ context: this.imageConfig },
|
||||
);
|
||||
}
|
||||
}}
|
||||
@@ -232,3 +261,9 @@ export class FrigateCardImage extends LitElement {
|
||||
return unsafeCSS(imageStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"frigate-card-image": FrigateCardImage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,240 @@
|
||||
import { CSSResultGroup, unsafeCSS } from 'lit';
|
||||
import { EmblaCarouselType } from 'embla-carousel';
|
||||
import { createRef, Ref } from 'lit/directives/ref.js';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
import { AutoMediaPluginType } from './embla-plugins/automedia.js';
|
||||
import { FrigateCardCarousel } from './carousel.js';
|
||||
import { EmblaOptionsType } from 'embla-carousel';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import mediaCarouselStyle from '../scss/media-carousel.scss';
|
||||
import type {
|
||||
MediaLoadedInfo,
|
||||
NextPreviousControlConfig,
|
||||
TitleControlConfig,
|
||||
TransitionEffect,
|
||||
} from '../types.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic';
|
||||
import {
|
||||
createMediaLoadedInfo,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
isValidMediaLoadedInfo,
|
||||
} from '../utils/media-info.js';
|
||||
import { CarouselSelect, EmblaCarouselPlugins, FrigateCardCarousel } from './carousel';
|
||||
import { AutoMediaType } from './embla-plugins/automedia.js';
|
||||
import './next-prev-control.js';
|
||||
import './carousel.js';
|
||||
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
|
||||
import { FrigateCardTitleControl } from './title-control.js';
|
||||
import type { MediaShowInfo } from '../types.js';
|
||||
import {
|
||||
dispatchExistingMediaShowInfoAsEvent,
|
||||
isValidMediaShowInfo,
|
||||
} from '../common.js';
|
||||
|
||||
import './next-prev-control.js';
|
||||
|
||||
import mediaCarouselStyle from '../scss/media-carousel.scss';
|
||||
|
||||
const getEmptyImageSrc = (width: number, height: number) =>
|
||||
`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;
|
||||
mediaLoadedInfo: MediaLoadedInfo;
|
||||
}
|
||||
|
||||
export interface CarouselMediaUnloadedInfo {
|
||||
slide: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a carousel media loaded event.
|
||||
* @param target The target to send it from.
|
||||
* @param carouselMediaLoadedInfo The CarouselMediaLoadedInfo.
|
||||
*/
|
||||
const dispatchFrigateCardCarouselMediaLoaded = (
|
||||
target: EventTarget,
|
||||
carouselMediaLoadedInfo: CarouselMediaLoadedInfo,
|
||||
): void => {
|
||||
dispatchFrigateCardEvent<CarouselMediaLoadedInfo>(
|
||||
target,
|
||||
'carousel:media:loaded',
|
||||
carouselMediaLoadedInfo,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatch a carousel media UNloaded event.
|
||||
* @param target The target to send it from.
|
||||
* @param carouselMediaUnloadedInfo The CarouselMediaUnloadedInfo.
|
||||
*/
|
||||
const dispatchFrigateCardCarouselMediaUnloaded = (
|
||||
target: EventTarget,
|
||||
carouselMediaUnloadedInfo: CarouselMediaUnloadedInfo,
|
||||
): void => {
|
||||
dispatchFrigateCardEvent<CarouselMediaUnloadedInfo>(
|
||||
target,
|
||||
'carousel:media:unloaded',
|
||||
carouselMediaUnloadedInfo,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Turn a MediaLoadedInfo into a CarouselMediaLoadedInfo.
|
||||
* @param slide The slide number.
|
||||
* @param event The MediaShowEvent.
|
||||
*/
|
||||
export const wrapMediaLoadedEventForCarousel = (
|
||||
slide: number,
|
||||
event: CustomEvent<MediaLoadedInfo>,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], {
|
||||
slide: slide,
|
||||
mediaLoadedInfo: event.detail,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param slide The slide number.
|
||||
* @param event The MediaUnloadedEvent.
|
||||
*/
|
||||
export const wrapMediaUnloadedEventForCarousel = (
|
||||
slide: number,
|
||||
event: CustomEvent<void>,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
dispatchFrigateCardCarouselMediaUnloaded(event.composedPath()[0], {
|
||||
slide: slide,
|
||||
});
|
||||
};
|
||||
|
||||
@customElement('frigate-card-media-carousel')
|
||||
export class FrigateCardMediaCarousel extends FrigateCardCarousel {
|
||||
// A "map" from slide number to MediaShowInfo object.
|
||||
protected _mediaShowInfo: Record<number, MediaShowInfo> = {};
|
||||
export class FrigateCardMediaCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public nextPreviousConfig?: NextPreviousControlConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public carouselOptions?: EmblaOptionsType;
|
||||
|
||||
@property({ attribute: false })
|
||||
public carouselPlugins?: EmblaCarouselPlugins;
|
||||
|
||||
@property({ attribute: true })
|
||||
public transitionEffect?: TransitionEffect;
|
||||
|
||||
@property({ attribute: false })
|
||||
public label?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public titlePopupConfig?: TitleControlConfig;
|
||||
|
||||
// A "map" from slide number to MediaLoadedInfo object.
|
||||
protected _mediaLoadedInfo: Record<number, MediaLoadedInfo> = {};
|
||||
protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
|
||||
protected _previousControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
|
||||
protected _titleControlRef: Ref<FrigateCardTitleControl> = createRef();
|
||||
protected _titleTimerID: number | null = null;
|
||||
|
||||
protected _boundAutoPlayHandler = this.autoPlay.bind(this);
|
||||
protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this);
|
||||
protected _boundAdaptContainerHeightToSlide =
|
||||
this._adaptContainerHeightToSlide.bind(this);
|
||||
protected _boundTitleHandler = this._titleHandler.bind(this);
|
||||
|
||||
// This carousel may be resized by Lovelace resizes, window resizes,
|
||||
// fullscreen, etc. Always call the adaptive height handler when the size
|
||||
// changes.
|
||||
protected _resizeObserver: ResizeObserver;
|
||||
protected _slideResizeObserver: ResizeObserver;
|
||||
protected _intersectionObserver: IntersectionObserver;
|
||||
|
||||
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._resizeObserver = new ResizeObserver(this._adaptiveHeightHandler.bind(this));
|
||||
// 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
|
||||
// (e.g. changing from a progress indicator to a loaded media).
|
||||
this._resizeObserver = new ResizeObserver(this._reInitAndAdjustHeight.bind(this));
|
||||
this._slideResizeObserver = new ResizeObserver(
|
||||
this._reInitAndAdjustHeight.bind(this),
|
||||
);
|
||||
this._intersectionObserver = new IntersectionObserver(
|
||||
this._intersectionHandler.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the media on the selected slide. May be overridden to control when
|
||||
* autoplay should happen.
|
||||
* Get the underlying carousel.
|
||||
*/
|
||||
protected _autoPlayHandler(): void {
|
||||
(this._plugins['AutoMediaPlugin'] as AutoMediaPluginType | undefined)?.play();
|
||||
public frigateCardCarousel(): FrigateCardCarousel | null {
|
||||
return this._refCarousel.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the media on the selected slide. May be overridden to control when
|
||||
* autoplay should happen.
|
||||
* Get the AutoMedia plugin (if any).
|
||||
* @returns The plugin or `null`.
|
||||
*/
|
||||
protected _autoUnmuteHandler(): void {
|
||||
(this._plugins['AutoMediaPlugin'] as AutoMediaPluginType | undefined)?.unmute();
|
||||
protected _getAutoMediaPlugin(): AutoMediaType | null {
|
||||
return this.frigateCardCarousel()?.carousel()?.plugins().autoMedia ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the media on the selected slide.
|
||||
*/
|
||||
public autoPlay(): void {
|
||||
const automediaOptions = this._getAutoMediaPlugin()?.options;
|
||||
if (
|
||||
automediaOptions?.autoPlayCondition &&
|
||||
['all', 'selected'].includes(automediaOptions?.autoPlayCondition)
|
||||
) {
|
||||
this._getAutoMediaPlugin()?.play();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the media on the selected slide.
|
||||
*/
|
||||
public autoPause(): void {
|
||||
const automediaOptions = this._getAutoMediaPlugin()?.options;
|
||||
if (
|
||||
automediaOptions?.autoPauseCondition &&
|
||||
['all', 'selected'].includes(automediaOptions.autoPauseCondition)
|
||||
) {
|
||||
this._getAutoMediaPlugin()?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the media on the selected slide.
|
||||
*/
|
||||
public autoUnmute(): void {
|
||||
const automediaOptions = this._getAutoMediaPlugin()?.options;
|
||||
if (
|
||||
automediaOptions?.autoUnmuteCondition &&
|
||||
['all', 'selected'].includes(automediaOptions?.autoUnmuteCondition)
|
||||
) {
|
||||
this._getAutoMediaPlugin()?.unmute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the media on the selected slide.
|
||||
*/
|
||||
public autoMute(): void {
|
||||
const automediaOptions = this._getAutoMediaPlugin()?.options;
|
||||
if (
|
||||
automediaOptions?.autoMuteCondition &&
|
||||
['all', 'selected'].includes(automediaOptions?.autoMuteCondition)
|
||||
) {
|
||||
this._getAutoMediaPlugin()?.mute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,8 +258,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
|
||||
|
||||
// Allow a brief pause after the media loads, but before the title is
|
||||
// displayed. This allows for a pleasant appearance/disappear of the title,
|
||||
// and allows for the browser to finish rendering the carousel (inc.
|
||||
// adaptive height which has `0.5s ease`, see `media-carousel.scss`).
|
||||
// and allows for the browser to finish rendering the carousel.
|
||||
this._titleTimerID = window.setTimeout(show, 0.5 * 1000);
|
||||
}
|
||||
|
||||
@@ -87,83 +267,88 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('frigate-card:media-show', this._autoPlayHandler);
|
||||
this.addEventListener('frigate-card:media-show', this._autoUnmuteHandler);
|
||||
this.addEventListener('frigate-card:media-show', this._adaptiveHeightHandler);
|
||||
this.addEventListener('frigate-card:media-show', this._titleHandler);
|
||||
|
||||
this.addEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler);
|
||||
this.addEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler);
|
||||
this.addEventListener(
|
||||
'frigate-card:media:loaded',
|
||||
this._boundAdaptContainerHeightToSlide,
|
||||
);
|
||||
this.addEventListener('frigate-card:media:loaded', this._boundTitleHandler);
|
||||
this._resizeObserver.observe(this);
|
||||
this._intersectionObserver.observe(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('frigate-card:media-show', this._autoPlayHandler);
|
||||
this.removeEventListener('frigate-card:media-show', this._autoUnmuteHandler);
|
||||
this.removeEventListener('frigate-card:media-show', this._adaptiveHeightHandler);
|
||||
this.removeEventListener('frigate-card:media-show', this._titleHandler);
|
||||
this.removeEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler);
|
||||
this.removeEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler);
|
||||
this.removeEventListener(
|
||||
'frigate-card:media:loaded',
|
||||
this._boundAdaptContainerHeightToSlide,
|
||||
);
|
||||
this.removeEventListener('frigate-card:media:loaded', this._boundTitleHandler);
|
||||
this._resizeObserver.disconnect();
|
||||
}
|
||||
this._intersectionObserver.disconnect();
|
||||
|
||||
protected _destroyCarousel(): void {
|
||||
super._destroyCarousel();
|
||||
|
||||
// Notes on instance variables:
|
||||
// * this._mediaShowInfo: This is set when the media in the DOM loads. If a
|
||||
// new View included the same media, the DOM would not change and so the
|
||||
// prior contents would still be valid and would not re-appear (as the
|
||||
// media would not reload) -- as such, leave this alone on carousel
|
||||
// destroy. New media in that slide will replace the prior contents on
|
||||
// load.
|
||||
this._mediaLoadedInfo = {};
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the carousel.
|
||||
* ReInit the carousel and adapt the container height.
|
||||
*/
|
||||
protected _initCarousel(): void {
|
||||
super._initCarousel();
|
||||
|
||||
// Necessary because typescript local type narrowing is not paying attention
|
||||
// to the side-effect of the call to super._initCarousel().
|
||||
const carousel = this._carousel as EmblaCarouselType | undefined;
|
||||
|
||||
// Update the view object as the carousel is moved.
|
||||
carousel?.on('select', this._selectSlideSetViewHandler.bind(this));
|
||||
|
||||
// Update the next/previous controls as the carousel is moved.
|
||||
carousel?.on('select', this._selectSlideNextPreviousHandler.bind(this));
|
||||
|
||||
// Dispatch MediaShow events as the carousel is moved.
|
||||
carousel?.on('init', this._selectSlideMediaShowHandler.bind(this));
|
||||
carousel?.on('select', this._selectSlideMediaShowHandler.bind(this));
|
||||
protected _reInitAndAdjustHeight(): void {
|
||||
this.frigateCardCarousel()?.carouselReInitWhenSafe();
|
||||
this._adaptContainerHeightToSlide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the the height of the container on media load in case the dimensions
|
||||
* Called when the carousel intersects with the viewport.
|
||||
* @param entries The IntersectionObserverEntry entries (should be only 1).
|
||||
*/
|
||||
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
|
||||
/**
|
||||
* - If the DOM that contains this carousel changes such that it causes
|
||||
* slides to entirely appear/disappear (e.g. `display: none` or hidden),
|
||||
* then the displayed slide sizes will significantly change and the
|
||||
* carousel will need to be reinitialized. Without this, odd bugs may
|
||||
* occur for some users in some circumstances causing the carousel to
|
||||
* appear 'stuck'.
|
||||
* - Example bug when this reinitialization is not performed:
|
||||
* https://github.com/dermotduffy/frigate-hass-card/issues/651
|
||||
*/
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
this._reInitAndAdjustHeight();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the the height of the component on media load in case the dimensions
|
||||
* have changed. This handler is not triggered from carousel events, as it's
|
||||
* actually the media load/show that will change the dimensions, and that is
|
||||
* async from carousel actions (e.g. lazy-loaded media).
|
||||
*
|
||||
* This component does not use the stock Embla auto-height plugin as it
|
||||
* resizes the container on selection rather than media load.
|
||||
*/
|
||||
protected _adaptiveHeightHandler(): void {
|
||||
protected _adaptContainerHeightToSlide(): void {
|
||||
const adaptCarouselHeight = (): void => {
|
||||
if (!this._carousel) {
|
||||
return;
|
||||
}
|
||||
const slide = this._carousel?.selectedScrollSnap();
|
||||
if (slide !== undefined) {
|
||||
this._carousel.containerNode().style.removeProperty('max-height');
|
||||
const slides = this._carousel.slideNodes();
|
||||
const height = slides[slide].getBoundingClientRect().height;
|
||||
if (height > 0) {
|
||||
this._carousel.containerNode().style.maxHeight = `${height}px`;
|
||||
const selected = this.frigateCardCarousel()?.getCarouselSelected();
|
||||
if (selected) {
|
||||
this.style.removeProperty('max-height');
|
||||
const height = selected.element.getBoundingClientRect().height;
|
||||
if (height !== undefined && height > 0) {
|
||||
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-show` events, which are usually in
|
||||
// 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
|
||||
@@ -171,117 +356,103 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
|
||||
window.requestAnimationFrame(adaptCarouselHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the user selecting a new slide in the carousel.
|
||||
*/
|
||||
protected _selectSlideSetViewHandler(): void {
|
||||
// To be overridden in children.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle updating of the next/previous controls when the carousel is moved.
|
||||
*/
|
||||
protected _selectSlideNextPreviousHandler(): void {
|
||||
// To be overridden in children.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a next/previous control interaction.
|
||||
* @param direction The direction requested, previous or next.
|
||||
*/
|
||||
protected _nextPreviousHandler(direction: 'previous' | 'next'): void {
|
||||
if (direction == 'previous') {
|
||||
this._carousel?.scrollPrev(this._getTransitionEffect() === 'none');
|
||||
} else if (direction == 'next') {
|
||||
this._carousel?.scrollNext(this._getTransitionEffect() === 'none');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a media show event when a slide is selected.
|
||||
*/
|
||||
protected _selectSlideMediaShowHandler(): void {
|
||||
if (!this._carousel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const slideIndex = this._carousel.selectedScrollSnap();
|
||||
if (slideIndex in this._mediaShowInfo) {
|
||||
dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]);
|
||||
protected _dispatchMediaLoadedInfo(): void {
|
||||
const slideIndex = this.frigateCardCarousel()?.getCarouselSelected()?.index;
|
||||
if (slideIndex !== undefined && slideIndex in this._mediaLoadedInfo) {
|
||||
dispatchExistingMediaLoadedInfoAsEvent(this, this._mediaLoadedInfo[slideIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a media-show event that is generated by a child component, saving the
|
||||
* Handle a media:loaded event that is generated by a child component, saving the
|
||||
* contents for future use when the relevant slide is actually shown.
|
||||
* @param slideIndex The relevant slide index.
|
||||
* @param event The media-show event from the child component.
|
||||
* @param event The media:loaded event from the child component.
|
||||
*/
|
||||
protected _mediaShowEventHandler(
|
||||
slideIndex: number,
|
||||
event: CustomEvent<MediaShowInfo>,
|
||||
): void {
|
||||
protected _storeMediaLoadedInfo(event: CustomEvent<CarouselMediaLoadedInfo>): void {
|
||||
// Don't allow the inbound event to propagate upwards, that will be
|
||||
// automatically done at the appropriate time as the slide is shown.
|
||||
event.stopPropagation();
|
||||
this._mediaLoadedHandler(slideIndex, event.detail);
|
||||
const mediaLoadedInfo = event.detail.mediaLoadedInfo;
|
||||
const slideIndex = event.detail.slide;
|
||||
|
||||
// isValidMediaLoadedInfo is used to prevent saving media info that will be
|
||||
// rejected upstream (empty 1x1 images will be rejected here).
|
||||
if (mediaLoadedInfo && isValidMediaLoadedInfo(mediaLoadedInfo)) {
|
||||
this._mediaLoadedInfo[slideIndex] = mediaLoadedInfo;
|
||||
if (this.frigateCardCarousel()?.getCarouselSelected()?.index === slideIndex) {
|
||||
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a MediaShowInfo object that is generated on media load, by saving it
|
||||
* for future, or immediate use, when the relevant slide is displayed.
|
||||
* @param slideIndex The relevant slide index.
|
||||
* @param mediaShowInfo The MediaShowInfo object generated by the media.
|
||||
* Remove a media loaded info (i.e. a media item has unloaded).
|
||||
* @param event The CarouselMediaUnloadedInfo event.
|
||||
*/
|
||||
protected _mediaLoadedHandler(
|
||||
slideIndex: number,
|
||||
mediaShowInfo?: MediaShowInfo | null,
|
||||
): void {
|
||||
// isValidMediaShowInfo is used to prevent saving media info that will be
|
||||
// rejected upstream (empty 1x1 images will be rejected here).
|
||||
if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) {
|
||||
this._mediaShowInfo[slideIndex] = mediaShowInfo;
|
||||
if (this._carousel && this._carousel?.selectedScrollSnap() == slideIndex) {
|
||||
dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo);
|
||||
}
|
||||
protected _removeMediaLoadedInfo(event: CustomEvent<CarouselMediaUnloadedInfo>): void {
|
||||
const slideIndex = event.detail.slide;
|
||||
delete this._mediaLoadedInfo[slideIndex];
|
||||
|
||||
/**
|
||||
* Images need a width/height from initial load, and browsers will assume
|
||||
* that the aspect ratio of the initial dummy-image load will persist. In
|
||||
* lazy-loading, this can cause a 1x1 pixel dummy image to cause the
|
||||
* browser to assume all images will be square, so the whole carousel will
|
||||
* have the wrong aspect-ratio until every single image has been lazily
|
||||
* loaded. Adaptive height helps in that the carousel gets resized on each
|
||||
* img display to the correct size, but it still causes a minor noticeable
|
||||
* flicker until the height change is complete.
|
||||
*
|
||||
* To avoid this, we use a 16:9 dummy image at first (most
|
||||
* likely?) and once the first piece of real media has been loaded, all
|
||||
* dummy images are replaced with dummy images that match the aspect ratio
|
||||
* of the real image. It still might be wrong, but it's the best option
|
||||
* available.
|
||||
*/
|
||||
const firstMediaLoad = !Object.keys(this._mediaShowInfo).length;
|
||||
if (firstMediaLoad) {
|
||||
const replacementImageSrc = getEmptyImageSrc(
|
||||
mediaShowInfo.width,
|
||||
mediaShowInfo.height,
|
||||
);
|
||||
|
||||
this.renderRoot.querySelectorAll('.embla__container img').forEach((img) => {
|
||||
const imageElement = img as HTMLImageElement;
|
||||
if (imageElement.src === IMG_EMPTY) {
|
||||
imageElement.src = replacementImageSrc;
|
||||
}
|
||||
});
|
||||
}
|
||||
// If the slide that unloaded is not visible, don't propagate the event upwards.
|
||||
if (this.frigateCardCarousel()?.getCarouselSelected()?.index !== slideIndex) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
return html` <frigate-card-carousel
|
||||
${ref(this._refCarousel)}
|
||||
.carouselOptions=${this.carouselOptions}
|
||||
.carouselPlugins=${this.carouselPlugins}
|
||||
transitionEffect=${ifDefined(this.transitionEffect)}
|
||||
@frigate-card:carousel:init=${this._dispatchMediaLoadedInfo.bind(this)}
|
||||
@frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelect>) => {
|
||||
this._slideResizeObserver.disconnect();
|
||||
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:unloaded=${this._removeMediaLoadedInfo.bind(this)}
|
||||
>
|
||||
<slot slot="previous" name="previous"></slot>
|
||||
<slot></slot>
|
||||
<slot slot="next" name="next"></slot>
|
||||
</frigate-card-carousel>
|
||||
${this.label && this.titlePopupConfig
|
||||
? html`<frigate-card-title-control
|
||||
${ref(this._titleControlRef)}
|
||||
.config=${this.titlePopupConfig}
|
||||
.text="${this.label}"
|
||||
.fitInto=${this as HTMLElement}
|
||||
>
|
||||
</frigate-card-title-control> `
|
||||
: ``}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return [super.styles, unsafeCSS(mediaCarouselStyle)];
|
||||
return unsafeCSS(mediaCarouselStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-media-carousel': FrigateCardMediaCarousel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,37 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { actionHandler } from '../action-handler-directive.js';
|
||||
|
||||
import './submenu.js';
|
||||
|
||||
import menuStyle from '../scss/menu.scss';
|
||||
import type {
|
||||
ActionsConfig,
|
||||
ActionType,
|
||||
ExtendedHomeAssistant,
|
||||
MenuButton,
|
||||
MenuConfig,
|
||||
StateParameters,
|
||||
MenuItem,
|
||||
StateParameters
|
||||
} from '../types.js';
|
||||
import {
|
||||
convertActionToFrigateCardCustomAction,
|
||||
frigateCardHandleActionConfig,
|
||||
frigateCardHasAction,
|
||||
getActionConfigGivenAction,
|
||||
refreshDynamicStateParameters,
|
||||
} from '../common.js';
|
||||
|
||||
import menuStyle from '../scss/menu.scss';
|
||||
getActionConfigGivenAction
|
||||
} from '../utils/action.js';
|
||||
import { FRIGATE_ICON_SVG_PATH } from '../utils/frigate.js';
|
||||
import { refreshDynamicStateParameters } from '../utils/ha';
|
||||
import './submenu.js';
|
||||
|
||||
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
|
||||
export const FRIGATE_ICON_FILLED =
|
||||
'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';
|
||||
|
||||
/**
|
||||
* A menu for the FrigateCard.
|
||||
@@ -55,22 +39,28 @@ export const FRIGATE_ICON_FILLED =
|
||||
@customElement('frigate-card-menu')
|
||||
export class FrigateCardMenu extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: true, type: Boolean, reflect: true })
|
||||
public expanded = false;
|
||||
|
||||
set menuConfig(menuConfig: MenuConfig) {
|
||||
this._menuConfig = menuConfig;
|
||||
if (menuConfig) {
|
||||
this.style.setProperty('--frigate-card-menu-button-size', menuConfig.button_size);
|
||||
this.style.setProperty(
|
||||
'--frigate-card-menu-button-size',
|
||||
`${menuConfig.button_size}px`,
|
||||
);
|
||||
}
|
||||
// Store the menu mode as an attribute (used for CSS attribute selectors).
|
||||
this.setAttribute('data-mode', menuConfig.mode);
|
||||
// Store the menu style, position and alignment as attributes (used for
|
||||
// styling).
|
||||
this.setAttribute('data-style', menuConfig.style);
|
||||
this.setAttribute('data-position', menuConfig.position);
|
||||
this.setAttribute('data-alignment', menuConfig.alignment);
|
||||
}
|
||||
@state()
|
||||
protected _menuConfig?: MenuConfig;
|
||||
|
||||
@property({ attribute: true, type: Boolean, reflect: true })
|
||||
protected expanded = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public buttons: MenuButton[] = [];
|
||||
|
||||
@@ -80,7 +70,7 @@ export class FrigateCardMenu extends LitElement {
|
||||
* @returns `true` if the menu is hiding, `false` otherwise.
|
||||
*/
|
||||
static isHidingMenu(menuConfig: MenuConfig | undefined): boolean {
|
||||
return menuConfig?.mode.startsWith('hidden-') ?? false;
|
||||
return menuConfig?.style === 'hidden' ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,24 +183,71 @@ export class FrigateCardMenu extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure menu buttons are sorted before the render.
|
||||
* @param changedProps The changed properties
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
const style = this._menuConfig?.style;
|
||||
const sortButtons = (a: MenuItem, b: MenuItem): number => {
|
||||
// If the menu is hidden, the Frigate button must come first.
|
||||
if (style === 'hidden') {
|
||||
if (a.icon === FRIGATE_BUTTON_MENU_ICON) {
|
||||
return -1;
|
||||
} else if (b.icon === FRIGATE_BUTTON_MENU_ICON) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise sort by priority.
|
||||
if (
|
||||
a.priority === undefined ||
|
||||
(b.priority !== undefined && b.priority > a.priority)
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
if (
|
||||
b.priority === undefined ||
|
||||
(a.priority !== undefined && b.priority < a.priority)
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
if (changedProps.has('_menuConfig') || changedProps.has('buttons')) {
|
||||
this.buttons.sort(sortButtons);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a button.
|
||||
* @param button The button configuration to render.
|
||||
* @returns A rendered template or void.
|
||||
*/
|
||||
protected _renderButton(button: MenuButton): TemplateResult | void {
|
||||
if (button.type == 'custom:frigate-card-menu-submenu') {
|
||||
if (button.enabled === false) {
|
||||
return;
|
||||
}
|
||||
if (button.type === 'custom:frigate-card-menu-submenu') {
|
||||
return html` <frigate-card-submenu
|
||||
.hass=${this.hass}
|
||||
.submenu=${button}
|
||||
@action=${this._actionHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-submenu>`;
|
||||
} else if (button.type === 'custom:frigate-card-menu-submenu-select') {
|
||||
return html` <frigate-card-submenu-select
|
||||
.hass=${this.hass}
|
||||
.submenuSelect=${button}
|
||||
@action=${this._actionHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-submenu-select>`;
|
||||
}
|
||||
|
||||
let stateParameters: StateParameters = { ...button };
|
||||
const svgPath =
|
||||
stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_FILLED : '';
|
||||
stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
|
||||
|
||||
if (this.hass && button.type === 'custom:frigate-card-menu-state-icon') {
|
||||
stateParameters = refreshDynamicStateParameters(this.hass, stateParameters);
|
||||
@@ -263,18 +300,37 @@ export class FrigateCardMenu extends LitElement {
|
||||
if (!this._menuConfig) {
|
||||
return;
|
||||
}
|
||||
const mode = this._menuConfig.mode;
|
||||
|
||||
if (mode == 'none') {
|
||||
const style = this._menuConfig.style;
|
||||
if (style === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the hidden menu isn't expanded, only show the Frigate button.
|
||||
const buttons =
|
||||
!mode.startsWith('hidden-') || this.expanded
|
||||
? this.buttons
|
||||
const matchingButtons =
|
||||
style !== 'hidden' || this.expanded
|
||||
? this.buttons.filter(
|
||||
(button) => !button.alignment || button.alignment === 'matching',
|
||||
)
|
||||
: this.buttons.filter((button) => button.icon === FRIGATE_BUTTON_MENU_ICON);
|
||||
return html` ${buttons.map((button) => this._renderButton(button))} `;
|
||||
|
||||
const opposingButtons =
|
||||
style !== 'hidden' || this.expanded
|
||||
? this.buttons.filter((button) => button.alignment === 'opposing')
|
||||
: [];
|
||||
|
||||
const matchingStyle = {
|
||||
flex: String(matchingButtons.length),
|
||||
};
|
||||
const opposingStyle = {
|
||||
flex: String(opposingButtons.length),
|
||||
};
|
||||
|
||||
return html` <div class="matching" style="${styleMap(matchingStyle)}">
|
||||
${matchingButtons.map((button) => this._renderButton(button))}
|
||||
</div>
|
||||
<div class="opposing" style="${styleMap(opposingStyle)}">
|
||||
${opposingButtons.map((button) => this._renderButton(button))}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,3 +340,9 @@ export class FrigateCardMenu extends LitElement {
|
||||
return unsafeCSS(menuStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"frigate-card-menu": FrigateCardMenu
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,51 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
import { Message } from '../types.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { TROUBLESHOOTING_URL } from '../const.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
|
||||
import messageStyle from '../scss/message.scss';
|
||||
import { FrigateCardError, Message, MessageType } from '../types.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
|
||||
@customElement('frigate-card-message')
|
||||
export class FrigateCardMessage extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected message = '';
|
||||
public message: string | TemplateResult<1> = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
protected context?: unknown;
|
||||
public context?: unknown;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected icon?: string;
|
||||
public icon?: string;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public dotdotdot?: boolean;
|
||||
|
||||
// Render the menu.
|
||||
protected render(): TemplateResult {
|
||||
const icon = this.icon ? this.icon : 'mdi:information-outline';
|
||||
return html`
|
||||
<div class="wrapper">
|
||||
<div class="message">
|
||||
<div class="icon">
|
||||
<ha-icon icon="${icon}"> </ha-icon>
|
||||
</div>
|
||||
<div class="contents">
|
||||
<span> ${this.message ? html`${this.message}` : ''} </span>
|
||||
${this.context ? html`<pre>${JSON.stringify(this.context, null, 2)}</pre>` : ''}
|
||||
</div>
|
||||
const classes = {
|
||||
dotdotdot: !!this.dotdotdot,
|
||||
};
|
||||
return html` <div class="wrapper">
|
||||
<div class="message">
|
||||
<div class="icon">
|
||||
<ha-icon icon="${icon}"> </ha-icon>
|
||||
</div>
|
||||
</div>`;
|
||||
<div class="contents">
|
||||
<span class="${classMap(classes)}">
|
||||
${this.message
|
||||
? html`${this.message}${this.context && typeof this.context === 'string'
|
||||
? ': ' + this.context
|
||||
: ''}`
|
||||
: ''}
|
||||
</span>
|
||||
${this.context && typeof this.context !== 'string'
|
||||
? html`<pre>${JSON.stringify(this.context, null, 2)}</pre>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@@ -43,7 +56,7 @@ export class FrigateCardMessage extends LitElement {
|
||||
@customElement('frigate-card-error-message')
|
||||
export class FrigateCardErrorMessage extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected message?: Message;
|
||||
public message?: Message;
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.message) {
|
||||
@@ -54,6 +67,7 @@ export class FrigateCardErrorMessage extends LitElement {
|
||||
<a href="${TROUBLESHOOTING_URL}"> ${localize('error.troubleshooting')}</a>.`}
|
||||
.icon=${'mdi:alert-circle'}
|
||||
.context=${this.message.context}
|
||||
.dotdotdot=${this.message.dotdotdot}
|
||||
>
|
||||
</frigate-card-message>`;
|
||||
}
|
||||
@@ -66,7 +80,7 @@ export class FrigateCardErrorMessage extends LitElement {
|
||||
@customElement('frigate-card-progress-indicator')
|
||||
export class FrigateCardProgressIndicator extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected message = '';
|
||||
public message: string | TemplateResult = '';
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html` <div class="message vertical">
|
||||
@@ -83,15 +97,16 @@ export class FrigateCardProgressIndicator extends LitElement {
|
||||
}
|
||||
|
||||
export function renderMessage(message: Message): TemplateResult {
|
||||
if (message.type == 'error') {
|
||||
if (message.type === 'error') {
|
||||
return html` <frigate-card-error-message
|
||||
.message=${message}
|
||||
></frigate-card-error-message>`;
|
||||
} else if (message.type == 'info') {
|
||||
} else {
|
||||
return html` <frigate-card-message
|
||||
.message=${message.message}
|
||||
.icon=${message.icon}
|
||||
.context=${message.context}
|
||||
.dotdotdot=${message.dotdotdot}
|
||||
></frigate-card-message>`;
|
||||
}
|
||||
return html``;
|
||||
@@ -103,3 +118,64 @@ export function renderProgressIndicator(message?: string): TemplateResult {
|
||||
</frigate-card-progress-indicator>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event with a message to show to the user.
|
||||
* @param element The element to send the event.
|
||||
* @param message The message to show.
|
||||
* @param options Optional icon and context to include.
|
||||
*/
|
||||
export function dispatchMessageEvent(
|
||||
element: EventTarget,
|
||||
message: string,
|
||||
type: MessageType,
|
||||
options?: {
|
||||
icon?: string;
|
||||
context?: unknown;
|
||||
},
|
||||
): void {
|
||||
dispatchFrigateCardEvent<Message>(element, 'message', {
|
||||
message: message,
|
||||
type: type,
|
||||
icon: options?.icon,
|
||||
context: options?.context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event with an error message to show to the user.
|
||||
* @param element The element to send the event.
|
||||
* @param message The message to show.
|
||||
* @param options Optional context to include.
|
||||
*/
|
||||
export function dispatchErrorMessageEvent(
|
||||
element: EventTarget,
|
||||
message: string,
|
||||
options?: {
|
||||
context?: unknown;
|
||||
},
|
||||
): void {
|
||||
dispatchMessageEvent(element, message, 'error', {
|
||||
context: options?.context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event with an error message to show to the user.
|
||||
* @param element The element to send the event.
|
||||
* @param message The message to show.
|
||||
*/
|
||||
export function dispatchFrigateCardErrorEvent(
|
||||
element: EventTarget,
|
||||
error: FrigateCardError,
|
||||
): void {
|
||||
dispatchErrorMessageEvent(element, error.message, { context: error.context });
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-progress-indicator': FrigateCardProgressIndicator;
|
||||
'frigate-card-error-message': FrigateCardErrorMessage;
|
||||
'frigate-card-message': FrigateCardMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { classMap } from 'lit/directives/class-map.js';
|
||||
import { NextPreviousControlConfig } from '../types.js';
|
||||
|
||||
import controlStyle from '../scss/next-previous-control.scss';
|
||||
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { renderTask } from '../utils/task.js';
|
||||
|
||||
@customElement('frigate-card-next-previous-control')
|
||||
export class FrigateCardNextPreviousControl extends LitElement {
|
||||
@@ -13,11 +16,14 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
|
||||
set controlConfig(controlConfig: NextPreviousControlConfig | undefined) {
|
||||
if (controlConfig?.size) {
|
||||
this.style.setProperty('--frigate-card-next-prev-size', controlConfig.size);
|
||||
this.style.setProperty('--frigate-card-next-prev-size', `${controlConfig.size}px`);
|
||||
}
|
||||
this._controlConfig = controlConfig;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@state()
|
||||
protected _controlConfig?: NextPreviousControlConfig;
|
||||
|
||||
@@ -31,7 +37,13 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
public disabled = false;
|
||||
|
||||
// Label that is used for ARIA support and as tooltip.
|
||||
@property() label = "";
|
||||
@property() label = '';
|
||||
|
||||
protected _embedThumbnailTask = createFetchThumbnailTask(
|
||||
this,
|
||||
() => this.hass,
|
||||
() => this.thumbnail,
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (this.disabled || !this._controlConfig || this._controlConfig.style == 'none') {
|
||||
@@ -50,18 +62,15 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
if (['chevrons', 'icons'].includes(this._controlConfig.style)) {
|
||||
let icon: string;
|
||||
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 {
|
||||
if (!this.icon) {
|
||||
return html``;
|
||||
}
|
||||
icon = this.icon
|
||||
icon = this.icon;
|
||||
}
|
||||
|
||||
return html` <ha-icon-button
|
||||
class="${classMap(classes)}"
|
||||
.label=${this.label}
|
||||
>
|
||||
return html` <ha-icon-button class="${classMap(classes)}" .label=${this.label}>
|
||||
<ha-icon icon=${icon}></ha-icon>
|
||||
</ha-icon-button>`;
|
||||
}
|
||||
@@ -69,15 +78,30 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
if (!this.thumbnail) {
|
||||
return html``;
|
||||
}
|
||||
return html`<img
|
||||
src="${this.thumbnail}"
|
||||
class="${classMap(classes)}"
|
||||
title="${this.label}"
|
||||
aria-label="${this.label}"
|
||||
/>`;
|
||||
|
||||
return renderTask(
|
||||
this,
|
||||
this._embedThumbnailTask,
|
||||
(embeddedThumbnail: string | null) =>
|
||||
embeddedThumbnail
|
||||
? html`<img
|
||||
src="${embeddedThumbnail}"
|
||||
class="${classMap(classes)}"
|
||||
title="${this.label}"
|
||||
aria-label="${this.label}"
|
||||
/>`
|
||||
: html``,
|
||||
() => html`<div class=${classMap(classes)}></div>`,
|
||||
);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(controlStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-next-previous-control': FrigateCardNextPreviousControl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,65 @@
|
||||
import type { Corner } from '@material/mwc-menu';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import {
|
||||
frigateCardHasAction,
|
||||
refreshDynamicStateParameters,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../common.js';
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ExtendedHomeAssistant, MenuSubmenu, MenuSubmenuItem } from '../types.js';
|
||||
import { actionHandler } from '../action-handler-directive.js';
|
||||
|
||||
import submenuStyle from '../scss/submenu.scss';
|
||||
import {
|
||||
MenuSubmenu,
|
||||
MenuSubmenuItem,
|
||||
MenuSubmenuSelect,
|
||||
StateParameters,
|
||||
} from '../types.js';
|
||||
import {
|
||||
frigateCardHasAction,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../utils/action.js';
|
||||
import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha';
|
||||
import { domainIcon } from '../utils/icons/domain-icon.js';
|
||||
|
||||
@customElement('frigate-card-submenu')
|
||||
export class FrigateCardSubmenu extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public submenu?: MenuSubmenu;
|
||||
|
||||
@property({ attribute: false })
|
||||
public corner?: Corner;
|
||||
|
||||
protected _renderItem(item: MenuSubmenuItem): TemplateResult | void {
|
||||
if (!this.hass) {
|
||||
return;
|
||||
}
|
||||
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item });
|
||||
const getIcon = (stateParameters: StateParameters): TemplateResult => {
|
||||
if (stateParameters.icon) {
|
||||
return html` <ha-icon
|
||||
style="${styleMap(stateParameters.style || {})}"
|
||||
data-domain=${ifDefined(stateParameters.data_domain)}
|
||||
data-state=${ifDefined(stateParameters.data_state)}
|
||||
slot="graphic"
|
||||
icon="${stateParameters.icon || ''}"
|
||||
>
|
||||
</ha-icon>`;
|
||||
}
|
||||
return html``;
|
||||
};
|
||||
|
||||
return html`
|
||||
<mwc-list-item
|
||||
style="${styleMap(stateParameters.style || {})}"
|
||||
graphic="icon"
|
||||
graphic=${ifDefined(stateParameters.icon ? 'icon' : undefined)}
|
||||
?twoline=${!!item.subtitle}
|
||||
?selected=${item.selected}
|
||||
?activated=${item.selected}
|
||||
?disabled=${item.enabled === false}
|
||||
aria-label="${stateParameters.title || ''}"
|
||||
@action=${(ev) => {
|
||||
// Attach the action config so ascendants have access to it.
|
||||
@@ -48,17 +70,9 @@ export class FrigateCardSubmenu extends LitElement {
|
||||
hasDoubleClick: frigateCardHasAction(item.double_tap_action),
|
||||
})}
|
||||
>
|
||||
${stateParameters.title || ''}
|
||||
${stateParameters.icon
|
||||
? html` <ha-icon
|
||||
data-domain=${ifDefined(stateParameters.data_domain)}
|
||||
data-state=${ifDefined(stateParameters.data_state)}
|
||||
style="${styleMap(stateParameters.style || {})}"
|
||||
slot="graphic"
|
||||
icon="${stateParameters.icon}"
|
||||
>
|
||||
</ha-icon>`
|
||||
: ``}
|
||||
<span>${stateParameters.title || ''}</span>
|
||||
${item.subtitle ? html`<span slot="secondary">${item.subtitle}</span>` : ''}
|
||||
${getIcon(stateParameters)}
|
||||
</mwc-list-item>
|
||||
`;
|
||||
}
|
||||
@@ -69,7 +83,7 @@ export class FrigateCardSubmenu extends LitElement {
|
||||
}
|
||||
return html`
|
||||
<ha-button-menu
|
||||
corner=${this.corner || 'BOTTOM_LEFT'}
|
||||
corner=${'BOTTOM_LEFT'}
|
||||
@closed=${
|
||||
// Prevent the submenu closing from closing anything upstream (e.g.
|
||||
// selecting a submenu in the editor dialog should not close the
|
||||
@@ -104,3 +118,111 @@ export class FrigateCardSubmenu extends LitElement {
|
||||
return unsafeCSS(submenuStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-submenu-select')
|
||||
export class FrigateCardSubmenuSelect extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public submenuSelect?: MenuSubmenuSelect;
|
||||
|
||||
protected _generatedSubmenu?: MenuSubmenu;
|
||||
|
||||
/**
|
||||
* Called to determine if the update should proceed.
|
||||
* @param changedProps
|
||||
* @returns `true` if the update should proceed, `false` otherwise.
|
||||
*/
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
// No need to update the submenu unless the select entity has changed.
|
||||
const oldHass = changedProps.get('hass') as HomeAssistant | undefined;
|
||||
return (
|
||||
changedProps.size != 1 ||
|
||||
!this.submenuSelect ||
|
||||
(!!oldHass && isHassDifferent(this.hass, oldHass, [this.submenuSelect.entity]))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the render function will be called.
|
||||
*/
|
||||
protected willUpdate(): void {
|
||||
if (!this.submenuSelect || !this.hass) {
|
||||
return;
|
||||
}
|
||||
const entity = this.submenuSelect.entity;
|
||||
const stateObj = this.hass.states[entity];
|
||||
const options = stateObj?.attributes?.options;
|
||||
if (!stateObj || !options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submenu: MenuSubmenu = {
|
||||
// Default icon. It should be impossible for this to be used, since
|
||||
// this.submenuSelect will always have an entity, which means
|
||||
// refreshDynamicStateParameters will always return an icon.
|
||||
icon: domainIcon('select'),
|
||||
|
||||
// Pull out the dynamic properties (like icon, and title) from the state.
|
||||
...refreshDynamicStateParameters(this.hass, this.submenuSelect),
|
||||
|
||||
// Override it with anything explicitly set in the submenuSelect.
|
||||
...this.submenuSelect,
|
||||
|
||||
type: 'custom:frigate-card-menu-submenu',
|
||||
items: [],
|
||||
};
|
||||
|
||||
// For cleanliness remove the options parameter which is unused by the
|
||||
// submenu rendering itself (above). It is only in this method to populate
|
||||
// the items correctly (below).
|
||||
delete submenu['options'];
|
||||
|
||||
for (const option of options) {
|
||||
// If there's a device_class there may be a localized translation of the
|
||||
// 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({
|
||||
state_color: true,
|
||||
selected: stateObj.state === option,
|
||||
enabled: true,
|
||||
title: title || option,
|
||||
...((entity.startsWith('select.') || entity.startsWith('input_select.')) && {
|
||||
tap_action: {
|
||||
action: 'call-service',
|
||||
service: entity.startsWith('select.')
|
||||
? 'select.select_option'
|
||||
: 'input_select.select_option',
|
||||
service_data: {
|
||||
entity_id: entity,
|
||||
option: option,
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Apply overrides the user may have specified for a given option.
|
||||
...(this.submenuSelect.options && this.submenuSelect.options[option]),
|
||||
});
|
||||
}
|
||||
|
||||
this._generatedSubmenu = submenu;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html` <frigate-card-submenu
|
||||
.hass=${this.hass}
|
||||
.submenu=${this._generatedSubmenu}
|
||||
></frigate-card-submenu>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-submenu': FrigateCardSubmenu;
|
||||
'frigate-card-submenu-select': FrigateCardSubmenuSelect;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
import { FrigateCardDrawer } from './drawer.js';
|
||||
|
||||
import './drawer.js';
|
||||
|
||||
import surroundStyle from '../scss/surround.scss';
|
||||
|
||||
interface FrigateCardDrawerOpen {
|
||||
drawer: 'left' | 'right';
|
||||
}
|
||||
|
||||
@customElement('frigate-card-surround')
|
||||
export class FrigateCardSurround extends LitElement {
|
||||
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">
|
||||
<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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(surroundStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"frigate-card-surround": FrigateCardSurround
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,113 @@
|
||||
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||
import { CSSResultGroup, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { EmblaOptionsType } from 'embla-carousel';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
import type { BrowseMediaSource, ThumbnailsControlConfig } from '../types.js';
|
||||
import { FrigateCardCarousel } from './carousel.js';
|
||||
import { dispatchFrigateCardEvent, stopEventFromActivatingCardWideActions } from '../common.js';
|
||||
|
||||
import { EmblaOptionsType, EmblaPluginType } 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 { classMap } from 'lit/directives/class-map.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
ThumbnailsControlConfig,
|
||||
} from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { isTrueMedia } from '../utils/ha/browse-media';
|
||||
import { View } from '../view.js';
|
||||
import { FrigateCardCarousel } from './carousel.js';
|
||||
import './thumbnail.js';
|
||||
import './carousel.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
|
||||
export interface ThumbnailCarouselTap {
|
||||
slideIndex: number;
|
||||
target: BrowseMediaSource;
|
||||
target: FrigateBrowseMediaSource;
|
||||
childIndex: number;
|
||||
}
|
||||
|
||||
@customElement('frigate-card-thumbnail-carousel')
|
||||
export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
||||
export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected target?: BrowseMediaSource;
|
||||
|
||||
protected _tapSelected?;
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
set config(config: ThumbnailsControlConfig | undefined) {
|
||||
if (config) {
|
||||
if (config && config.size !== undefined && config.size !== null) {
|
||||
this.style.setProperty('--frigate-card-carousel-thumbnail-size', config.size);
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
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 })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
|
||||
|
||||
// Thumbnail carousels can expand (e.g. drawer-based carousels after the main
|
||||
// media loads). The carousel must be re-initialized in these cases, or the
|
||||
// dynamic sizing fails (and users can scroll past the end of the carousel).
|
||||
protected _resizeObserver: ResizeObserver;
|
||||
|
||||
@property({ attribute: false })
|
||||
public config?: ThumbnailsControlConfig;
|
||||
|
||||
@state()
|
||||
protected _selected: number | null = null;
|
||||
|
||||
protected _carouselOptions?: EmblaOptionsType;
|
||||
protected _carouselPlugins: EmblaPluginType[] = [
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
// gestures as scrolling for the carousel.
|
||||
forceWheelAxis: 'y',
|
||||
}),
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
|
||||
}
|
||||
protected _config?: ThumbnailsControlConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
set highlightSelected(value: boolean) {
|
||||
set selected(selected: number | null) {
|
||||
this._selected = selected;
|
||||
this.style.setProperty(
|
||||
'--frigate-card-carousel-thumbnail-opacity',
|
||||
value ? '0.6' : '1.0',
|
||||
selected === null ? '1.0' : '0.4',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle gallery resize.
|
||||
*/
|
||||
protected _resizeHandler(): void {
|
||||
this._refCarousel.value?.carouselReInitWhenSafe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
this._resizeObserver.disconnect();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla options to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
@@ -49,28 +116,9 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
||||
return {
|
||||
containScroll: 'keepSnaps',
|
||||
dragFree: true,
|
||||
startIndex: this._selected ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a particular slide.
|
||||
* @param index Slide number.
|
||||
*/
|
||||
carouselScrollTo(index: number): void {
|
||||
if (!this._carousel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._tapSelected !== undefined) {
|
||||
this._carousel.slideNodes()[this._tapSelected].classList.remove('slide-selected');
|
||||
}
|
||||
|
||||
super.carouselScrollTo(index);
|
||||
|
||||
this._carousel.slideNodes()[index].classList.add('slide-selected');
|
||||
this._tapSelected = index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slides to include in the render.
|
||||
* @returns The slides to include in the render.
|
||||
@@ -90,44 +138,111 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
||||
return slides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an update will occur.
|
||||
* @param changedProps The changed properties
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('config')) {
|
||||
if (this.config?.size) {
|
||||
this.style.setProperty('--frigate-card-thumbnail-size', `${this.config.size}px`);
|
||||
}
|
||||
const direction = this._getDirection();
|
||||
if (direction) {
|
||||
this.setAttribute('direction', direction);
|
||||
} else {
|
||||
this.removeAttribute('direction');
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._carouselOptions) {
|
||||
// Want to set the initial carousel options just before the first render
|
||||
// in order to get the startIndex correct in the options. It is not safe
|
||||
// to rely on carouselScrollTo() post update, since the nested carousel
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a given thumbnail.
|
||||
* @param mediaToRender The media item to render.
|
||||
* @returns A template or void if the item could not be rendered.
|
||||
*/
|
||||
protected _renderThumbnail(
|
||||
parent: BrowseMediaSource,
|
||||
parent: FrigateBrowseMediaSource,
|
||||
childIndex: number,
|
||||
slideIndex: number,
|
||||
): TemplateResult | void {
|
||||
if (!parent.children || !parent.children.length) {
|
||||
if (
|
||||
!parent.children ||
|
||||
!parent.children.length ||
|
||||
!isTrueMedia(parent.children[childIndex])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaToRender = parent.children[childIndex];
|
||||
if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) {
|
||||
return;
|
||||
}
|
||||
const classes = {
|
||||
embla__slide: true,
|
||||
'slide-selected': this._selected === childIndex,
|
||||
};
|
||||
|
||||
return html`<div
|
||||
class="embla__slide"
|
||||
const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null;
|
||||
return html` <frigate-card-thumbnail
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.target=${parent}
|
||||
.childIndex=${childIndex}
|
||||
.clientID=${cameraConfig?.frigate.client_id}
|
||||
?details=${this.config?.show_details}
|
||||
?show_favorite_control=${this.config?.show_favorite_control}
|
||||
?show_timeline_control=${this.config?.show_timeline_control}
|
||||
class="${classMap(classes)}"
|
||||
@click=${(ev) => {
|
||||
if (this._carousel && this._carousel.clickAllowed()) {
|
||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(this, 'carousel:tap', {
|
||||
slideIndex: slideIndex,
|
||||
target: parent,
|
||||
childIndex: childIndex,
|
||||
});
|
||||
if (this._refCarousel.value?.carouselClickAllowed()) {
|
||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
|
||||
this,
|
||||
'thumbnail-carousel:tap',
|
||||
{
|
||||
slideIndex: slideIndex,
|
||||
target: parent,
|
||||
childIndex: childIndex,
|
||||
},
|
||||
);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
aria-label="${mediaToRender.title}"
|
||||
src="${mediaToRender.thumbnail}"
|
||||
title="${mediaToRender.title}"
|
||||
/>
|
||||
</div>`;
|
||||
</frigate-card-thumbnail>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the direction of the thumbnail carousel.
|
||||
* @returns `vertical`, `horizontal` or undefined.
|
||||
*/
|
||||
protected _getDirection(): 'horizontal' | 'vertical' | undefined {
|
||||
if (this.config?.mode === 'left' || this.config?.mode === 'right') {
|
||||
return 'vertical';
|
||||
} else if (this.config?.mode === 'above' || this.config?.mode === 'below') {
|
||||
return 'horizontal';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,21 +251,30 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
const slides = this._getSlides();
|
||||
if (!slides || !this._config || this._config.mode == 'none') {
|
||||
if (!slides.length || !this.config || this.config.mode === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
return html` <div class="embla">
|
||||
<div class="embla__viewport">
|
||||
<div class="embla__container">${slides}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
return html`<frigate-card-carousel
|
||||
${ref(this._refCarousel)}
|
||||
direction=${ifDefined(this._getDirection())}
|
||||
.carouselOptions=${this._carouselOptions}
|
||||
.carouselPlugins=${this._carouselPlugins}
|
||||
>
|
||||
${slides}
|
||||
</frigate-card-carousel>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return [super.styles, unsafeCSS(thumbnailCarouselStyle)];
|
||||
return unsafeCSS(thumbnailCarouselStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-thumbnail-carousel': FrigateCardThumbnailCarousel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { format, fromUnixTime } from 'date-fns';
|
||||
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
|
||||
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
|
||||
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
|
||||
import thumbnailStyle from '../scss/thumbnail.scss';
|
||||
import type {
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
FrigateEvent,
|
||||
FrigateRecording,
|
||||
} from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { errorToConsole, prettifyTitle } from '../utils/basic.js';
|
||||
import { retainEvent } from '../utils/frigate.js';
|
||||
import { getEventDurationString } from '../utils/ha/browse-media.js';
|
||||
import { renderTask } from '../utils/task.js';
|
||||
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
||||
import { View } from '../view.js';
|
||||
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
@customElement('frigate-card-thumbnail-feature-event')
|
||||
export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public thumbnail?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
protected _embedThumbnailTask = createFetchThumbnailTask(
|
||||
this,
|
||||
() => this.hass,
|
||||
() => this.thumbnail,
|
||||
);
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
return html`
|
||||
${this.thumbnail
|
||||
? renderTask(
|
||||
this,
|
||||
this._embedThumbnailTask,
|
||||
(embeddedThumbnail: string | null) =>
|
||||
embeddedThumbnail
|
||||
? html`<img src="${embeddedThumbnail}" />`
|
||||
: html``
|
||||
)
|
||||
: html`<ha-icon
|
||||
icon="mdi:image-off"
|
||||
title=${localize('thumbnail.no_thumbnail')}
|
||||
></ha-icon> `}
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResult {
|
||||
return unsafeCSS(thumbnailFeatureEventStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-thumbnail-feature-recording')
|
||||
export class FrigateCardThumbnailFeatureRecording extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public date?: Date;
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.date) {
|
||||
return;
|
||||
}
|
||||
return html`
|
||||
<div class="title">${format(this.date, 'HH:mm')}</div>
|
||||
<div class="subtitle">${format(this.date, 'MMM do')}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResult {
|
||||
return unsafeCSS(thumbnailFeatureRecordingStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-thumbnail-details-event')
|
||||
export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public event?: FrigateEvent;
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.event) {
|
||||
return;
|
||||
}
|
||||
const score = (this.event.top_score * 100).toFixed(2) + '%';
|
||||
return html`<div class="left">
|
||||
<div class="larger">${prettifyTitle(this.event.label)}</div>
|
||||
<div>
|
||||
<span class="heading">${localize('event.start')}:</span>
|
||||
<span>${format(fromUnixTime(this.event.start_time), 'HH:mm:ss')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="heading">${localize('event.duration')}:</span>
|
||||
<span>${getEventDurationString(this.event)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<span class="larger">${score}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResult {
|
||||
return unsafeCSS(thumbnailDetailsStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-thumbnail-details-recording')
|
||||
export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public recording?: FrigateRecording;
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.recording) {
|
||||
return;
|
||||
}
|
||||
return html`<div class="left">
|
||||
<div class="larger">${prettifyTitle(this.recording.camera) || ''}</div>
|
||||
${this.recording.seek_time
|
||||
? html` <div>
|
||||
<span class="heading">${localize('recording.seek')}</span>
|
||||
<span>${format(fromUnixTime(this.recording.seek_time), 'HH:mm:ss')}</span>
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
<div class="right">
|
||||
<span class="larger">${this.recording.events}</span>
|
||||
<span>${localize('recording.events')}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResult {
|
||||
return unsafeCSS(thumbnailDetailsStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-thumbnail')
|
||||
export class FrigateCardThumbnail extends LitElement {
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public details = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_favorite_control = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_timeline_control = false;
|
||||
|
||||
// ======================
|
||||
// Target-based interface
|
||||
// ======================
|
||||
@property({ attribute: false })
|
||||
public target?: FrigateBrowseMediaSource | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public childIndex?: number;
|
||||
|
||||
// ===================================================
|
||||
// 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 })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public clientID?: string;
|
||||
|
||||
/**
|
||||
* Render the element.
|
||||
* @returns A template to display to the user.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
let event: FrigateEvent | null = null;
|
||||
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;
|
||||
}
|
||||
|
||||
const starClasses = {
|
||||
star: true,
|
||||
starred: !!event?.retain_indefinitely,
|
||||
};
|
||||
|
||||
return html` ${event
|
||||
? html`<frigate-card-thumbnail-feature-event
|
||||
aria-label="${label ?? ''}"
|
||||
title="${label ?? ''}"
|
||||
.hass=${this.hass}
|
||||
.thumbnail=${thumbnail ?? undefined}
|
||||
.label=${label ?? undefined}
|
||||
></frigate-card-thumbnail-feature-event>`
|
||||
: html`<frigate-card-thumbnail-feature-recording
|
||||
aria-label="${label ?? ''}"
|
||||
title="${label ?? ''}"
|
||||
.date=${recording ? fromUnixTime(recording.start_time) : undefined}
|
||||
></frigate-card-thumbnail-feature-recording>`}
|
||||
${this.show_favorite_control && event && this.hass && this.clientID
|
||||
? html` <ha-icon
|
||||
class="${classMap(starClasses)}"
|
||||
icon=${event?.retain_indefinitely ? 'mdi:star' : 'mdi:star-outline'}
|
||||
title=${localize('thumbnail.retain_indefinitely')}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (event && this.hass && this.clientID) {
|
||||
retainEvent(
|
||||
this.hass,
|
||||
this.clientID,
|
||||
event.id,
|
||||
!event.retain_indefinitely,
|
||||
)
|
||||
.then(() => {
|
||||
if (event) {
|
||||
event.retain_indefinitely = !event.retain_indefinitely;
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
errorToConsole(e);
|
||||
});
|
||||
}
|
||||
}}
|
||||
/></ha-icon>`
|
||||
: ``}
|
||||
${this.details && event
|
||||
? html`<frigate-card-thumbnail-details-event
|
||||
.event=${event ?? undefined}
|
||||
></frigate-card-thumbnail-details-event>`
|
||||
: this.details && recording
|
||||
? html`<frigate-card-thumbnail-details-recording
|
||||
.recording=${recording ?? undefined}
|
||||
></frigate-card-thumbnail-details-recording>`
|
||||
: html``}
|
||||
${this.show_timeline_control
|
||||
? html`<ha-icon
|
||||
class="timeline"
|
||||
icon="mdi:target"
|
||||
title=${localize('thumbnail.timeline')}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (event) {
|
||||
this.view
|
||||
?.evolve({
|
||||
view: 'timeline',
|
||||
target: this.target,
|
||||
childIndex: this.childIndex ?? null,
|
||||
})
|
||||
.removeContext('timeline')
|
||||
.dispatchChangeEvent(this);
|
||||
} else if (recording) {
|
||||
this.view
|
||||
?.evolve({
|
||||
view: 'timeline',
|
||||
target: null,
|
||||
childIndex: null,
|
||||
})
|
||||
.mergeInContext({
|
||||
timeline: {
|
||||
window: {
|
||||
start: fromUnixTime(recording.start_time),
|
||||
end: fromUnixTime(recording.end_time),
|
||||
},
|
||||
},
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
}}
|
||||
></ha-icon>`
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResult {
|
||||
return unsafeCSS(thumbnailStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-thumbnail': FrigateCardThumbnail;
|
||||
'frigate-card-thumbnail-details-recording': FrigateCardThumbnailDetailsRecording;
|
||||
'frigate-card-thumbnail-details-event': FrigateCardThumbnailDetailsEvent;
|
||||
'frigate-card-thumbnail-feature-recording': FrigateCardThumbnailFeatureRecording;
|
||||
'frigate-card-thumbnail-feature-event': FrigateCardThumbnailFeatureEvent;
|
||||
}
|
||||
}
|
||||
@@ -83,3 +83,9 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
return unsafeCSS(titleStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"frigate-card-title-control": FrigateCardTitleControl
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +1,131 @@
|
||||
import { Task } from '@lit-labs/task';
|
||||
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
html,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { Task } from '@lit-labs/task';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
|
||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||
import type {
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { renderProgressIndicator } from '../components/message.js';
|
||||
import viewerStyle from '../scss/viewer.scss';
|
||||
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
||||
import {
|
||||
BrowseMediaNeighbors,
|
||||
BrowseMediaQueryParameters,
|
||||
BrowseMediaSource,
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
MediaShowInfo,
|
||||
FrigateBrowseMediaSource,
|
||||
frigateCardConfigDefaults,
|
||||
FrigateCardMediaPlayer,
|
||||
MediaLoadedInfo,
|
||||
TransitionEffect,
|
||||
ViewerConfig,
|
||||
} from '../types.js';
|
||||
import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js';
|
||||
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { contentsChanged } from '../utils/basic.js';
|
||||
import {
|
||||
FrigateCardThumbnailCarousel,
|
||||
ThumbnailCarouselTap,
|
||||
} from './thumbnail-carousel.js';
|
||||
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
|
||||
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
|
||||
fetchLatestMediaAndDispatchViewChange,
|
||||
getEventStartTime,
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError,
|
||||
isTrueMedia,
|
||||
multipleBrowseMediaQueryMerged,
|
||||
overrideMultiBrowseMediaQueryParameters,
|
||||
} from '../utils/ha/browse-media.js';
|
||||
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||
import { View } from '../view.js';
|
||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||
import { Lazyload } from './embla-plugins/lazyload.js';
|
||||
import {
|
||||
contentsChanged,
|
||||
createMediaShowInfo,
|
||||
dispatchErrorMessageEvent,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../common.js';
|
||||
import { renderProgressIndicator } from '../components/message.js';
|
||||
|
||||
FrigateCardMediaCarousel,
|
||||
IMG_EMPTY,
|
||||
wrapRawMediaLoadedEventForCarousel,
|
||||
wrapMediaLoadedEventForCarousel,
|
||||
} from './media-carousel.js';
|
||||
import './next-prev-control.js';
|
||||
import './title-control.js';
|
||||
|
||||
import viewerStyle from '../scss/viewer.scss';
|
||||
import viewerCoreStyle from '../scss/viewer-core.scss';
|
||||
import '../patches/ha-hls-player';
|
||||
import './surround-thumbnails';
|
||||
import { EmblaCarouselPlugins } from './carousel.js';
|
||||
import { renderTask } from '../utils/task.js';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||
|
||||
@customElement('frigate-card-viewer')
|
||||
export class FrigateCardViewer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected viewerConfig?: ViewerConfig;
|
||||
public viewerConfig?: ViewerConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected cameraConfig?: CameraConfig;
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected resolvedMediaCache?: ResolvedMediaCache;
|
||||
public resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view || !this.cameraConfig) {
|
||||
if (!this.hass || !this.view || !this.cameras || !this.viewerConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const browseMediaQueryParameters =
|
||||
BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError(
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError(
|
||||
this,
|
||||
this.view,
|
||||
this.cameraConfig,
|
||||
this.hass,
|
||||
this.cameras,
|
||||
this.view.camera,
|
||||
);
|
||||
if (!browseMediaQueryParameters) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.view.target) {
|
||||
BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange(
|
||||
// If the target is not specified, the view must tell us which mediaType
|
||||
// to search for. When the target *is* specified, the view is not required
|
||||
// to indicate the media type (e.g. the mixed 'events' view from the
|
||||
// timeline).
|
||||
const mediaType = this.view.getMediaType();
|
||||
if (!browseMediaQueryParameters || !mediaType) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchLatestMediaAndDispatchViewChange(
|
||||
this,
|
||||
this.hass,
|
||||
this.view,
|
||||
browseMediaQueryParameters,
|
||||
overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, {
|
||||
mediaType: mediaType,
|
||||
}),
|
||||
);
|
||||
return renderProgressIndicator();
|
||||
}
|
||||
|
||||
return html` <frigate-card-viewer-core
|
||||
.view=${this.view}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
return html` <frigate-card-surround-thumbnails
|
||||
.hass=${this.hass}
|
||||
.browseMediaQueryParameters=${browseMediaQueryParameters}
|
||||
.view=${this.view}
|
||||
.config=${this.viewerConfig.controls.thumbnails}
|
||||
.cameras=${this.cameras}
|
||||
>
|
||||
</frigate-card-viewer-core>`;
|
||||
<frigate-card-viewer-carousel
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.browseMediaQueryParameters=${browseMediaQueryParameters}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
>
|
||||
</frigate-card-viewer-carousel>
|
||||
</frigate-card-surround-thumbnails>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,89 +136,15 @@ export class FrigateCardViewer extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-viewer-core')
|
||||
export class FrigateCardViewerCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
|
||||
// See note on viewerConfig in <frigate-card-viewer-carousel>.
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
protected viewerConfig?: ViewerConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
protected _viewerCarouselRef: Ref<FrigateCardViewerCarousel> = createRef();
|
||||
protected _thumbnailCarouselRef: Ref<FrigateCardThumbnailCarousel> = createRef();
|
||||
|
||||
protected _syncThumbnailCarousel(): void {
|
||||
const mediaSelected = this._viewerCarouselRef.value?.carouselSelected();
|
||||
if (mediaSelected !== undefined) {
|
||||
this._thumbnailCarouselRef.value?.carouselScrollTo(mediaSelected);
|
||||
}
|
||||
}
|
||||
|
||||
protected _renderThumbnails(): TemplateResult {
|
||||
if (!this.view || !this.viewerConfig) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html` <frigate-card-thumbnail-carousel
|
||||
${ref(this._thumbnailCarouselRef)}
|
||||
.target=${this.view.target}
|
||||
.config=${this.viewerConfig.controls.thumbnails}
|
||||
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
|
||||
this._viewerCarouselRef.value?.carouselScrollTo(ev.detail.slideIndex);
|
||||
}}
|
||||
@frigate-card:carousel:init=${this._syncThumbnailCarousel.bind(this)}
|
||||
>
|
||||
</frigate-card-thumbnail-carousel>`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.view || !this.viewerConfig) {
|
||||
return html``;
|
||||
}
|
||||
return html` ${this.viewerConfig &&
|
||||
this.viewerConfig.controls.thumbnails.mode === 'above'
|
||||
? this._renderThumbnails()
|
||||
: ''}
|
||||
<frigate-card-viewer-carousel
|
||||
${ref(this._viewerCarouselRef)}
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.browseMediaQueryParameters=${this.browseMediaQueryParameters}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
@frigate-card:carousel:select=${this._syncThumbnailCarousel.bind(this)}
|
||||
>
|
||||
</frigate-card-viewer-carousel>
|
||||
${this.viewerConfig && this.viewerConfig.controls.thumbnails.mode === 'below'
|
||||
? this._renderThumbnails()
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(viewerCoreStyle);
|
||||
}
|
||||
}
|
||||
const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player';
|
||||
|
||||
@customElement('frigate-card-viewer-carousel')
|
||||
export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
export class FrigateCardViewerCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
public view?: Readonly<View>;
|
||||
|
||||
// Resetting the viewer configuration causes a full reset so ensure the config
|
||||
// has actually changed with a full comparison (dynamic configuration
|
||||
@@ -202,22 +152,27 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
// could lead to the address of the viewerConfig changing without it being
|
||||
// semantically different).
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
protected viewerConfig?: ViewerConfig;
|
||||
public viewerConfig?: ViewerConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
|
||||
public browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected resolvedMediaCache?: ResolvedMediaCache;
|
||||
public resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
// Mapping of slide # to BrowseMediaSource child #.
|
||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||
|
||||
// Mapping of slide # to FrigateBrowseMediaSource child #.
|
||||
// (Folders are not media items that can be rendered).
|
||||
protected _slideToChild: Record<number, number> = {};
|
||||
|
||||
// A task to resolve target media if lazy loading is disabled.
|
||||
protected _mediaResolutionTask = new Task<[BrowseMediaSource | undefined], void>(
|
||||
protected _mediaResolutionTask = new Task<
|
||||
[FrigateBrowseMediaSource | null | undefined],
|
||||
void
|
||||
>(
|
||||
this,
|
||||
async ([target]: (BrowseMediaSource | undefined)[]): Promise<void> => {
|
||||
async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise<void> => {
|
||||
for (
|
||||
let i = 0;
|
||||
!this.viewerConfig?.lazy_load &&
|
||||
@@ -227,12 +182,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
i < (target.children || []).length;
|
||||
++i
|
||||
) {
|
||||
if (BrowseMediaUtil.isTrueMedia(target.children[i])) {
|
||||
await ResolvedMediaUtil.resolveMedia(
|
||||
this.hass,
|
||||
target.children[i],
|
||||
this.resolvedMediaCache,
|
||||
);
|
||||
if (isTrueMedia(target.children[i])) {
|
||||
await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -244,22 +195,23 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
* @param changedProperties The properties that were changed in this render.
|
||||
*/
|
||||
updated(changedProperties: PropertyValues): void {
|
||||
if (this._carousel && changedProperties.has('viewerConfig')) {
|
||||
this._destroyCarousel();
|
||||
}
|
||||
const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel();
|
||||
|
||||
if (this._carousel && changedProperties.has('view')) {
|
||||
if (frigateCardCarousel && changedProperties.has('view')) {
|
||||
const oldView = changedProperties.get('view') as View | undefined;
|
||||
if (oldView) {
|
||||
if (oldView.target != this.view?.target) {
|
||||
// If the media target is different entirely, reset the carousel.
|
||||
this._destroyCarousel();
|
||||
} else if (this.view?.childIndex != oldView.childIndex) {
|
||||
const slide = this._getSlideForChild(this.view?.childIndex);
|
||||
if (slide !== undefined && slide !== this.carouselSelected()) {
|
||||
if (
|
||||
oldView.target === this.view?.target &&
|
||||
this.view.childIndex != oldView.childIndex
|
||||
) {
|
||||
const slide = this._getSlideForChild(this.view.childIndex);
|
||||
if (
|
||||
slide !== null &&
|
||||
slide !== frigateCardCarousel.getCarouselSelected()?.index
|
||||
) {
|
||||
// If the media target is the same as already loaded, but isn't of
|
||||
// the selected slide, scroll to that slide.
|
||||
this.carouselScrollTo(slide);
|
||||
frigateCardCarousel.carouselScrollTo(slide);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,47 +221,29 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the media on the selected slide.
|
||||
* Get the slide number given a media child number.
|
||||
* @param childIndex The child index (relative to `view.target`)
|
||||
* @returns A number or null if the child is not found.
|
||||
*/
|
||||
protected _autoPlayHandler(): void {
|
||||
if (this.viewerConfig?.auto_play) {
|
||||
super._autoPlayHandler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the media on the selected slide.
|
||||
*/
|
||||
protected _autoUnmuteHandler(): void {
|
||||
if (this.viewerConfig?.auto_unmute) {
|
||||
super._autoUnmuteHandler();
|
||||
}
|
||||
}
|
||||
|
||||
protected _destroyCarousel(): void {
|
||||
super._destroyCarousel();
|
||||
|
||||
// Notes on instance variables:
|
||||
// * this._slideToChild: This is set as part of each render and does not
|
||||
// need to be destroyed here.
|
||||
}
|
||||
|
||||
protected _getSlideForChild(childIndex: number | undefined): number | undefined {
|
||||
if (childIndex === undefined) {
|
||||
return undefined;
|
||||
protected _getSlideForChild(childIndex: number | null | undefined): number | null {
|
||||
if (childIndex === undefined || childIndex === null) {
|
||||
return null;
|
||||
}
|
||||
const slideIndex = Object.keys(this._slideToChild).find(
|
||||
(key) => this._slideToChild[key] === childIndex,
|
||||
);
|
||||
return slideIndex !== undefined ? Number(slideIndex) : undefined;
|
||||
return slideIndex !== undefined ? Number(slideIndex) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the transition effect to use.
|
||||
* @returns An TransitionEffect object.
|
||||
*/
|
||||
protected _getTransitionEffect(): TransitionEffect | undefined {
|
||||
return this.viewerConfig?.transition_effect;
|
||||
protected _getTransitionEffect(): TransitionEffect {
|
||||
return (
|
||||
this.viewerConfig?.transition_effect ??
|
||||
frigateCardConfigDefaults.media_viewer.transition_effect
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,32 +253,67 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
protected _getOptions(): EmblaOptionsType {
|
||||
return {
|
||||
// Start the carousel on the selected child number.
|
||||
startIndex: this._getSlideForChild(this.view?.childIndex),
|
||||
draggable: this.viewerConfig?.draggable,
|
||||
startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0,
|
||||
draggable: this.viewerConfig?.draggable ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla plugins to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
* The the HLS player on a slide (or current slide if not provided.)
|
||||
* @param slide An optional slide.
|
||||
* @returns The FrigateCardMediaPlayer or null if not found.
|
||||
*/
|
||||
protected _getPlugins(): EmblaPluginType[] | undefined {
|
||||
protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null {
|
||||
if (!slide) {
|
||||
slide = this._refMediaCarousel.value
|
||||
?.frigateCardCarousel()
|
||||
?.getCarouselSelected()?.element;
|
||||
}
|
||||
|
||||
return (
|
||||
(slide?.querySelector(FRIGATE_CARD_HLS_SELECTOR) as FrigateCardMediaPlayer) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla plugins to use.
|
||||
* @returns A list of EmblaOptionsTypes.
|
||||
*/
|
||||
protected _getPlugins(): EmblaPluginType[] {
|
||||
return [
|
||||
Lazyload({
|
||||
lazyloadCallback: this.viewerConfig?.lazy_load
|
||||
? this._lazyloadSlide.bind(this)
|
||||
: undefined,
|
||||
}),
|
||||
// Don't need autoplay/pause for snapshots.
|
||||
...(this.view?.is('clip')
|
||||
// Only enable wheel plugin if there is more than one media item.
|
||||
...(this.view &&
|
||||
this.view.target &&
|
||||
this.view.target.children &&
|
||||
this.view.target.children.length > 1
|
||||
? [
|
||||
AutoMediaPlugin({
|
||||
playerSelector: 'frigate-card-ha-hls-player',
|
||||
autoPlayWhenVisible: !!this.viewerConfig?.auto_play,
|
||||
autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute,
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
// gestures as scrolling for the carousel.
|
||||
forceWheelAxis: 'y',
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
Lazyload({
|
||||
...(this.viewerConfig?.lazy_load && {
|
||||
lazyLoadCallback: this._lazyloadSlide.bind(this),
|
||||
}),
|
||||
}),
|
||||
AutoMediaPlugin({
|
||||
playerSelector: FRIGATE_CARD_HLS_SELECTOR,
|
||||
...(this.viewerConfig?.auto_play && {
|
||||
autoPlayCondition: this.viewerConfig.auto_play,
|
||||
}),
|
||||
...(this.viewerConfig?.auto_pause && {
|
||||
autoPauseCondition: this.viewerConfig.auto_pause,
|
||||
}),
|
||||
...(this.viewerConfig?.auto_mute && {
|
||||
autoMuteCondition: this.viewerConfig.auto_mute,
|
||||
}),
|
||||
...(this.viewerConfig?.auto_unmute && {
|
||||
autoUnmuteCondition: this.viewerConfig.auto_unmute,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -358,7 +327,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
this.view.childIndex === undefined
|
||||
this.view.childIndex === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -367,7 +336,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
let prevIndex: number | null = null;
|
||||
for (let i = this.view.childIndex - 1; i >= 0; i--) {
|
||||
const media = this.view.target.children[i];
|
||||
if (media && BrowseMediaUtil.isTrueMedia(media)) {
|
||||
if (media && isTrueMedia(media)) {
|
||||
prevIndex = i;
|
||||
break;
|
||||
}
|
||||
@@ -377,7 +346,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
let nextIndex: number | null = null;
|
||||
for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
|
||||
const media = this.view.target.children[i];
|
||||
if (media && BrowseMediaUtil.isTrueMedia(media)) {
|
||||
if (media && isTrueMedia(media)) {
|
||||
nextIndex = i;
|
||||
break;
|
||||
}
|
||||
@@ -398,7 +367,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
* @returns The view that would show the matching clip.
|
||||
*/
|
||||
protected async _findRelatedClipView(
|
||||
snapshot: BrowseMediaSource,
|
||||
snapshot: FrigateBrowseMediaSource,
|
||||
): Promise<View | null> {
|
||||
if (
|
||||
!this.hass ||
|
||||
@@ -411,7 +380,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapshotStartTime = BrowseMediaUtil.extractEventStartTime(snapshot);
|
||||
const snapshotStartTime = getEventStartTime(snapshot);
|
||||
if (!snapshotStartTime) {
|
||||
return null;
|
||||
}
|
||||
@@ -429,10 +398,10 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
let latest: number | null = null;
|
||||
for (let i = 0; i < this.view.target.children.length; i++) {
|
||||
const child = this.view.target.children[i];
|
||||
if (!BrowseMediaUtil.isTrueMedia(child)) {
|
||||
if (!isTrueMedia(child)) {
|
||||
continue;
|
||||
}
|
||||
const startTime = BrowseMediaUtil.extractEventStartTime(child);
|
||||
const startTime = getEventStartTime(child);
|
||||
|
||||
if (startTime && (earliest === null || startTime < earliest)) {
|
||||
earliest = startTime;
|
||||
@@ -445,15 +414,19 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
return null;
|
||||
}
|
||||
|
||||
let clips: BrowseMediaSource | null;
|
||||
let clips: FrigateBrowseMediaSource | null;
|
||||
|
||||
try {
|
||||
clips = await BrowseMediaUtil.browseMediaQuery(this.hass, {
|
||||
...this.browseMediaQueryParameters,
|
||||
const params = overrideMultiBrowseMediaQueryParameters(
|
||||
this.browseMediaQueryParameters,
|
||||
{
|
||||
mediaType: 'clips',
|
||||
before: latest,
|
||||
after: earliest,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
clips = await multipleBrowseMediaQueryMerged(this.hass, params);
|
||||
} catch (e) {
|
||||
// This is best effort.
|
||||
return null;
|
||||
@@ -465,17 +438,15 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
|
||||
for (let i = 0; i < clips.children.length; i++) {
|
||||
const child = clips.children[i];
|
||||
if (!BrowseMediaUtil.isTrueMedia(child)) {
|
||||
if (!isTrueMedia(child)) {
|
||||
continue;
|
||||
}
|
||||
const clipStartTime = BrowseMediaUtil.extractEventStartTime(child);
|
||||
const clipStartTime = getEventStartTime(child);
|
||||
if (clipStartTime && clipStartTime === snapshotStartTime) {
|
||||
return new View({
|
||||
return this.view.evolve({
|
||||
view: 'clip',
|
||||
camera: this.view.camera,
|
||||
target: clips,
|
||||
childIndex: i,
|
||||
previous: this.view,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -485,26 +456,39 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
/**
|
||||
* Handle the user selecting a new slide in the carousel.
|
||||
*/
|
||||
protected _selectSlideSetViewHandler(): void {
|
||||
if (!this._carousel || !this.view) {
|
||||
protected _setViewHandler(): void {
|
||||
if (!this._refMediaCarousel.value || !this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the childIndex in the view.
|
||||
const slidesInView = this._carousel.slidesInView(true);
|
||||
if (slidesInView.length) {
|
||||
const childIndex = this._slideToChild[slidesInView[0]];
|
||||
const selected = this._refMediaCarousel.value
|
||||
.frigateCardCarousel()
|
||||
?.getCarouselSelected()?.index;
|
||||
if (selected !== undefined) {
|
||||
const childIndex = this._slideToChild[selected];
|
||||
if (childIndex !== undefined) {
|
||||
this.view
|
||||
.evolve({
|
||||
childIndex: childIndex,
|
||||
previous: this.view,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure media URLs use the correct HA URL (relevant for Chromecast where the
|
||||
* default location will be the Chromecast receiver, not HA).
|
||||
* @param url The media URL
|
||||
*/
|
||||
protected _canonicalizeHAURL(url?: string): string | undefined {
|
||||
if (this.hass && url && url.startsWith('/')) {
|
||||
return this.hass.hassUrl(url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy load a slide.
|
||||
* @param index The index of the slide to lazy load.
|
||||
@@ -515,17 +499,17 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
const childIndex: number | undefined = this._slideToChild[index];
|
||||
|
||||
if (
|
||||
childIndex == undefined ||
|
||||
childIndex === undefined ||
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
!BrowseMediaUtil.isTrueMedia(this.view.target.children[childIndex])
|
||||
!isTrueMedia(this.view.target.children[childIndex])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
ResolvedMediaUtil.resolveMedia(
|
||||
resolveMedia(
|
||||
this.hass,
|
||||
this.view.target.children[childIndex],
|
||||
this.resolvedMediaCache,
|
||||
@@ -538,43 +522,18 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
const img = slide.querySelector('img') as HTMLImageElement;
|
||||
|
||||
// Frigate >= 0.9.0+ clips.
|
||||
const hls_player = slide.querySelector(
|
||||
'frigate-card-ha-hls-player',
|
||||
) as HTMLElement & { url: string };
|
||||
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
|
||||
url: string;
|
||||
};
|
||||
|
||||
if (img) {
|
||||
img.src = resolvedMedia.url;
|
||||
img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
|
||||
} else if (hls_player) {
|
||||
hls_player.url = resolvedMedia.url;
|
||||
hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle updating of the next/previous controls when the carousel is moved.
|
||||
*/
|
||||
protected _selectSlideNextPreviousHandler(): void {
|
||||
const updateNextPreviousControl = (
|
||||
control: FrigateCardNextPreviousControl,
|
||||
direction: 'previous' | 'next',
|
||||
): void => {
|
||||
const neighbors = this._getMediaNeighbors();
|
||||
const [prev, next] = [neighbors?.previous, neighbors?.next];
|
||||
const target = direction == 'previous' ? prev : next;
|
||||
|
||||
control.disabled = target == null;
|
||||
control.title = target && target.title ? target.title : '';
|
||||
control.thumbnail = target && target.thumbnail ? target.thumbnail : undefined;
|
||||
};
|
||||
|
||||
if (this._previousControlRef.value) {
|
||||
updateNextPreviousControl(this._previousControlRef.value, 'previous');
|
||||
}
|
||||
if (this._nextControlRef.value) {
|
||||
updateNextPreviousControl(this._nextControlRef.value, 'next');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slides to include in the render.
|
||||
* @returns The slides to include in the render and an index keyed by slide
|
||||
@@ -615,6 +574,16 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an update will occur.
|
||||
* @param changedProps The changed properties
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('viewerConfig')) {
|
||||
updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the element, resolving the media first if necessary.
|
||||
*/
|
||||
@@ -624,12 +593,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
// If lazy loading is not enabled, wait for the media resolver task to
|
||||
// complete and show a progress indictator until this.
|
||||
if (!this.viewerConfig?.lazy_load && !this._isMediaFullyResolved()) {
|
||||
return html`${this._mediaResolutionTask.render({
|
||||
initial: () => renderProgressIndicator(),
|
||||
pending: () => renderProgressIndicator(),
|
||||
error: (e: unknown) => dispatchErrorMessageEvent(this, (e as Error).message),
|
||||
complete: () => this._render(),
|
||||
})}`;
|
||||
return renderTask(this, this._mediaResolutionTask, this._render.bind(this));
|
||||
}
|
||||
return this._render();
|
||||
}
|
||||
@@ -641,62 +605,93 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
protected _render(): TemplateResult | void {
|
||||
const [slides, slideToChild] = this._getSlides();
|
||||
this._slideToChild = slideToChild;
|
||||
if (!slides) {
|
||||
if (!slides.length || !this.view?.media) {
|
||||
return;
|
||||
}
|
||||
|
||||
const neighbors = this._getMediaNeighbors();
|
||||
const [prev, next] = [neighbors?.previous, neighbors?.next];
|
||||
|
||||
return html`<div class="embla">
|
||||
<frigate-card-next-previous-control
|
||||
${ref(this._previousControlRef)}
|
||||
.direction=${'previous'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${prev && prev.thumbnail ? prev.thumbnail : undefined}
|
||||
.label=${prev ? prev.title : ''}
|
||||
?disabled=${!prev}
|
||||
@click=${(ev) => {
|
||||
this._nextPreviousHandler('previous');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
<div class="embla__viewport">
|
||||
<div class="embla__container">${slides}</div>
|
||||
</div>
|
||||
<frigate-card-next-previous-control
|
||||
${ref(this._nextControlRef)}
|
||||
.direction=${'next'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${next && next.thumbnail ? next.thumbnail : undefined}
|
||||
.label=${next ? next.title : ''}
|
||||
?disabled=${!next}
|
||||
@click=${(ev) => {
|
||||
this._nextPreviousHandler('next');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
</div>
|
||||
${this.view?.media
|
||||
? html` <frigate-card-title-control
|
||||
${ref(this._titleControlRef)}
|
||||
.config=${this.viewerConfig?.controls.title}
|
||||
.text="${this.view.media.title}"
|
||||
.fitInto=${this as HTMLElement}
|
||||
>
|
||||
</frigate-card-title-control>`
|
||||
: ``} `;
|
||||
// Notes on the below:
|
||||
// - guard() is used to avoid reseting the carousel unless the
|
||||
// options/plugins actually change.
|
||||
|
||||
return html` <frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${guard([this.viewerConfig], this._getOptions.bind(this))}
|
||||
.carouselPlugins=${guard(
|
||||
[this.viewerConfig, this.view?.target?.children?.length],
|
||||
this._getPlugins.bind(this),
|
||||
) as EmblaCarouselPlugins}
|
||||
.label="${this.view.media.title}"
|
||||
.titlePopupConfig=${this.viewerConfig?.controls.title}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:media:loaded=${this._recordingSeekHandler.bind(this)}
|
||||
>
|
||||
<frigate-card-next-previous-control
|
||||
slot="previous"
|
||||
.hass=${this.hass}
|
||||
.direction=${'previous'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${prev && prev.thumbnail ? prev.thumbnail : undefined}
|
||||
.label=${prev ? prev.title : ''}
|
||||
?disabled=${!prev}
|
||||
@click=${(ev) => {
|
||||
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious();
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
${slides}
|
||||
<frigate-card-next-previous-control
|
||||
slot="next"
|
||||
.hass=${this.hass}
|
||||
.direction=${'next'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${next && next.thumbnail ? next.thumbnail : undefined}
|
||||
.label=${next ? next.title : ''}
|
||||
?disabled=${!next}
|
||||
@click=${(ev) => {
|
||||
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
</frigate-card-media-carousel>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a media show event when a slide is selected.
|
||||
*/
|
||||
protected _recordingSeekHandler(): void {
|
||||
// If this is a recording and play is desired to be started from a
|
||||
// particular point, seek to that point. Use the media off the slide itself
|
||||
// -- when the slide is changed, the media show event may be dispatched
|
||||
// before this.view has been updated to reflect the new selection.
|
||||
const player = this._getPlayer() as FrigateCardMediaPlayer & {
|
||||
media?: FrigateBrowseMediaSource;
|
||||
};
|
||||
if (player && player.media && player.media.frigate?.recording?.seek_seconds) {
|
||||
player.seek(player.media.frigate.recording.seek_seconds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single media item in the viewer carousel.
|
||||
* @param mediaToRender The FrigateBrowseMediaSource to render.
|
||||
* @param slideIndex The index of the slide to render.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected _renderMediaItem(
|
||||
mediaToRender: BrowseMediaSource,
|
||||
mediaToRender: FrigateBrowseMediaSource,
|
||||
slideIndex: number,
|
||||
): TemplateResult | void {
|
||||
// Skip folders as they cannot be rendered by this viewer.
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.viewerConfig ||
|
||||
!BrowseMediaUtil.isTrueMedia(mediaToRender)
|
||||
!isTrueMedia(mediaToRender) ||
|
||||
!['video', 'image'].includes(mediaToRender.media_content_type)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -707,9 +702,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
return;
|
||||
}
|
||||
|
||||
// The media is attached to the player as '.media' which is used in
|
||||
// `_selectSlideMediaShowHandler` (and not used by the player itself).
|
||||
return html`
|
||||
<div class="embla__slide">
|
||||
${this.view.isClipRelatedView()
|
||||
${mediaToRender.media_content_type === 'video'
|
||||
? html`<frigate-card-ha-hls-player
|
||||
allow-exoplayer
|
||||
aria-label="${mediaToRender.title}"
|
||||
@@ -718,18 +715,28 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
muted
|
||||
playsinline
|
||||
title="${mediaToRender.title}"
|
||||
url=${ifDefined(lazyLoad ? undefined : resolvedMedia?.url)}
|
||||
url=${ifDefined(
|
||||
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url),
|
||||
)}
|
||||
.media=${mediaToRender}
|
||||
.hass=${this.hass}
|
||||
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) =>
|
||||
this._mediaShowEventHandler(slideIndex, e)}
|
||||
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
|
||||
wrapMediaLoadedEventForCarousel(slideIndex, e);
|
||||
}}
|
||||
>
|
||||
</frigate-card-ha-hls-player>`
|
||||
: html`<img
|
||||
aria-label="${mediaToRender.title}"
|
||||
src=${ifDefined(lazyLoad ? IMG_EMPTY : resolvedMedia?.url)}
|
||||
src=${ifDefined(
|
||||
lazyLoad ? IMG_EMPTY : this._canonicalizeHAURL(resolvedMedia?.url),
|
||||
)}
|
||||
title="${mediaToRender.title}"
|
||||
@click=${() => {
|
||||
if (this._carousel?.clickAllowed()) {
|
||||
if (
|
||||
this._refMediaCarousel.value
|
||||
?.frigateCardCarousel()
|
||||
?.carouselClickAllowed()
|
||||
) {
|
||||
this._findRelatedClipView(mediaToRender).then((view) => {
|
||||
if (view) {
|
||||
view.dispatchChangeEvent(this);
|
||||
@@ -738,6 +745,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
}
|
||||
}}
|
||||
@load="${(e: Event) => {
|
||||
const lazyloadPlugin = this._refMediaCarousel.value
|
||||
?.frigateCardCarousel()
|
||||
?.getCarouselPlugins()?.lazyload;
|
||||
if (
|
||||
// This handler will be called on the empty image (including
|
||||
// an updated empty image that is the same dimensions large as
|
||||
@@ -745,15 +755,27 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
// images in media-carousel.ts). Here we need to only call the
|
||||
// media load handler on a 'real' load.
|
||||
!lazyLoad ||
|
||||
(this._plugins['Lazyload'] as LazyloadType | undefined)?.hasLazyloaded(
|
||||
slideIndex,
|
||||
)
|
||||
lazyloadPlugin?.hasLazyloaded(slideIndex)
|
||||
) {
|
||||
this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e));
|
||||
wrapRawMediaLoadedEventForCarousel(slideIndex, e);
|
||||
}
|
||||
}}"
|
||||
/>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(viewerCarouselStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-viewer-carousel': FrigateCardViewerCarousel;
|
||||
'frigate-card-viewer': FrigateCardViewer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
import { get, set } from 'lodash-es';
|
||||
import { cloneDeep, get, isEqual, set } from 'lodash-es';
|
||||
import {
|
||||
CONF_CAMERAS,
|
||||
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
||||
CONF_CAMERAS_ARRAY_CAMERA_NAME,
|
||||
CONF_CAMERAS_ARRAY_CLIENT_ID,
|
||||
CONF_CAMERAS_ARRAY_LABEL,
|
||||
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
|
||||
CONF_CAMERAS_ARRAY_URL,
|
||||
CONF_CAMERAS_ARRAY_ZONE,
|
||||
CONF_EVENT_VIEWER_AUTO_PLAY,
|
||||
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
CONF_IMAGE_URL,
|
||||
CONF_LIVE_AUTO_UNMUTE,
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_LIVE_LAZY_UNLOAD,
|
||||
CONF_LIVE_PRELOAD,
|
||||
CONF_LIVE_WEBRTC_CARD,
|
||||
CONF_MEDIA_VIEWER,
|
||||
CONF_MENU,
|
||||
CONF_MENU_BUTTONS_CAMERAS,
|
||||
CONF_MENU_BUTTONS_CLIPS,
|
||||
CONF_MENU_BUTTONS_DOWNLOAD,
|
||||
CONF_MENU_BUTTONS_FRIGATE,
|
||||
CONF_MENU_BUTTONS_FRIGATE_UI,
|
||||
CONF_MENU_BUTTONS_FULLSCREEN,
|
||||
CONF_MENU_BUTTONS_IMAGE,
|
||||
CONF_MENU_BUTTONS_LIVE,
|
||||
CONF_MENU_BUTTONS_SNAPSHOTS,
|
||||
CONF_MENU_BUTTON_SIZE,
|
||||
CONF_MENU_MODE,
|
||||
CONF_MENU_POSITION,
|
||||
CONF_MENU_STYLE,
|
||||
CONF_OVERRIDES,
|
||||
CONF_VIEW_DEFAULT,
|
||||
CONF_VIEW_TIMEOUT_SECONDS,
|
||||
CONF_VIEW_UPDATE_ENTITIES,
|
||||
} from './const';
|
||||
import { RawFrigateCardConfig, RawFrigateCardConfigArray } from './types';
|
||||
import {
|
||||
BUTTON_SIZE_MIN,
|
||||
RawFrigateCardConfig,
|
||||
RawFrigateCardConfigArray,
|
||||
THUMBNAIL_WIDTH_MAX,
|
||||
THUMBNAIL_WIDTH_MIN,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Set a configuration value.
|
||||
@@ -123,7 +136,7 @@ export const trimConfig = function (obj: RawFrigateCardConfig): boolean {
|
||||
* @returns A new deeply-copied configuration.
|
||||
*/
|
||||
export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardConfig {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
return cloneDeep(obj);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -131,7 +144,7 @@ export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardCo
|
||||
* @param value The value.
|
||||
* @returns `true` is the value is not an object.
|
||||
*/
|
||||
const isNotObject = function (value: unknown) {
|
||||
const isNotObject = function (value: unknown): unknown | undefined {
|
||||
return typeof value !== 'object' ? value : undefined;
|
||||
};
|
||||
|
||||
@@ -140,10 +153,62 @@ const isNotObject = function (value: unknown) {
|
||||
* @param value The value.
|
||||
* @returns A number or undefined.
|
||||
*/
|
||||
const toNumberOrIgnore = function (value: unknown) {
|
||||
const toNumberOrIgnore = function (value: unknown): number | undefined {
|
||||
return isNaN(value as number) ? undefined : Number(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a transform that will cap a numeric value.
|
||||
* @param value The value.
|
||||
* @returns A number or null.
|
||||
*/
|
||||
const createRangedTransform = function (
|
||||
transform: (value: unknown) => unknown,
|
||||
min?: number,
|
||||
max?: number,
|
||||
): (valueIn: unknown) => unknown {
|
||||
return (value: unknown): unknown => {
|
||||
let transformed = transform(value);
|
||||
if (typeof transformed !== 'number') {
|
||||
return transformed;
|
||||
}
|
||||
transformed = min ? Math.max(min, transformed as number) : transformed;
|
||||
transformed = max ? Math.min(max, transformed as number) : transformed;
|
||||
return transformed;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a value from 'XXpx' to XX (as a number).
|
||||
* @param value Incoming value.
|
||||
* @returns A number, null if the property should be deleted or undefined if it
|
||||
* should be ignored.
|
||||
*/
|
||||
const toPixelsOrDelete = function (value: unknown): number | null | undefined {
|
||||
// Ignore the value if it's a number.
|
||||
if (typeof value === 'number') {
|
||||
return undefined;
|
||||
}
|
||||
// Delete the value if it's not a string.
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
// Remove 'px' and return the number, unless it's an invalid number -- then
|
||||
// delete it.
|
||||
value = value.replace(/px$/i, '');
|
||||
return isNaN(value as number) ? null : Number(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Request a property be deleted.
|
||||
* @param _value Inbound value (not required).
|
||||
* @returns `null` to request the property be deleted.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const deleteProperty = function (_value: unknown): number | null | undefined {
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Move a property from one location to another.
|
||||
* @param obj The configuration object in which the property resides.
|
||||
@@ -156,15 +221,31 @@ export const moveConfigValue = (
|
||||
obj: RawFrigateCardConfig,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
transform?: (valueIn: unknown) => unknown,
|
||||
options?: {
|
||||
transform?: (valueIn: unknown) => unknown;
|
||||
keepOriginal?: boolean;
|
||||
},
|
||||
): boolean => {
|
||||
let value = getConfigValue(obj, oldPath);
|
||||
if (transform) {
|
||||
value = transform(value);
|
||||
const inValue = getConfigValue(obj, oldPath);
|
||||
if (inValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value !== 'undefined') {
|
||||
deleteConfigValue(obj, oldPath);
|
||||
setConfigValue(obj, newPath, value);
|
||||
const outValue = options?.transform ? options.transform(inValue) : inValue;
|
||||
if (oldPath === newPath && isEqual(inValue, outValue)) {
|
||||
return false;
|
||||
}
|
||||
if (outValue === null) {
|
||||
if (!options?.keepOriginal) {
|
||||
deleteConfigValue(obj, oldPath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (outValue !== undefined) {
|
||||
if (!options?.keepOriginal) {
|
||||
deleteConfigValue(obj, oldPath);
|
||||
}
|
||||
setConfigValue(obj, newPath, outValue);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -190,37 +271,13 @@ export const getArrayConfigPath = (path: string, index: number): string => {
|
||||
const upgradeMoveTo = function (
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
transform?: (valueIn: unknown) => unknown,
|
||||
options?: {
|
||||
transform?: (valueIn: unknown) => unknown;
|
||||
keepOriginal?: boolean;
|
||||
},
|
||||
): (obj: RawFrigateCardConfig) => boolean {
|
||||
return function (obj: RawFrigateCardConfig): boolean {
|
||||
return moveConfigValue(obj, oldPath, newPath, transform);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Upgrade a property by changing it if it is present.
|
||||
* @param path The property path.
|
||||
* @param transform A callback that transforms the old value to the new value,
|
||||
* if undefined is returned the property is removed.
|
||||
* @returns `true` if the configuration was modified.
|
||||
*/
|
||||
const upgradeChangeIfPresent = function (
|
||||
path: string,
|
||||
transform: (valueIn: unknown) => unknown,
|
||||
): (obj: RawFrigateCardConfig) => boolean {
|
||||
return function (obj: RawFrigateCardConfig): boolean {
|
||||
const oldValue = getConfigValue(obj, path);
|
||||
if (oldValue !== undefined) {
|
||||
const newValue = transform(oldValue);
|
||||
if (newValue === undefined) {
|
||||
deleteConfigValue(obj, path);
|
||||
return true;
|
||||
} else if (newValue !== oldValue) {
|
||||
setConfigValue(obj, path, newValue);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return moveConfigValue(obj, oldPath, newPath, options);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -235,20 +292,49 @@ const upgradeChangeIfPresent = function (
|
||||
const upgradeMoveToWithOverrides = function (
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
transform?: (valueIn: unknown) => unknown,
|
||||
options?: {
|
||||
transform?: (valueIn: unknown) => unknown;
|
||||
keepOriginal?: boolean;
|
||||
},
|
||||
): (obj: RawFrigateCardConfig) => boolean {
|
||||
return function (obj: RawFrigateCardConfig): boolean {
|
||||
let modified = upgradeMoveTo(oldPath, newPath, transform)(obj);
|
||||
let modified = upgradeMoveTo(oldPath, newPath, options)(obj);
|
||||
modified =
|
||||
upgradeArrayValue(
|
||||
CONF_OVERRIDES,
|
||||
upgradeMoveTo(oldPath, newPath, transform),
|
||||
upgradeMoveTo(oldPath, newPath, options),
|
||||
(obj) => obj.overrides as RawFrigateCardConfig | undefined,
|
||||
)(obj) || modified;
|
||||
return modified;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Upgrade a property in place with overrides.
|
||||
* @param path The old property path.
|
||||
* @param transform An optional transform for the value.
|
||||
* @returns A function that returns `true` if the configuration was modified.
|
||||
*/
|
||||
const upgradeWithOverrides = function (
|
||||
path: string,
|
||||
transform: (valueIn: unknown) => unknown,
|
||||
): (obj: RawFrigateCardConfig) => boolean {
|
||||
return upgradeMoveToWithOverrides(path, path, { transform: transform });
|
||||
};
|
||||
|
||||
/**
|
||||
* Upgrade a property in place without overrides.
|
||||
* @param path The old property path.
|
||||
* @param transform An optional transform for the value.
|
||||
* @returns A function that returns `true` if the configuration was modified.
|
||||
*/
|
||||
const upgrade = function (
|
||||
path: string,
|
||||
transform: (valueIn: unknown) => unknown,
|
||||
): (obj: RawFrigateCardConfig) => boolean {
|
||||
return upgradeMoveTo(path, path, { transform: transform });
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a path to an array, apply an upgrade to each object in the array.
|
||||
* @param arrayPath The path to the array to upgrade.
|
||||
@@ -279,8 +365,7 @@ const upgradeArrayValue = function (
|
||||
|
||||
/**
|
||||
* Upgrade from a singular camera model to multiple.
|
||||
* @param key A string key.
|
||||
* @returns A safe key.
|
||||
* @returns An upgrade function.
|
||||
*/
|
||||
const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) => {
|
||||
return function (obj: RawFrigateCardConfig): boolean {
|
||||
@@ -294,11 +379,11 @@ const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) =>
|
||||
|
||||
const imports = {
|
||||
camera_entity: CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
||||
'frigate.camera_name': CONF_CAMERAS_ARRAY_CAMERA_NAME,
|
||||
'frigate.client_id': CONF_CAMERAS_ARRAY_CLIENT_ID,
|
||||
'frigate.label': CONF_CAMERAS_ARRAY_LABEL,
|
||||
'frigate.url': CONF_CAMERAS_ARRAY_URL,
|
||||
'frigate.zone': CONF_CAMERAS_ARRAY_ZONE,
|
||||
'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,
|
||||
@@ -311,6 +396,70 @@ const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) =>
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Upgrade from a menu-mode to a style & position.
|
||||
* @returns An upgrade function.
|
||||
*/
|
||||
const upgradeMenuModeToStyleAndPosition = (): ((
|
||||
obj: RawFrigateCardConfig,
|
||||
) => boolean) => {
|
||||
return function (obj: RawFrigateCardConfig): boolean {
|
||||
let modified = false;
|
||||
|
||||
// Change the 'start' of the mode into a style.
|
||||
modified =
|
||||
upgradeMoveToWithOverrides('menu.mode', CONF_MENU_STYLE, {
|
||||
transform: (mode: unknown): string | undefined => {
|
||||
if (typeof mode === 'string') {
|
||||
const result = mode.match(/^(hover|hidden|overlay|above|below|none)/);
|
||||
if (result) {
|
||||
switch (result[1]) {
|
||||
case 'hover':
|
||||
case 'hidden':
|
||||
case 'overlay':
|
||||
case 'none':
|
||||
return result[1];
|
||||
case 'above':
|
||||
case 'below':
|
||||
return 'outside';
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
keepOriginal: true,
|
||||
})(obj) || modified;
|
||||
|
||||
// Change the 'end' of the mode into a position.
|
||||
modified =
|
||||
upgradeMoveToWithOverrides('menu.mode', CONF_MENU_POSITION, {
|
||||
transform: (mode: unknown): string | undefined => {
|
||||
if (typeof mode === 'string') {
|
||||
const result = mode.match(/(above|below|left|right|top|bottom)$/);
|
||||
if (result) {
|
||||
switch (result[1]) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
case 'top':
|
||||
case 'bottom':
|
||||
return result[1];
|
||||
case 'above':
|
||||
return 'top';
|
||||
case 'below':
|
||||
return 'bottom';
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
keepOriginal: true,
|
||||
})(obj) || modified;
|
||||
|
||||
// Delete the old `menu.mode` .
|
||||
return upgradeWithOverrides('menu.mode', deleteProperty)(obj) || modified;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Upgrade from a condition on the menu (to allow rendering) to a menu mode
|
||||
* override instead.
|
||||
@@ -345,6 +494,56 @@ const upgradeMenuConditionToMenuOverride = (): ((
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform a menu button from a boolean to a priority.
|
||||
* @param value The boolean true/false for show/hide the switch.
|
||||
* @returns A priority value.
|
||||
*/
|
||||
const menuButtonBooleanToObject = function (
|
||||
value: unknown,
|
||||
): { enabled: boolean } | null | undefined {
|
||||
if (typeof value === 'object') {
|
||||
return undefined;
|
||||
}
|
||||
// If it's not a boolean remove it.
|
||||
if (typeof value !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
return { enabled: value };
|
||||
};
|
||||
|
||||
/**
|
||||
* Upgrade from a show_controls key to individual favorite/timeline keys.
|
||||
* @returns An upgrade function.
|
||||
*/
|
||||
const upgradeThumbnailShowControlsToIndividualControls = (
|
||||
thumbnailsBasePath: string,
|
||||
): ((obj: RawFrigateCardConfig) => boolean) => {
|
||||
const thumbnailsShowControlsPath = `${thumbnailsBasePath}.show_controls`;
|
||||
|
||||
return function (obj: RawFrigateCardConfig): boolean {
|
||||
let modified = false;
|
||||
modified =
|
||||
upgradeMoveToWithOverrides(
|
||||
thumbnailsShowControlsPath,
|
||||
`${thumbnailsBasePath}.show_favorite_control`,
|
||||
{ keepOriginal: true },
|
||||
)(obj) || modified;
|
||||
|
||||
modified =
|
||||
upgradeMoveToWithOverrides(
|
||||
thumbnailsShowControlsPath,
|
||||
`${thumbnailsBasePath}.show_timeline_control`,
|
||||
{ keepOriginal: true },
|
||||
)(obj) || modified;
|
||||
|
||||
// Delete the old `show_controls`.
|
||||
return (
|
||||
upgradeWithOverrides(thumbnailsShowControlsPath, deleteProperty)(obj) || modified
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const UPGRADES = [
|
||||
// v1.2.1 -> v2.0.0
|
||||
upgradeMoveTo('frigate_url', 'frigate.url'),
|
||||
@@ -358,12 +557,12 @@ const UPGRADES = [
|
||||
upgradeMoveTo('live_preload', CONF_LIVE_PRELOAD),
|
||||
upgradeMoveTo('webrtc', 'live.webrtc'),
|
||||
upgradeMoveTo('autoplay_clip', 'event_viewer.autoplay_clip'),
|
||||
upgradeMoveTo('controls.nextprev', CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE),
|
||||
upgradeMoveTo('controls.nextprev_size', CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE),
|
||||
upgradeMoveTo('menu_mode', CONF_MENU_MODE),
|
||||
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', isNotObject),
|
||||
upgradeMoveTo('image', 'image.src', { transform: isNotObject }),
|
||||
|
||||
// v2.0.0 -> v2.1.0
|
||||
upgradeMoveTo('update_entities', CONF_VIEW_UPDATE_ENTITIES),
|
||||
@@ -371,17 +570,76 @@ const UPGRADES = [
|
||||
// v2.1.0 -> v3.0.0-rc.1
|
||||
upgradeToMultipleCameras(),
|
||||
upgradeMenuConditionToMenuOverride(),
|
||||
upgradeMoveTo('view.timeout', CONF_VIEW_TIMEOUT_SECONDS, toNumberOrIgnore),
|
||||
upgradeMoveTo('event_viewer.autoplay_clip', CONF_EVENT_VIEWER_AUTO_PLAY),
|
||||
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,
|
||||
upgradeChangeIfPresent('live_provider', (val) =>
|
||||
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
|
||||
upgradeWithOverrides(
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
||||
createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX),
|
||||
),
|
||||
upgradeWithOverrides(
|
||||
'event_viewer.controls.thumbnails.size',
|
||||
createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX),
|
||||
),
|
||||
upgradeWithOverrides(
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||
createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN),
|
||||
),
|
||||
upgradeWithOverrides(
|
||||
'event_viewer.controls.next_previous.size',
|
||||
createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN),
|
||||
),
|
||||
upgradeWithOverrides(
|
||||
CONF_MENU_BUTTON_SIZE,
|
||||
createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN),
|
||||
),
|
||||
upgradeWithOverrides('event_gallery.min_columns', deleteProperty),
|
||||
upgradeMenuModeToStyleAndPosition(),
|
||||
upgradeWithOverrides(CONF_MENU_BUTTONS_FRIGATE, menuButtonBooleanToObject),
|
||||
upgradeWithOverrides(CONF_MENU_BUTTONS_CAMERAS, menuButtonBooleanToObject),
|
||||
upgradeWithOverrides(CONF_MENU_BUTTONS_LIVE, menuButtonBooleanToObject),
|
||||
upgradeWithOverrides(CONF_MENU_BUTTONS_CLIPS, menuButtonBooleanToObject),
|
||||
upgradeWithOverrides(CONF_MENU_BUTTONS_SNAPSHOTS, menuButtonBooleanToObject),
|
||||
upgradeWithOverrides(CONF_MENU_BUTTONS_IMAGE, menuButtonBooleanToObject),
|
||||
upgradeWithOverrides(CONF_MENU_BUTTONS_DOWNLOAD, menuButtonBooleanToObject),
|
||||
upgradeWithOverrides(CONF_MENU_BUTTONS_FRIGATE_UI, menuButtonBooleanToObject),
|
||||
upgradeWithOverrides(CONF_MENU_BUTTONS_FULLSCREEN, menuButtonBooleanToObject),
|
||||
upgrade(CONF_LIVE_LAZY_UNLOAD, (val) =>
|
||||
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
|
||||
),
|
||||
upgrade(CONF_LIVE_AUTO_UNMUTE, (val) =>
|
||||
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
|
||||
),
|
||||
upgrade('event_viewer.auto_play', (val) =>
|
||||
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
|
||||
),
|
||||
upgrade('event_viewer.auto_unmute', (val) =>
|
||||
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
|
||||
),
|
||||
upgradeMoveToWithOverrides('event_viewer', CONF_MEDIA_VIEWER),
|
||||
upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('camera_name', 'frigate.camera_name')),
|
||||
upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('client_id', 'frigate.client_id')),
|
||||
upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('label', 'frigate.label')),
|
||||
upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('frigate_url', 'frigate.url')),
|
||||
upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('zone', 'frigate.zone')),
|
||||
|
||||
// v4.0.0-rc.1 -> v4.0.0-rc.3
|
||||
upgradeThumbnailShowControlsToIndividualControls('event_gallery.controls.thumbnails'),
|
||||
upgradeThumbnailShowControlsToIndividualControls('media_viewer.controls.thumbnails'),
|
||||
upgradeThumbnailShowControlsToIndividualControls('live.controls.thumbnails'),
|
||||
upgradeThumbnailShowControlsToIndividualControls('timeline.controls.thumbnails'),
|
||||
];
|
||||
|
||||
@@ -1,58 +1,103 @@
|
||||
export const CARD_VERSION = '3.0.0' as const;
|
||||
export const CAMERA_BIRDSEYE = 'birdseye' 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 CONF_CAMERAS = 'cameras' as const;
|
||||
export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY =
|
||||
`${CONF_CAMERAS}.#.camera_entity` as const;
|
||||
export const CONF_CAMERAS_ARRAY_CAMERA_NAME = `${CONF_CAMERAS}.#.camera_name` as const;
|
||||
export const CONF_CAMERAS_ARRAY_CLIENT_ID = `${CONF_CAMERAS}.#.client_id` as const;
|
||||
export const CONF_CAMERAS_ARRAY_LABEL = `${CONF_CAMERAS}.#.label` as const;
|
||||
export const CONF_CAMERAS_ARRAY_URL = `${CONF_CAMERAS}.#.frigate_url` as const;
|
||||
export const CONF_CAMERAS_ARRAY_ZONE = `${CONF_CAMERAS}.#.zone` as const;
|
||||
export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME =
|
||||
`${CONF_CAMERAS}.#.frigate.camera_name` as const;
|
||||
export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID =
|
||||
`${CONF_CAMERAS}.#.frigate.client_id` as const;
|
||||
export const CONF_CAMERAS_ARRAY_FRIGATE_LABEL =
|
||||
`${CONF_CAMERAS}.#.frigate.label` 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_ID = `${CONF_CAMERAS}.#.id` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const;
|
||||
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
|
||||
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY =
|
||||
`${CONF_CAMERAS}.#.webrtc_card.entity` as const;
|
||||
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = `${CONF_CAMERAS}.#.webrtc_card.url` as const;
|
||||
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL =
|
||||
`${CONF_CAMERAS}.#.webrtc_card.url` as const;
|
||||
export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER =
|
||||
`${CONF_CAMERAS}.#.live_provider` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS =
|
||||
`${CONF_CAMERAS}.#.dependencies.cameras` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS =
|
||||
`${CONF_CAMERAS}.#.dependencies.all_cameras` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
|
||||
`${CONF_CAMERAS}.#.triggers.motion` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
||||
`${CONF_CAMERAS}.#.triggers.occupancy` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
|
||||
`${CONF_CAMERAS}.#.triggers.entities` as const;
|
||||
|
||||
export const CONF_VIEW = 'view' 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_DEFAULT = `${CONF_VIEW}.default` 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_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_SCAN = `${CONF_VIEW}.scan` as const;
|
||||
export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const;
|
||||
export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS =
|
||||
`${CONF_VIEW_SCAN}.show_trigger_status` as const;
|
||||
export const CONF_VIEW_SCAN_UNTRIGGER_RESET =
|
||||
`${CONF_VIEW_SCAN}.untrigger_reset` as const;
|
||||
export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS =
|
||||
`${CONF_VIEW_SCAN}.untrigger_seconds` as const;
|
||||
|
||||
export const CONF_EVENT_GALLERY = 'event_gallery' as const;
|
||||
export const CONF_EVENT_GALLERY_MIN_COLUMNS =
|
||||
`${CONF_EVENT_GALLERY}.min_columns` as const;
|
||||
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS =
|
||||
`${CONF_EVENT_GALLERY}.controls.thumbnails.show_details` as const;
|
||||
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
|
||||
`${CONF_EVENT_GALLERY}.controls.thumbnails.show_favorite_control` as const;
|
||||
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||
`${CONF_EVENT_GALLERY}.controls.thumbnails.show_timeline_control` as const;
|
||||
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE =
|
||||
`${CONF_EVENT_GALLERY}.controls.thumbnails.size` as const;
|
||||
|
||||
export const CONF_EVENT_VIEWER = 'event_viewer' as const;
|
||||
export const CONF_EVENT_VIEWER_AUTO_PLAY = `${CONF_EVENT_VIEWER}.auto_play` as const;
|
||||
export const CONF_EVENT_VIEWER_AUTO_UNMUTE = `${CONF_EVENT_VIEWER}.auto_unmute` as const;
|
||||
export const CONF_EVENT_VIEWER_DRAGGABLE = `${CONF_EVENT_VIEWER}.draggable` as const;
|
||||
export const CONF_EVENT_VIEWER_LAZY_LOAD = `${CONF_EVENT_VIEWER}.lazy_load` as const;
|
||||
export const CONF_EVENT_VIEWER_TRANSITION_EFFECT =
|
||||
`${CONF_EVENT_VIEWER}.transition_effect` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
|
||||
`${CONF_EVENT_VIEWER}.controls.next_previous.style` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
|
||||
`${CONF_EVENT_VIEWER}.controls.next_previous.size` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE =
|
||||
`${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE =
|
||||
`${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE =
|
||||
`${CONF_EVENT_VIEWER}.controls.title.mode` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
|
||||
`${CONF_EVENT_VIEWER}.controls.title.duration_seconds` 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_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const;
|
||||
export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const;
|
||||
export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const;
|
||||
export const CONF_MEDIA_VIEWER_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_TRANSITION_EFFECT =
|
||||
`${CONF_MEDIA_VIEWER}.transition_effect` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.next_previous.size` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
|
||||
`${CONF_MEDIA_VIEWER}.controls.title.duration_seconds` as const;
|
||||
export const CONF_MEDIA_VIEWER_LAYOUT_FIT = `${CONF_MEDIA_VIEWER}.layout.fit` as const;
|
||||
export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_X =
|
||||
`${CONF_MEDIA_VIEWER}.layout.position.x` as const;
|
||||
export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y =
|
||||
`${CONF_MEDIA_VIEWER}.layout.position.y` as const;
|
||||
|
||||
export const CONF_LIVE = 'live' as const;
|
||||
export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const;
|
||||
export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const;
|
||||
export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const;
|
||||
export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const;
|
||||
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
|
||||
`${CONF_LIVE}.controls.next_previous.style` as const;
|
||||
@@ -64,35 +109,69 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
|
||||
`${CONF_LIVE}.controls.thumbnails.mode` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
|
||||
`${CONF_LIVE}.controls.thumbnails.size` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
|
||||
`${CONF_LIVE}.controls.thumbnails.show_details` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
|
||||
`${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||
`${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const;
|
||||
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
|
||||
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
|
||||
`${CONF_LIVE}.controls.title.duration_seconds` as const;
|
||||
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_Y = `${CONF_LIVE}.layout.position.y` 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_UNLOAD = `${CONF_LIVE}.lazy_unload` as const;
|
||||
export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const;
|
||||
export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
|
||||
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
|
||||
`${CONF_LIVE}.show_image_during_load` as const;
|
||||
export const CONF_LIVE_WEBRTC_CARD = `${CONF_LIVE}.webrtc_card` as const;
|
||||
|
||||
export const CONF_IMAGE = 'image' 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_Y = `${CONF_IMAGE}.layout.position.y` as const;
|
||||
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_URL = `${CONF_IMAGE}.url` as const;
|
||||
|
||||
export const CONF_TIMELINE = 'timeline' as const;
|
||||
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
||||
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
|
||||
`${CONF_TIMELINE}.clustering_threshold` as const;
|
||||
export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const;
|
||||
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.mode` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.size` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.show_details` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const;
|
||||
|
||||
export const CONF_MENU = 'menu' 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_STYLE = `${CONF_MENU}.style` as const;
|
||||
export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const;
|
||||
export const CONF_MENU_BUTTONS = `${CONF_MENU}.buttons` as const;
|
||||
|
||||
export const CONF_MENU_BUTTONS_CAMERAS = `${CONF_MENU}.buttons.cameras` 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_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_FRIGATE_FULLSCREEN =
|
||||
`${CONF_MENU}.buttons.fullscreen` as const;
|
||||
export const CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD =
|
||||
`${CONF_MENU}.buttons.download` as const;
|
||||
export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const;
|
||||
export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const;
|
||||
export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` 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_BUTTON_SIZE = `${CONF_MENU}.button_size` as const;
|
||||
export const CONF_MENU_MODE = `${CONF_MENU}.mode` as const;
|
||||
export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const;
|
||||
export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const;
|
||||
|
||||
export const CONF_DIMENSIONS = 'dimensions' as const;
|
||||
export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const;
|
||||
@@ -100,3 +179,6 @@ export const CONF_DIMENSIONS_ASPECT_RATIO_MODE =
|
||||
`${CONF_DIMENSIONS}.aspect_ratio_mode` as const;
|
||||
|
||||
export const CONF_OVERRIDES = 'overrides' as const;
|
||||
|
||||
// Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93
|
||||
export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
declare module '*.scss';
|
||||
declare module '*.jpg';
|
||||
declare module 'view' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface ViewContext {}
|
||||
}
|
||||
|
||||
@@ -1,235 +1,387 @@
|
||||
{
|
||||
"common": {
|
||||
"version": "Version",
|
||||
"frigate_card": "Frigate card",
|
||||
"frigate_card_description": "A Lovelace card for use with Frigate",
|
||||
"no_snapshots": "No snapshots",
|
||||
"live": "Live",
|
||||
"no_clip": "No recent clip",
|
||||
"no_clips": "No clips",
|
||||
"no_snapshot": "No recent snapshot",
|
||||
"no_clip": "No recent clip",
|
||||
"live": "Live"
|
||||
"no_snapshots": "No snapshots",
|
||||
"recordings": "Recordings",
|
||||
"version": "Version"
|
||||
},
|
||||
"config": {
|
||||
"cameras": {
|
||||
"camera_entity": "Camera Entity",
|
||||
"camera_name": "Frigate camera name (Autodetected from entity)",
|
||||
"client_id": "Frigate client id (For >1 Frigate server)",
|
||||
"id": "Unique id for this camera in this card",
|
||||
"label": "Frigate label/object filter",
|
||||
"frigate_url": "Frigate server URL",
|
||||
"title": "Title for this camera (Autodetected from entity)",
|
||||
"dependencies": {
|
||||
"all_cameras": "Show events for all cameras with this camera",
|
||||
"cameras": "Show events for specific cameras with this camera",
|
||||
"options": "Dependency Options"
|
||||
},
|
||||
"frigate": {
|
||||
"camera_name": "Frigate camera name (Autodetected from entity)",
|
||||
"client_id": "Frigate client id (For >1 Frigate server)",
|
||||
"label": "Frigate label/object filter",
|
||||
"options": "Frigate Options",
|
||||
"url": "Frigate server URL",
|
||||
"zone": "Frigate zone"
|
||||
},
|
||||
"icon": "Icon for this camera (Autodetected from entity)",
|
||||
"id": "Unique id for this camera in this card",
|
||||
"live_provider": "Live view provider for this camera",
|
||||
"live_providers": {
|
||||
"auto": "Automatic",
|
||||
"ha": "Home Assistant (i.e. HLS, LL-HLS, WebRTC native)",
|
||||
"frigate-jsmpeg": "Frigate JSMpeg",
|
||||
"ha": "Home Assistant (i.e. HLS, LL-HLS, WebRTC native)",
|
||||
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
|
||||
},
|
||||
"title": "Title for this camera (Autodetected from entity)",
|
||||
"triggers": {
|
||||
"entities": "Trigger from other entities",
|
||||
"motion": "Trigger by auto-detecting the motion sensor",
|
||||
"occupancy": "Trigger by auto-detecting the occupancy sensor",
|
||||
"options": "Trigger Options"
|
||||
},
|
||||
"webrtc_card": {
|
||||
"entity": "WebRTC Card Camera Entity (Not a Frigate camera)",
|
||||
"options": "WebRTC Card Options",
|
||||
"url": "WebRTC Card Camera URL"
|
||||
},
|
||||
"zone": "Frigate zone"
|
||||
}
|
||||
},
|
||||
"view": {
|
||||
"camera_select": "View for newly selected cameras",
|
||||
"default": "Default view",
|
||||
"views": {
|
||||
"live": "Live view",
|
||||
"clip": "Most recent clip",
|
||||
"clips": "Clips gallery",
|
||||
"snapshot": "Most recent snapshot",
|
||||
"snapshots": "Snapshots gallery",
|
||||
"image": "Static image",
|
||||
"current": "Current view"
|
||||
"common": {
|
||||
"media_action_conditions": {
|
||||
"all": "All opportunities",
|
||||
"hidden": "On browser/tab hiding",
|
||||
"never": "Never",
|
||||
"selected": "On selection",
|
||||
"unselected": "On unselection",
|
||||
"visible": "On browser/tab visibility"
|
||||
},
|
||||
"timeout_seconds": "Reset to default view X seconds after user action (0=never)",
|
||||
"update_cycle_camera": "Cycle through cameras when default view updates",
|
||||
"update_force": "Force card updates (ignore user interaction)",
|
||||
"update_seconds": "Refresh default view every X seconds (0=never)"
|
||||
},
|
||||
"event_gallery": {
|
||||
"min_columns": "Minimum number of columns"
|
||||
},
|
||||
"event_viewer": {
|
||||
"auto_play": "Automatically play media",
|
||||
"auto_unmute": "Automatically unmute media",
|
||||
"draggable": "Event Viewer can be dragged/swiped",
|
||||
"lazy_load": "Event Viewer media is lazily loaded in carousel",
|
||||
"transition_effect": "Event Viewer transition effect",
|
||||
"transition_effects": {
|
||||
"none": "No transition",
|
||||
"slide": "Slide transition"
|
||||
},
|
||||
"controls": {
|
||||
"next_previous": {
|
||||
"style": "Event Viewer next & previous control style",
|
||||
"size": "Event Viewer next & previous control size (e.g. '48px')",
|
||||
"styles": {
|
||||
"thumbnails": "Thumbnails",
|
||||
"chevrons": "Chevrons",
|
||||
"none": "None"
|
||||
}
|
||||
"layout": {
|
||||
"fit": "Layout fit",
|
||||
"fits": {
|
||||
"cover": "Media expands proportionally to cover the card",
|
||||
"contain": "Media is contained/letterboxed",
|
||||
"fill": "Media is stretched to fill the card"
|
||||
},
|
||||
"thumbnails": {
|
||||
"mode": "Event Viewer thumbnails mode",
|
||||
"size": "Event Viewer thumbnails size (e.g. '100px')",
|
||||
"modes": {
|
||||
"below": "Thumbnails below the media",
|
||||
"above": "Thumbnails above the media",
|
||||
"none": "No thumbnails"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"mode": "Event Viewer media title display mode",
|
||||
"modes": {
|
||||
"none": "No title display",
|
||||
"popup-top-left": "Popup on the top left",
|
||||
"popup-top-right": "Popup on the top right",
|
||||
"popup-bottom-left": "Popup on the bottom left",
|
||||
"popup-bottom-right": "Popup on the bottom right"
|
||||
},
|
||||
"duration_seconds": "Seconds to display popup title (0=forever)"
|
||||
"position": {
|
||||
"x": "Horizontal placement percentage",
|
||||
"y": "Vertical placement percentage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dimensions": {
|
||||
"aspect_ratio": "Default aspect ratio (e.g. '16:9')",
|
||||
"aspect_ratio_mode": "Aspect ratio mode",
|
||||
"aspect_ratio_modes": {
|
||||
"dynamic": "Aspect ratio adjusts to media",
|
||||
"static": "Static aspect ratio",
|
||||
"unconstrained": "Unconstrained aspect ratio"
|
||||
}
|
||||
},
|
||||
"event_gallery": {
|
||||
"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": {
|
||||
"layout": "Image Layout",
|
||||
"mode": "Image view mode",
|
||||
"modes": {
|
||||
"camera": "Home Assistant camera snapshot of camera entity",
|
||||
"screensaver": "Embedded Frigate logo",
|
||||
"url": "Arbitrary image specified by URL"
|
||||
},
|
||||
"refresh_seconds": "Number of seconds after which to refresh (0=never)",
|
||||
"url": "Static image URL for image view"
|
||||
},
|
||||
"live": {
|
||||
"preload": "Preload live view in the background",
|
||||
"draggable": "Live cameras view can be dragged/swiped",
|
||||
"lazy_load": "Live cameras are lazily loaded",
|
||||
"lazy_unload": "Live cameras are lazily unloaded",
|
||||
"auto_mute": "Automatically mute live cameras",
|
||||
"auto_pause": "Automatically pause live cameras",
|
||||
"auto_play": "Automatically play live cameras",
|
||||
"auto_unmute": "Automatically unmute live cameras",
|
||||
"transition_effect": "Live camera transition effect",
|
||||
"controls": {
|
||||
"next_previous": {
|
||||
"size": "Live view next & previous control size in pixels",
|
||||
"style": "Live view next & previous control style",
|
||||
"size": "Live view next & previous control size (e.g. '48px')",
|
||||
"styles": {
|
||||
"chevrons": "Chevrons",
|
||||
"icons": "Icons",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"options": "Live Controls",
|
||||
"thumbnails": {
|
||||
"mode": "Live thumbnails mode",
|
||||
"size": "Live thumbnails size (e.g. '100px')",
|
||||
"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": {
|
||||
"mode": "Live media title display mode",
|
||||
"duration_seconds": "Seconds to display popup title (0=forever)"
|
||||
"duration_seconds": "Seconds to display popup title (0=forever)",
|
||||
"mode": "Live media title display mode"
|
||||
}
|
||||
},
|
||||
"draggable": "Live cameras view can be dragged/swiped",
|
||||
"layout": "Live Layout",
|
||||
"lazy_load": "Live cameras are lazily loaded",
|
||||
"lazy_unload": "Live cameras are lazily unloaded",
|
||||
"preload": "Preload live view in the background",
|
||||
"show_image_during_load": "Show still image while the live stream is loading",
|
||||
"transition_effect": "Live camera transition effect"
|
||||
},
|
||||
"media_viewer": {
|
||||
"auto_mute": "Automatically mute media",
|
||||
"auto_pause": "Automatically pause media",
|
||||
"auto_play": "Automatically play media",
|
||||
"auto_unmute": "Automatically unmute media",
|
||||
"controls": {
|
||||
"next_previous": {
|
||||
"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",
|
||||
"lazy_load": "Media Viewer media is lazily loaded in carousel",
|
||||
"layout": "Media Viewer Layout",
|
||||
"transition_effect": "Media Viewer transition effect",
|
||||
"transition_effects": {
|
||||
"none": "No transition",
|
||||
"slide": "Slide transition"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"mode": "Image view mode",
|
||||
"modes": {
|
||||
"screensaver": "Embedded Frigate logo",
|
||||
"url": "Arbitrary image specified by URL",
|
||||
"camera": "Home Assistant camera snapshot of camera entity"
|
||||
},
|
||||
"url": "Static image URL for image view",
|
||||
"refresh_seconds": "Number of seconds after which to refresh (0=never)"
|
||||
},
|
||||
"menu": {
|
||||
"alignment": "Menu alignment",
|
||||
"alignments": {
|
||||
"bottom": "Aligned to the bottom",
|
||||
"left": "Aligned to the left",
|
||||
"right": "Aligned to the right",
|
||||
"top": "Aligned to the top"
|
||||
},
|
||||
"button_size": "Menu button size in pixels",
|
||||
"buttons": {
|
||||
"alignment": "Button alignment",
|
||||
"alignments": {
|
||||
"matching": "Matching the menu alignment",
|
||||
"opposing": "Opposing the menu alignment"
|
||||
},
|
||||
"cameras": "Cameras",
|
||||
"clips": "Clips",
|
||||
"download": "Download",
|
||||
"enabled": "Button enabled",
|
||||
"frigate": "Frigate menu / Default view",
|
||||
"frigate_ui": "Frigate user Interface",
|
||||
"frigate_ui": "Frigate user interface",
|
||||
"fullscreen": "Fullscreen",
|
||||
"download": "Download event media",
|
||||
"cameras": "Select camera"
|
||||
"icon": "Icon",
|
||||
"image": "Image",
|
||||
"live": "Live",
|
||||
"media_player": "Send to media player",
|
||||
"priority": "Priority",
|
||||
"snapshots": "Snapshots",
|
||||
"timeline": "Timeline"
|
||||
},
|
||||
"mode": "Menu mode",
|
||||
"modes": {
|
||||
"position": "Menu position",
|
||||
"positions": {
|
||||
"bottom": "Positioned on the bottom",
|
||||
"left": "Positioned on the left",
|
||||
"right": "Positioned on the right",
|
||||
"top": "Positioned on the top"
|
||||
},
|
||||
"style": "Menu style",
|
||||
"styles": {
|
||||
"hidden": "Hidden menu",
|
||||
"hover": "Hover menu",
|
||||
"none": "No menu",
|
||||
"hidden-top": "Hidden top",
|
||||
"hidden-left": "Hidden left",
|
||||
"hidden-bottom": "Hidden bottom",
|
||||
"hidden-right": "Hidden right",
|
||||
"overlay-top": "Overlay top",
|
||||
"overlay-left": "Overlay left",
|
||||
"overlay-bottom": "Overlay bottom",
|
||||
"overlay-right": "Overlay right",
|
||||
"hover-top": "Hover top",
|
||||
"hover-left": "Hover left",
|
||||
"hover-bottom": "Hover bottom",
|
||||
"hover-right": "Hover right",
|
||||
"above": "Above",
|
||||
"below": "Below"
|
||||
},
|
||||
"button_size": "Menu button size (e.g. '40px')"
|
||||
},
|
||||
"dimensions": {
|
||||
"aspect_ratio": "Default aspect ratio (e.g. '16:9')",
|
||||
"aspect_ratio_mode": "Aspect ratio mode",
|
||||
"aspect_ratio_modes": {
|
||||
"unconstrained": "Unconstrained aspect ratio",
|
||||
"dynamic": "Aspect ratio adjusts to media",
|
||||
"static": "Static aspect ratio"
|
||||
"outside": "Outside menu",
|
||||
"overlay": "Overlay menu"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
|
||||
"controls": {
|
||||
"options": "Timeline Controls",
|
||||
"thumbnails": {
|
||||
"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",
|
||||
"medias": {
|
||||
"all": "All media types",
|
||||
"clips": "Clips",
|
||||
"snapshots": "Snapshots"
|
||||
},
|
||||
"show_recordings": "Show recordings",
|
||||
"window_seconds": "The default length of the timeline view in seconds"
|
||||
},
|
||||
"view": {
|
||||
"camera_select": "View for newly selected cameras",
|
||||
"dark_mode": "Dark mode",
|
||||
"dark_modes": {
|
||||
"auto": "Auto",
|
||||
"off": "Off",
|
||||
"on": "On"
|
||||
},
|
||||
"default": "Default view",
|
||||
"scan": {
|
||||
"enabled": "Scan mode enabled",
|
||||
"scan_mode": "Scan mode",
|
||||
"show_trigger_status": "Show pulsing border when triggered",
|
||||
"untrigger_reset": "Reset the view to default after untrigger",
|
||||
"untrigger_seconds": "Seconds after inactive state change to untrigger"
|
||||
},
|
||||
"timeout_seconds": "Reset to default view X seconds after user action (0=never)",
|
||||
"update_cycle_camera": "Cycle through cameras when default view updates",
|
||||
"update_force": "Force card updates (ignore user interaction)",
|
||||
"update_seconds": "Refresh default view every X seconds (0=never)",
|
||||
"views": {
|
||||
"clip": "Most recent clip",
|
||||
"clips": "Clips gallery",
|
||||
"current": "Current view",
|
||||
"image": "Static image",
|
||||
"live": "Live view",
|
||||
"snapshot": "Most recent snapshot",
|
||||
"snapshots": "Snapshots gallery",
|
||||
"timeline": "Timeline view"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"add_new_camera": "Add new camera",
|
||||
"button": "Button",
|
||||
"camera": "Camera",
|
||||
"cameras": "Cameras",
|
||||
"cameras_secondary": "What cameras to render on this card",
|
||||
"view": "View",
|
||||
"view_secondary": "What the card should show and how to show it",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Menu look & feel options",
|
||||
"live": "Live",
|
||||
"live_secondary": "Live camera view options",
|
||||
"event_gallery": "Event gallery",
|
||||
"event_gallery_secondary": "Snapshots & clips gallery options",
|
||||
"event_viewer": "Event viewer",
|
||||
"event_viewer_secondary": "Snapshots & clips viewer options",
|
||||
"image": "Image",
|
||||
"image_secondary": "Static image view options",
|
||||
"delete": "Delete",
|
||||
"dimensions": "Dimensions",
|
||||
"dimensions_secondary": "Dimensions & shape options",
|
||||
"show_button": "Show button",
|
||||
"event_gallery": "Event gallery",
|
||||
"event_gallery_secondary": "Snapshots & clips gallery options",
|
||||
"image": "Image",
|
||||
"image_secondary": "Static image view options",
|
||||
"live": "Live",
|
||||
"live_secondary": "Live camera view options",
|
||||
"media_viewer": "Media viewer",
|
||||
"media_viewer_secondary": "Viewer for static media (clips, snapshots or recordings)",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Menu look & feel options",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"overrides": "Overrides are active",
|
||||
"overrides_secondary": "Dynamic configuration overrides detected",
|
||||
"timeline": "Timeline",
|
||||
"timeline_secondary": "Event timeline options",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_available": "An automatic card configuration upgrade is available",
|
||||
"delete": "Delete",
|
||||
"move_up": "Move up",
|
||||
"move_down": "Move down",
|
||||
"add_new_camera": "Add new camera",
|
||||
"camera": "Camera",
|
||||
"overrides": "Overrides are active",
|
||||
"overrides_secondary": "Dynamic configuration overrides detected"
|
||||
"view": "View",
|
||||
"view_secondary": "What the card should show and how to show it"
|
||||
},
|
||||
"error": {
|
||||
"empty_response": "Received empty response from Home Assistant for request",
|
||||
"invalid_response": "Received invalid response from Home Assistant for request",
|
||||
"invalid_keys": "Invalid keys",
|
||||
"unknown": "Unknown error",
|
||||
"troubleshooting": "Check troubleshooting",
|
||||
"could_not_render_elements": "Could not render picture elements",
|
||||
"could_not_resolve": "Could not resolve media URL",
|
||||
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
|
||||
"live_camera_unavailable": "The configured camera_entity is unavailable",
|
||||
"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_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",
|
||||
"empty_response": "Received empty response from Home Assistant for request",
|
||||
"failed_response": "Failed to receive response from Home Assistant for request",
|
||||
"failed_retain": "Could not retain event",
|
||||
"failed_sign": "Could not sign Home Assistant URL",
|
||||
"image_load_error": "The image could not be loaded",
|
||||
"invalid_configuration": "Invalid configuration",
|
||||
"invalid_configuration_no_hint": "No location hint available (bad or missing type?)",
|
||||
"invalid_elements_config": "Invalid picture elements configuration",
|
||||
"invalid_response": "Received invalid response from Home Assistant for request",
|
||||
"jsmpeg_no_player": "Could not start JSMPEG player",
|
||||
"jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path",
|
||||
"live_camera_not_found": "The configured camera_entity was not found",
|
||||
"live_camera_unavailable": "Camera unavailable",
|
||||
"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_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",
|
||||
"reconnecting": "Reconnecting",
|
||||
"timeline_no_cameras": "No Frigate cameras to show in timeline",
|
||||
"troubleshooting": "Check troubleshooting",
|
||||
"unknown": "Unknown error",
|
||||
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
|
||||
"webrtc_card_reported_error": "WebRTC Card reported an error",
|
||||
"webrtc_card_waiting": "Waiting for WebRTC Card to load ...",
|
||||
"no_cameras": "No valid cameras found, you must configure at least one camera entry",
|
||||
"no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually",
|
||||
"duplicate_camera_id": "Duplicate Frigate camera id for the following camera, use the 'id' parameter to uniquely identify cameras",
|
||||
"could_not_render_elements": "Could not render picture elements",
|
||||
"invalid_elements_config": "Invalid picture elements configuration",
|
||||
"jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path",
|
||||
"jsmpeg_no_player": "Could not start JSMPEG player",
|
||||
"no_camera_name": "Could not determine Frigate camera name for camera, please specify either 'camera_entity' or 'camera_name' for the following camera",
|
||||
"download_no_media": "No media to download",
|
||||
"download_no_event_id": "Could not extract Frigate event id from media",
|
||||
"download_sign_failed": "Could not sign media URL for download"
|
||||
"webrtc_card_waiting": "Waiting for WebRTC Card to load ..."
|
||||
},
|
||||
"event": {
|
||||
"duration": "Duration",
|
||||
"in_progress": "In Progress",
|
||||
"score": "Score",
|
||||
"start": "Start"
|
||||
},
|
||||
"recording": {
|
||||
"events": "Events",
|
||||
"seek": "Seek"
|
||||
},
|
||||
"thumbnail": {
|
||||
"no_thumbnail": "No thumbnail available",
|
||||
"retain_indefinitely": "Event will be indefinitely retained",
|
||||
"timeline": "See event in timeline"
|
||||
},
|
||||
"elements": {
|
||||
"ptz": {
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"left": "Left",
|
||||
"right": "Right",
|
||||
"zoom_in": "Zoom In",
|
||||
"zoom_out": "Zoom Out",
|
||||
"home": "Home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"common": {
|
||||
"frigate_card": "Frigate card",
|
||||
"frigate_card_description": "Una scheda Lovelace per l'uso con Frigate",
|
||||
"live": "Live",
|
||||
"no_clip": "Nessuna clip recente",
|
||||
"no_clips": "Nessun clip",
|
||||
"no_snapshot": "Nessuna istantanea recente",
|
||||
"no_snapshots": "Nessuna istantanea",
|
||||
"recordings": "Registrazioni",
|
||||
"version": "Versione"
|
||||
},
|
||||
"config": {
|
||||
"cameras": {
|
||||
"camera_entity": "Entità della telecamera",
|
||||
"dependencies": {
|
||||
"all_cameras": "Mostra eventi per tutte le telecamere con questa telecamera",
|
||||
"cameras": "Mostra eventi per telecamere specifiche con questa telecamera",
|
||||
"options": "Opzioni di dipendenza"
|
||||
},
|
||||
"frigate": {
|
||||
"camera_name": "Nome della telecamera frigate (autodificato dall'entità)",
|
||||
"client_id": "ID client Frigate (per > 1 Frigate server)",
|
||||
"label": "Filtro etichetta/oggetto Frigate",
|
||||
"options": "Frigate Opzione",
|
||||
"url": "Frigate URL del server",
|
||||
"zone": "Frigate zona"
|
||||
},
|
||||
"icon": "Icona per questa telecamera (Autoidentificato dall'entità)",
|
||||
"id": "ID univoco per questa telecamera in questa carta",
|
||||
"live_provider": "Provider di visualizzazione dal vivo per questa telecamera",
|
||||
"live_providers": {
|
||||
"auto": "Automatica",
|
||||
"frigate-jsmpeg": "Frigate JSMpeg",
|
||||
"ha": "Home Assistant (ovvero HLS, LL-HLS, WebRTC nativo)",
|
||||
"webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)"
|
||||
},
|
||||
"title": "Titolo per questa telecamera (Autoidentificato dall'entità)",
|
||||
"triggers": {
|
||||
"entities": "Trigger da altre entità",
|
||||
"motion": "Trigger rilevando automaticamente dal sensore di movimento",
|
||||
"occupancy": "Attivare rilevando automatico tramite il sensore di presenza",
|
||||
"options": "Trigger Opzioni"
|
||||
},
|
||||
"webrtc_card": {
|
||||
"entity": "Entità della telecamera della scheda WebRTC (non una telecamera Frigate)",
|
||||
"options": "Opzioni della scheda WebRTC",
|
||||
"url": "URL della telecamera della scheda WebRTC"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"media_action_conditions": {
|
||||
"all": "Tutte le opportunità",
|
||||
"hidden": "Sul browser/nascondere le schede",
|
||||
"never": "Mai",
|
||||
"selected": "Sulla selezione",
|
||||
"unselected": "Sulla non selezione",
|
||||
"visible": "Sul browser/visibilità della scheda"
|
||||
}
|
||||
},
|
||||
"dimensions": {
|
||||
"aspect_ratio": "Proporzioni predefinite (ad es. '16:9')",
|
||||
"aspect_ratio_mode": "Modalità proporzioni",
|
||||
"aspect_ratio_modes": {
|
||||
"dynamic": "Le proporzioni si adattano ai media",
|
||||
"static": "Proporzioni statiche",
|
||||
"unconstrained": "Proporzioni non vincolate"
|
||||
}
|
||||
},
|
||||
"event_gallery": {
|
||||
"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": {
|
||||
"mode": "Modalità Visualizza immagine",
|
||||
"modes": {
|
||||
"camera": "Istantanea della telecamera di Home Assistant dell'entità telecamera",
|
||||
"screensaver": "Logo Frigate incorporato",
|
||||
"url": "Immagine arbitraria specificata dall'URL"
|
||||
},
|
||||
"refresh_seconds": "Numero di secondi dopo i quali aggiornare (0 = mai)",
|
||||
"url": "URL di immagine statica per la vista dell'immagine"
|
||||
},
|
||||
"live": {
|
||||
"auto_mute": "Muta automaticamente le telecamere in diretta",
|
||||
"auto_pause": "Metti in pausa automaticamente le telecamere in diretta",
|
||||
"auto_play": "Gioca automaticamente le telecamere dal vivo",
|
||||
"auto_unmute": "Riattiva automaticamente l'audio delle telecamere live",
|
||||
"controls": {
|
||||
"next_previous": {
|
||||
"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",
|
||||
"lazy_load": "Le telecamere dal vivo sono pigramente cariche",
|
||||
"lazy_unload": "Le telecamere dal vivo sono pigramente non caricate",
|
||||
"preload": "Precarica Live View in background",
|
||||
"show_image_during_load": "",
|
||||
"transition_effect": "Effetto di transizione della telecamera dal vivo"
|
||||
},
|
||||
"media_viewer": {
|
||||
"auto_mute": "Muta automaticamente i media",
|
||||
"auto_pause": "Metti in Pausa automaticamente i media",
|
||||
"auto_play": "Riproduci automaticamente i contenuti multimediali",
|
||||
"auto_unmute": "Riattiva automaticamente i contenuti multimediali",
|
||||
"controls": {
|
||||
"next_previous": {
|
||||
"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",
|
||||
"lazy_load": "Il media Viewer viene caricato pigramente nel carosello",
|
||||
"transition_effect": "Effetto di transizione del visualizzatore multimediale",
|
||||
"transition_effects": {
|
||||
"none": "Nessuna transizione",
|
||||
"slide": "Transizione diapositiva"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"alignment": "Allineamento dei menu",
|
||||
"alignments": {
|
||||
"bottom": "Allineato al fondo",
|
||||
"left": "Allineato a sinistra",
|
||||
"right": "Allineato a destra",
|
||||
"top": "Allineato in cima"
|
||||
},
|
||||
"button_size": "Dimensione del pulsante menu in pixel",
|
||||
"buttons": {
|
||||
"alignment": "Allineamento dei pulsanti",
|
||||
"alignments": {
|
||||
"matching": "Corrispondenza con l'allineamento del menu",
|
||||
"opposing": "Contrastare l'allineamento del menu"
|
||||
},
|
||||
"cameras": "Telecamere",
|
||||
"clips": "Clip",
|
||||
"download": "Download",
|
||||
"enabled": "Pulsante abilitato",
|
||||
"frigate": "Frigate menu / Visualizzazione predefinita",
|
||||
"frigate_ui": "Frigate interfaccia utente",
|
||||
"fullscreen": "A schermo intero",
|
||||
"icon": "Icona",
|
||||
"image": "Immagine",
|
||||
"live": "Abitare",
|
||||
"media_player": "Invia a Media Player",
|
||||
"priority": "Priorità",
|
||||
"snapshots": "Istantanee",
|
||||
"timeline": "Timeline"
|
||||
},
|
||||
"position": "Posizione del menu",
|
||||
"positions": {
|
||||
"bottom": "Posizionato sul fondo",
|
||||
"left": "Posizionato a sinistra",
|
||||
"right": "Posizionato a destra",
|
||||
"top": "Posizionato in alto"
|
||||
},
|
||||
"style": "Stile menu",
|
||||
"styles": {
|
||||
"hidden": "Menu nascosto",
|
||||
"hover": "Menu al passaggio del mouse",
|
||||
"none": "Nessun menu",
|
||||
"outside": "Menu esterno",
|
||||
"overlay": "Menu di overlay"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"timeline": {
|
||||
"clustering_threshold": "Il conteggio degli eventi in cui sono raggruppati (0 = nessun clustering)",
|
||||
"controls": {
|
||||
"options": "Controlli della sequenza temporale",
|
||||
"thumbnails": {
|
||||
"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",
|
||||
"medias": {
|
||||
"all": "Tutti i tipi di media",
|
||||
"clips": "Clip",
|
||||
"snapshots": "Istantanee"
|
||||
},
|
||||
"show_recordings": "Mostra registrazioni",
|
||||
"window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi"
|
||||
},
|
||||
"view": {
|
||||
"camera_select": "Visualizza per le telecamere appena selezionate",
|
||||
"dark_mode": "Tema scuro",
|
||||
"dark_modes": {
|
||||
"auto": "auto",
|
||||
"off": "Off",
|
||||
"on": "On"
|
||||
},
|
||||
"default": "Visualizzazione predefinita",
|
||||
"scan": {
|
||||
"enabled": "Modalità di scansione abilitata",
|
||||
"scan_mode": "Modalità di scansione",
|
||||
"show_trigger_status": "Mostra bordo pulsante quando attivato",
|
||||
"untrigger_reset": "Reset the view to default after untrigger",
|
||||
"untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione"
|
||||
},
|
||||
"timeout_seconds": "Ripristina la vista predefinita x secondi dopo l'azione dell'utente (0 = mai)",
|
||||
"update_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita",
|
||||
"update_force": "Aggiornamenti della scheda forza (ignora l'interazione dell'utente)",
|
||||
"update_seconds": "Aggiorna la visualizzazione predefinita ogni x secondi (0 = mai)",
|
||||
"views": {
|
||||
"clip": "Clip più recente",
|
||||
"clips": "Galleria delle clip",
|
||||
"current": "Vista corrente",
|
||||
"image": "Immagine statica",
|
||||
"live": "Dal vivo",
|
||||
"snapshot": "Snapshot più recente",
|
||||
"snapshots": "Galleria delle istantanee",
|
||||
"timeline": "Vista della timeline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"add_new_camera": "Aggiungi nuova telecamera",
|
||||
"button": "Pulsante",
|
||||
"camera": "Telecamera",
|
||||
"cameras": "Telecamere",
|
||||
"cameras_secondary": "Quali telecamere visualizzare su questa card",
|
||||
"delete": "Elimina",
|
||||
"dimensions": "Dimensioni",
|
||||
"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_secondary": "Opzioni di visualizzazione dell'immagine statica",
|
||||
"live": "Live",
|
||||
"live_secondary": "Opzioni di visualizzazione della telecamera live",
|
||||
"media_viewer": "Visualizzatore dei media",
|
||||
"media_viewer_secondary": "Visualizzatore per supporti statici (clip, istantanee o registrazioni)",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Opzioni di aspetto e funzionalità del menu",
|
||||
"move_down": "Sposta verso il basso",
|
||||
"move_up": "Sposta verso l'alto",
|
||||
"overrides": "La sovrascrittura è attiva",
|
||||
"overrides_secondary": "Rilevate sovrascritture della configurazione dinamica",
|
||||
"timeline": "Timeline",
|
||||
"timeline_secondary": "Opzioni della timeline degli eventi",
|
||||
"upgrade": "Aggiornamento",
|
||||
"upgrade_available": "È disponibile un aggiornamento della configurazione della scheda automatica",
|
||||
"view": "Visualizzazione",
|
||||
"view_secondary": "Cosa dovrebbe mostrare la carta e come mostrarla"
|
||||
},
|
||||
"error": {
|
||||
"could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine",
|
||||
"could_not_resolve": "Impossibile risolvere l'URL dei media",
|
||||
"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_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",
|
||||
"empty_response": "Ricevuto risposta vuota da Home Assistant per la richiesta",
|
||||
"failed_response": "Impossibile ricevere risposta da Home Assistant per la richiesta",
|
||||
"failed_retain": "Impossibile conservare l'evento",
|
||||
"failed_sign": "Impossibile firmare l'URL ad Home Assistant",
|
||||
"image_load_error": "L'immagine non può essere caricata",
|
||||
"invalid_configuration": "Configurazione non valida",
|
||||
"invalid_configuration_no_hint": "Nessun suggerimento di posizione disponibile (tipo difettoso o mancante?)",
|
||||
"invalid_elements_config": "Configurazione degli elementi di immagine non valida",
|
||||
"invalid_response": "Ricevuta una risposta non valida da Home Assistant per la richiesta",
|
||||
"jsmpeg_no_player": "Impossibile avviare JSMPEG Player",
|
||||
"jsmpeg_no_sign": "Impossibile recuperare o firmare il percorso WebSocket JSMPEG",
|
||||
"live_camera_not_found": "La telecamera configurata non è stata trovata",
|
||||
"live_camera_unavailable": "Telecamera non disponibile",
|
||||
"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_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",
|
||||
"reconnecting": "Riconnessione",
|
||||
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
|
||||
"troubleshooting": "Controllare la risoluzione dei problemi",
|
||||
"unknown": "Errore sconosciuto",
|
||||
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
|
||||
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
|
||||
"webrtc_card_waiting": "Aspettando che la scheda WebRTC si carichi ..."
|
||||
},
|
||||
"event": {
|
||||
"duration": "Durata",
|
||||
"in_progress": "In corso",
|
||||
"score": "Punteggio",
|
||||
"start": "Avvia"
|
||||
},
|
||||
"recording": {
|
||||
"events": "Eventi",
|
||||
"seek": "Cercare"
|
||||
},
|
||||
"thumbnail": {
|
||||
"no_thumbnail": "Nessuna miniatura disponibile",
|
||||
"retain_indefinitely": "L'evento sarà mantenuto indefinitamente",
|
||||
"timeline": "Vedi evento nella timeline"
|
||||
}
|
||||
}
|
||||
@@ -1,234 +1,361 @@
|
||||
{
|
||||
"common": {
|
||||
"version": "Versão",
|
||||
"frigate_card": "Cartão Frigate",
|
||||
"frigate_card_description": "Um cartão da Lovelace para usar com Frigate",
|
||||
"no_snapshots": "Sem snapshots",
|
||||
"live": "Ao Vivo",
|
||||
"no_clip": "Sem clip recente",
|
||||
"no_clips": "Sem clips",
|
||||
"no_snapshot": "Sem snapshot recente",
|
||||
"no_clip": "Sem clip recente",
|
||||
"live": "Ao Vivo"
|
||||
"no_snapshots": "Sem snapshots",
|
||||
"recordings": "Gravações",
|
||||
"version": "Versão"
|
||||
},
|
||||
"config": {
|
||||
"cameras": {
|
||||
"camera_entity": "Entidade da Câmera",
|
||||
"camera_name": "Nome da câmera do Frigate (detectado automaticamente pela entidade)",
|
||||
"client_id": "ID de cliente do Frigate (para >1 servidor da Frigate)",
|
||||
"id": "ID exclusivo para esta câmera nesse cartão",
|
||||
"label": "Filtro de rótulo/objeto do Frigate",
|
||||
"frigate_url": "URL do servidor Frigate",
|
||||
"title": "Título para esta câmera (detectado automaticamente pela entidade)",
|
||||
"dependencies": {
|
||||
"all_cameras": "Mostrar eventos para todas as câmeras nesta câmera",
|
||||
"cameras": "Mostrar eventos para câmeras específicas nesta câmera",
|
||||
"options": "Opções de dependência"
|
||||
},
|
||||
"frigate": {
|
||||
"camera_name": "Nome da câmera do Frigate (detectado automaticamente pela entidade)",
|
||||
"client_id": "ID do cliente do Frigate (para >1 servidor Frigate)",
|
||||
"label": "Filtro de rótulo/objeto do Frigate",
|
||||
"options": "Opções do Frigate",
|
||||
"url": "URL do servidor Frigate",
|
||||
"zone": "Zona do Frigate"
|
||||
},
|
||||
"icon": "Ícone para esta câmera (detectado automaticamente pela entidade)",
|
||||
"id": "ID exclusivo para esta câmera nesse cartão",
|
||||
"live_provider": "Provedor de visualização ao vivo para esta câmera",
|
||||
"live_providers": {
|
||||
"auto": "Automatico",
|
||||
"ha": "Home Assistant (HLS, LL-HLS ou WebRTC nativo)",
|
||||
"frigate-jsmpeg": "Frigate JSMpeg",
|
||||
"ha": "Home Assistant (HLS, LL-HLS ou WebRTC nativo)",
|
||||
"webrtc-card": "Cartão WebRTC (de @AlexxIT)"
|
||||
},
|
||||
"title": "Título para esta câmera (detectado automaticamente pela entidade)",
|
||||
"triggers": {
|
||||
"entities": "Acionar a partir de outras entidades",
|
||||
"motion": "Acionar detectando automaticamente o sensor de movimento",
|
||||
"occupancy": "Acionar detectando automaticamente o sensor de ocupação",
|
||||
"options": "Opções de acionamento"
|
||||
},
|
||||
"webrtc_card": {
|
||||
"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"
|
||||
},
|
||||
"zone": "Zona Frigate"
|
||||
},
|
||||
"view": {
|
||||
"camera_select": "Visualização de câmeras recém-selecionadas",
|
||||
"default": "Visualização padrão",
|
||||
"views": {
|
||||
"live": "Visualização ao vivo",
|
||||
"clip": "Clipe mais recente",
|
||||
"clips": "Galeria de clipes",
|
||||
"snapshot": "Snapshot mais recente",
|
||||
"snapshots": "Galeria de Snapshots",
|
||||
"image": "Imagem estática",
|
||||
"current": "Visualização atual"
|
||||
},
|
||||
"timeout_seconds": "Redefinir para a visualização padrão X segundos após a ação do usuário (0 = nunca)",
|
||||
"update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada",
|
||||
"update_force": "Forçar atualizações do cartão (ignore a interação do usuário)",
|
||||
"update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)"
|
||||
},
|
||||
"event_gallery": {
|
||||
"min_columns": "Número mínimo de colunas"
|
||||
},
|
||||
"event_viewer": {
|
||||
"auto_play": "Reproduzir mídia automaticamente",
|
||||
"auto_unmute": "Ativar mídia automaticamente",
|
||||
"draggable": "Visualizador de eventos pode ser arrastado/deslizado",
|
||||
"lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel",
|
||||
"transition_effect": "Efeito de transição do Visualizador de eventos",
|
||||
"transition_effects": {
|
||||
"none": "Sem transição",
|
||||
"slide": "Transição de slides"
|
||||
},
|
||||
"controls": {
|
||||
"next_previous": {
|
||||
"style": "Estilo do controle próximo e anterior do Visualizador de eventos",
|
||||
"size": "Tamanho do controle próximo e anterior do Visualizador de eventos (por exemplo, '48px')",
|
||||
"styles": {
|
||||
"thumbnails": "Miniaturas",
|
||||
"chevrons": "Chevrons",
|
||||
"none": "Nenhum"
|
||||
}
|
||||
},
|
||||
"thumbnails": {
|
||||
"mode": "Modo de miniaturas do Visualizador de eventos",
|
||||
"size": "Tamanho das miniaturas do Visualizador de eventos (por exemplo, '100px')",
|
||||
"modes": {
|
||||
"below": "Miniaturas abaixo da mídia",
|
||||
"above": "Miniaturas acima da mídia",
|
||||
"none": "Sem miniaturas"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"mode": "Modo de exibição de título de mídia do Visualizador de eventos",
|
||||
"modes": {
|
||||
"none": "Sem exibição de título",
|
||||
"popup-top-left": "Pop-up no canto superior esquerdo",
|
||||
"popup-top-right": "Pop-up no canto superior direito",
|
||||
"popup-bottom-left": "Pop-up no canto inferior esquerdo",
|
||||
"popup-bottom-right": "Pop-up no canto inferior direito"
|
||||
},
|
||||
"duration_seconds": "Segundos de exibição do pop-up no Visualizador de Eventos (0 = para sempre)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"preload": "Pré-carregar a visualização ao vivo em segundo plano",
|
||||
"draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada",
|
||||
"lazy_load": "As câmeras ao vivo são carregadas lentamente",
|
||||
"lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente",
|
||||
"auto_unmute": "Ativar automaticamente o som das câmeras ao vivo",
|
||||
"transition_effect": "Efeito de transição de câmera ao vivo",
|
||||
"common": {
|
||||
"media_action_conditions": {
|
||||
"all": "Todas as oportunidades",
|
||||
"hidden": "Ao ocultar o navegador/aba",
|
||||
"never": "Nunca",
|
||||
"selected": "Ao selecionar",
|
||||
"unselected": "Ao desselecionar",
|
||||
"visible": "Ao mostrar o navegador/aba"
|
||||
}
|
||||
},
|
||||
"dimensions": {
|
||||
"aspect_ratio": "Proporção padrão (e.g. '16:9')",
|
||||
"aspect_ratio_mode": "Modo de proporção",
|
||||
"aspect_ratio_modes": {
|
||||
"dynamic": "A proporção se ajusta à mídia",
|
||||
"static": "Proporção estática",
|
||||
"unconstrained": "Proporção irrestrita"
|
||||
}
|
||||
},
|
||||
"event_gallery": {
|
||||
"controls": {
|
||||
"next_previous": {
|
||||
"style": "Estilo do controle próximo e anterior na visualização ao vivo",
|
||||
"size": "Tamanho de controle próximo e anterior na visualização ao vivo (por exemplo, '48px')",
|
||||
"styles": {
|
||||
"chevrons": "Chevrons",
|
||||
"icons": "Ícones",
|
||||
"none": "Nenhum"
|
||||
}
|
||||
},
|
||||
"options": "Controles da Galeria de Eventos",
|
||||
"thumbnails": {
|
||||
"mode": "Miniaturas do modo ao vivo",
|
||||
"size": "Tamanho das miniaturas ao vivo (e.g. '100px')",
|
||||
"media": "Se deve mostrar miniaturas de clipes ou snapshots",
|
||||
"medias": {
|
||||
"clips": "Miniaturas de clipes",
|
||||
"snapshots": "Miniaturas de Snapshots"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"mode": "Modo de exibição de título de mídia ao vivo",
|
||||
"duration_seconds": "Segundos para exibir o pop-up na visualização ao vivo (0 = para sempre)"
|
||||
"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": {
|
||||
"mode": "Modo de visualização de imagem",
|
||||
"modes": {
|
||||
"camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera",
|
||||
"screensaver": "Logo Frigate embutido",
|
||||
"url": "Imagem arbitrária especificada por URL",
|
||||
"camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera"
|
||||
"url": "Imagem arbitrária especificada por URL"
|
||||
},
|
||||
"url": "Imagem arbitrária especificada por URL",
|
||||
"refresh_seconds": "Número de segundos após o qual atualizar (0 = nunca)"
|
||||
"refresh_seconds": "Número de segundos após o qual atualizar (0 = nunca)",
|
||||
"url": "Imagem arbitrária especificada por URL"
|
||||
},
|
||||
"live": {
|
||||
"auto_mute": "Silenciar câmeras ao vivo automaticamente",
|
||||
"auto_pause": "Pausar câmeras ao vivo automaticamente",
|
||||
"auto_play": "Reproduzir câmeras ao vivo automaticamente",
|
||||
"auto_unmute": "Ativar automaticamente o som das câmeras ao vivo",
|
||||
"controls": {
|
||||
"next_previous": {
|
||||
"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",
|
||||
"lazy_load": "As câmeras ao vivo são carregadas lentamente",
|
||||
"lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente",
|
||||
"preload": "Pré-carregar a visualização ao vivo em segundo plano",
|
||||
"show_image_during_load": "",
|
||||
"transition_effect": "Efeito de transição de câmera ao vivo"
|
||||
},
|
||||
"media_viewer": {
|
||||
"auto_mute": "Silenciar mídia automaticamente",
|
||||
"auto_pause": "Pausar mídia automaticamente",
|
||||
"auto_play": "Reproduzir mídia automaticamente",
|
||||
"auto_unmute": "Ativar mídia automaticamente",
|
||||
"controls": {
|
||||
"next_previous": {
|
||||
"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",
|
||||
"lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel",
|
||||
"transition_effect": "Efeito de transição do Visualizador de eventos",
|
||||
"transition_effects": {
|
||||
"none": "Sem transição",
|
||||
"slide": "Transição de slides"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"alignment": "Alinhamento do menu",
|
||||
"alignments": {
|
||||
"bottom": "Alinhado à parte inferior",
|
||||
"left": "Alinhado à esquerda",
|
||||
"right": "Alinhado à direita",
|
||||
"top": "Alinhado ao topo"
|
||||
},
|
||||
"button_size": "Tamanho do botão de menu (e.g. '40px')",
|
||||
"buttons": {
|
||||
"alignment": "Alinhamento do botão",
|
||||
"alignments": {
|
||||
"matching": "Mesmo alinhamento do menu",
|
||||
"opposing": "Opor-se ao alinhamento do menu"
|
||||
},
|
||||
"cameras": "Selecionar câmera",
|
||||
"clips": "Clipes",
|
||||
"download": "Baixe a mídia do evento",
|
||||
"enabled": "Botão ativado",
|
||||
"frigate": "Frigate menu / Visualização padrão",
|
||||
"frigate_ui": "Frigate Interface de usuário",
|
||||
"fullscreen": "Tela cheia",
|
||||
"download": "Baixe a mídia do evento",
|
||||
"cameras": "Selecionar câmera"
|
||||
"icon": "Ícone",
|
||||
"image": "Imagem",
|
||||
"live": "Ao vivo",
|
||||
"media_player": "Enviar para o reprodutor de mídia",
|
||||
"priority": "Prioridade",
|
||||
"snapshots": "Instantâneos",
|
||||
"timeline": "Linha do tempo"
|
||||
},
|
||||
"mode": "Modo do menu",
|
||||
"modes": {
|
||||
"position": "Posição do menu",
|
||||
"positions": {
|
||||
"bottom": "Posicionado na parte inferior",
|
||||
"left": "Posicionado à esquerda",
|
||||
"right": "Posicionado à direita",
|
||||
"top": "Posicionado no topo"
|
||||
},
|
||||
"style": "Estilo do menu",
|
||||
"styles": {
|
||||
"hidden": "Menu oculto",
|
||||
"hover": "Menu suspenso",
|
||||
"none": "Sem menu",
|
||||
"hidden-top": "Ocultar parte superior",
|
||||
"hidden-left": "Ocultar esquerda",
|
||||
"hidden-bottom": "Ocultar parte inferior",
|
||||
"hidden-right": "Ocultar direita",
|
||||
"overlay-top": "Sobreposição da parte superior",
|
||||
"overlay-left": "Sobreposição esquerda",
|
||||
"overlay-bottom": "Sobreposição da parte inferior",
|
||||
"overlay-right": "Sobreposição direita",
|
||||
"hover-top": "Passar para cima",
|
||||
"hover-left": "Passar para esquerda",
|
||||
"hover-bottom": "Passar para baixo",
|
||||
"hover-right": "Passar para direita",
|
||||
"above": "Acima",
|
||||
"below": "Abaixo"
|
||||
},
|
||||
"button_size": "Tamanho do botão de menu (e.g. '40px')"
|
||||
},
|
||||
"dimensions": {
|
||||
"aspect_ratio": "Proporção padrão (e.g. '16:9')",
|
||||
"aspect_ratio_mode": "Modo de proporção",
|
||||
"aspect_ratio_modes": {
|
||||
"unconstrained": "Proporção irrestrita",
|
||||
"dynamic": "A proporção se ajusta à mídia",
|
||||
"static": "Proporção estática"
|
||||
"outside": "Menu externo",
|
||||
"overlay": "Menu sobreposto"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"timeline": {
|
||||
"clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)",
|
||||
"controls": {
|
||||
"options": "Controles de linha do tempo",
|
||||
"thumbnails": {
|
||||
"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",
|
||||
"medias": {
|
||||
"all": "Todos os tipos de mídia",
|
||||
"clips": "Clipes",
|
||||
"snapshots": "Instantâneos"
|
||||
},
|
||||
"show_recordings": "Mostrar gravações",
|
||||
"window_seconds": "A duração padrão da visualização da linha do tempo em segundos"
|
||||
},
|
||||
"view": {
|
||||
"camera_select": "Visualização de câmeras recém-selecionadas",
|
||||
"dark_mode": "Modo escuro",
|
||||
"dark_modes": {
|
||||
"auto": "Automático",
|
||||
"off": "Desligado",
|
||||
"on": "Ligado"
|
||||
},
|
||||
"default": "Visualização padrão",
|
||||
"scan": {
|
||||
"enabled": "Modo scan ativado",
|
||||
"scan_mode": "Modo scan",
|
||||
"show_trigger_status": "Pulsar borda quando acionado",
|
||||
"untrigger_reset": "Redefinir a visualização para o padrão após desacionar",
|
||||
"untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
|
||||
},
|
||||
"timeout_seconds": "Redefinir para a visualização padrão X segundos após a ação do usuário (0 = nunca)",
|
||||
"update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada",
|
||||
"update_force": "Forçar atualizações do cartão (ignore a interação do usuário)",
|
||||
"update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)",
|
||||
"views": {
|
||||
"clip": "Clipe mais recente",
|
||||
"clips": "Galeria de clipes",
|
||||
"current": "Visualização atual",
|
||||
"image": "Imagem estática",
|
||||
"live": "Visualização ao vivo",
|
||||
"snapshot": "Snapshot mais recente",
|
||||
"snapshots": "Galeria de Snapshots",
|
||||
"timeline": "Visualização da linha do tempo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"add_new_camera": "Adicionar nova câmera",
|
||||
"button": "Botão",
|
||||
"camera": "Câmera",
|
||||
"cameras": "Câmeras",
|
||||
"cameras_secondary": "Quais câmeras renderizar neste cartão",
|
||||
"view": "Visualizar",
|
||||
"view_secondary": "O que o cartão deve mostrar e como mostrá-lo",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Opções de aparência do menu",
|
||||
"live": "Ao vivo",
|
||||
"live_secondary": "Opções de visualização da câmera ao vivo",
|
||||
"event_gallery": "Galeria de eventos",
|
||||
"event_gallery_secondary": "Opções da galeria de Snapshots e clipes",
|
||||
"event_viewer": "Visualizador de eventos",
|
||||
"event_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
|
||||
"image": "Imagem",
|
||||
"image_secondary": "Opções de visualização de imagem estática",
|
||||
"delete": "Excluir",
|
||||
"dimensions": "Dimensões",
|
||||
"dimensions_secondary": "Dimensões e opções de forma",
|
||||
"show_button": "Mostrar botão",
|
||||
"event_gallery": "Galeria de eventos",
|
||||
"event_gallery_secondary": "Opções da galeria de Snapshots e clipes",
|
||||
"image": "Imagem",
|
||||
"image_secondary": "Opções de visualização de imagem estática",
|
||||
"live": "Ao vivo",
|
||||
"live_secondary": "Opções de visualização da câmera ao vivo",
|
||||
"media_viewer": "Visualizador de eventos",
|
||||
"media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
|
||||
"menu": "Menu",
|
||||
"menu_secondary": "Opções de aparência do menu",
|
||||
"move_down": "Descer",
|
||||
"move_up": "Subir",
|
||||
"overrides": "As substituições estão ativas",
|
||||
"overrides_secondary": "Substituições de configuração dinâmica detectadas",
|
||||
"timeline": "Linha do tempo",
|
||||
"timeline_secondary": "Opções do evento da linha do tempo",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_available": "Um upgrade automático da configuração de cartão está disponível",
|
||||
"delete": "Excluir",
|
||||
"move_up": "Subir",
|
||||
"move_down": "Descer",
|
||||
"add_new_camera": "Adicionar nova câmera",
|
||||
"camera": "Câmera",
|
||||
"overrides": "As substituições estão ativas",
|
||||
"overrides_secondary": "Substituições de configuração dinâmica detectadas"
|
||||
"view": "Visualizar",
|
||||
"view_secondary": "O que o cartão deve mostrar e como mostrá-lo"
|
||||
},
|
||||
"error": {
|
||||
"empty_response": "Sem resposta do Home Assistant para a solicitação",
|
||||
"invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação",
|
||||
"invalid_keys": "Chaves inválidas",
|
||||
"unknown": "Erro desconhecido",
|
||||
"troubleshooting": "Verifique a solução de problemas",
|
||||
"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",
|
||||
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo",
|
||||
"live_camera_unavailable": "camera_entity configurada não está disponível",
|
||||
"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_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",
|
||||
"empty_response": "Sem resposta do Home Assistant para a solicitação",
|
||||
"failed_response": "Falha ao receber resposta do Home Assistant para solicitação",
|
||||
"failed_retain": "Não foi possível reter o evento",
|
||||
"failed_sign": "Não foi possível assinar a URL do Home Assistant",
|
||||
"image_load_error": "A imagem não pôde ser carregada",
|
||||
"invalid_configuration": "Configuração inválida",
|
||||
"invalid_configuration_no_hint": "Nenhuma dica de local disponível (tipo incorreto ou ausente?)",
|
||||
"invalid_elements_config": "Configuração de elementos de imagem inválida",
|
||||
"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_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG",
|
||||
"live_camera_not_found": "",
|
||||
"live_camera_unavailable": "",
|
||||
"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_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",
|
||||
"reconnecting": "Reconectando",
|
||||
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
|
||||
"troubleshooting": "Verifique a solução de problemas",
|
||||
"unknown": "Erro desconhecido",
|
||||
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
|
||||
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
|
||||
"webrtc_card_waiting": "Aguardando o cartão WebRTC carregar ...",
|
||||
"no_cameras": "Nenhuma câmera válida encontrada, você deve configurar pelo menos uma câmera",
|
||||
"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",
|
||||
"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",
|
||||
"could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
|
||||
"invalid_elements_config": "Configuração de elementos de imagem inválida",
|
||||
"jsmpeg_no_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG",
|
||||
"jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG",
|
||||
"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",
|
||||
"download_no_media": "Nenhuma mídia para download",
|
||||
"download_no_event_id": "Não foi possível extrair o Frigate ID do evento da mídia",
|
||||
"download_sign_failed": "Não foi possível assinar o URL de mídia para download"
|
||||
"webrtc_card_waiting": "Aguardando o cartão WebRTC carregar ..."
|
||||
},
|
||||
"event": {
|
||||
"duration": "Duração",
|
||||
"in_progress": "Em andamento",
|
||||
"score": "Pontuação",
|
||||
"start": "Início"
|
||||
},
|
||||
"recording": {
|
||||
"events": "Eventos",
|
||||
"seek": "Procurar"
|
||||
},
|
||||
"thumbnail": {
|
||||
"no_thumbnail": "Nenhuma miniatura disponível",
|
||||
"retain_indefinitely": "Evento será retido por tempo indeterminado",
|
||||
"timeline": "Ver evento na linha do tempo"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,46 @@
|
||||
import * as en from './languages/en.json';
|
||||
import * as pt_BR from './languages/pt_br.json';
|
||||
import * as pt_BR from './languages/pt-BR.json';
|
||||
import * as it from './languages/it.json';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const languages: any = {
|
||||
en: en,
|
||||
pt_BR: pt_BR,
|
||||
it: it,
|
||||
};
|
||||
|
||||
export function localize(string: string, search = '', replace = ''): string {
|
||||
// Get the browser language.
|
||||
let lang = localStorage
|
||||
.getItem('selectedLanguage')
|
||||
?.replace(/['"]+/g, '')
|
||||
.replace('-', '_');
|
||||
export function getLanguage(): string {
|
||||
const canonicalizeLanguage = (language?: string | null): string | null => {
|
||||
if (!language) {
|
||||
return null;
|
||||
}
|
||||
return language.replace('-', '_');
|
||||
};
|
||||
|
||||
// If that's not specified, try to find the Home Assistant language.
|
||||
if (!lang) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hass = (document.querySelector('home-assistant') as any)?.hass;
|
||||
lang = hass.selectedLanguage || hass.language;
|
||||
// Try the HA language first...
|
||||
let lang: string | null = null;
|
||||
const HALanguage = localStorage.getItem('selectedLanguage');
|
||||
if (HALanguage) {
|
||||
const selectedLanguage = canonicalizeLanguage(JSON.parse(HALanguage));
|
||||
if (selectedLanguage) {
|
||||
lang = selectedLanguage;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to English is there's still no language setting.
|
||||
lang = lang || 'en';
|
||||
// Then fall back to the browser language.
|
||||
if (!lang) {
|
||||
for (const language of navigator.languages) {
|
||||
const canonicalLanguage = canonicalizeLanguage(language);
|
||||
if (canonicalLanguage && canonicalLanguage in languages) {
|
||||
lang = language;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lang || 'en';
|
||||
}
|
||||
|
||||
export function localize(string: string, search = '', replace = ''): string {
|
||||
const lang = getLanguage();
|
||||
let translated: string;
|
||||
|
||||
try {
|
||||
@@ -31,7 +49,7 @@ export function localize(string: string, search = '', replace = ''): string {
|
||||
translated = string.split('.').reduce((o, i) => o[i], languages['en']);
|
||||
}
|
||||
|
||||
if (translated === undefined) {
|
||||
if (!translated) {
|
||||
translated = string.split('.').reduce((o, i) => o[i], languages['en']);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
// available as compilation time.
|
||||
// ====================================================================
|
||||
|
||||
import { Ref, createRef, ref } from 'lit/directives/ref.js';
|
||||
import { TemplateResult, css, html } from 'lit';
|
||||
import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { dispatchMediaShowEvent } from '../common.js';
|
||||
import { query } from 'lit/decorators/query.js';
|
||||
import { FrigateCardMediaPlayer } from '../types.js';
|
||||
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
||||
import './ha-hls-player';
|
||||
import './ha-web-rtc-player';
|
||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||
|
||||
customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
// ========================================================================================
|
||||
@@ -38,7 +42,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
@customElement('frigate-card-ha-camera-stream')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
class FrigateCardHaCameraStream extends customElements.get('ha-camera-stream') {
|
||||
protected _playerRef: Ref<HTMLElement> = createRef();
|
||||
// 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.
|
||||
@query('#player')
|
||||
protected _player: FrigateCardMediaPlayer;
|
||||
|
||||
// ========================================================================================
|
||||
// Minor modifications from:
|
||||
@@ -49,28 +56,35 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
* Play the video.
|
||||
*/
|
||||
public play(): void {
|
||||
this._playerRef.value?.play();
|
||||
this._player?.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the video.
|
||||
*/
|
||||
public pause(): void {
|
||||
this._playerRef.value?.pause();
|
||||
this._player?.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the video.
|
||||
*/
|
||||
public mute(): void {
|
||||
this.muted = true;
|
||||
this._player?.mute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the video.
|
||||
*/
|
||||
public unmute(): void {
|
||||
this.muted = false;
|
||||
this._player?.unmute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video (unsupported).
|
||||
*/
|
||||
public seek(seconds: number): void {
|
||||
this._player?.seek(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,8 +99,8 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
if (this._shouldRenderMJPEG) {
|
||||
return html`
|
||||
<img
|
||||
@load=${(e) => {
|
||||
dispatchMediaShowEvent(this, e);
|
||||
@load=${(ev: Event) => {
|
||||
dispatchMediaLoadedEvent(this, ev);
|
||||
}}
|
||||
.src=${typeof this._connected == 'undefined' || this._connected
|
||||
? computeMJPEGStreamUrl(this.stateObj)
|
||||
@@ -98,7 +112,7 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
if (this.stateObj.attributes.frontend_stream_type === STREAM_TYPE_HLS) {
|
||||
return this._url
|
||||
? html` <frigate-card-ha-hls-player
|
||||
${ref(this._playerRef)}
|
||||
id="player"
|
||||
?autoplay=${false}
|
||||
playsinline
|
||||
.allowExoPlayer=${this.allowExoPlayer}
|
||||
@@ -111,7 +125,7 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
}
|
||||
if (this.stateObj.attributes.frontend_stream_type === STREAM_TYPE_WEB_RTC) {
|
||||
return html`<frigate-card-ha-web-rtc-player
|
||||
${ref(this._playerRef)}
|
||||
id="player"
|
||||
?autoplay=${false}
|
||||
playsinline
|
||||
.muted=${this.muted}
|
||||
@@ -125,13 +139,24 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
unsafeCSS(liveHAComponentsStyle),
|
||||
css`
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-ha-camera-stream': FrigateCardHaCameraStream;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,43 +9,64 @@
|
||||
// available as compilation time.
|
||||
// ====================================================================
|
||||
|
||||
import { TemplateResult, css, html } from 'lit';
|
||||
import { Ref, createRef, ref } from 'lit/directives/ref.js';
|
||||
import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { dispatchErrorMessageEvent, dispatchMediaShowEvent } from '../common.js';
|
||||
import { query } from 'lit/decorators/query.js';
|
||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||
|
||||
customElements.whenDefined('ha-hls-player').then(() => {
|
||||
@customElement('frigate-card-ha-hls-player')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
class FrigateCardHaHlsPlayer extends customElements.get('ha-hls-player') {
|
||||
protected _videoRef: Ref<HTMLVideoElement> = createRef();
|
||||
// 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.
|
||||
@query('#video')
|
||||
protected _video: HTMLVideoElement;
|
||||
|
||||
/**
|
||||
* Play the video.
|
||||
*/
|
||||
public play(): void {
|
||||
this._videoRef.value?.play();
|
||||
this._video?.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the video.
|
||||
*/
|
||||
public pause(): void {
|
||||
this._videoRef.value?.pause();
|
||||
this._video?.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the video.
|
||||
*/
|
||||
public mute(): void {
|
||||
this.muted = true;
|
||||
// The muted property is only for the initial muted state. Must explicitly
|
||||
// set the muted on the video player to make the change dynamic.
|
||||
if (this._video) {
|
||||
this._video.muted = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the video.
|
||||
*/
|
||||
public unmute(): void {
|
||||
this.muted = false;
|
||||
// See note in mute().
|
||||
if (this._video) {
|
||||
this._video.muted = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video.
|
||||
*/
|
||||
public seek(seconds: number): void {
|
||||
if (this._video) {
|
||||
this._video.currentTime = seconds;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
@@ -54,19 +75,18 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
||||
// =====================================================================================
|
||||
protected render(): TemplateResult {
|
||||
if (this._error) {
|
||||
// Use native Frigate card error handling, and attach the entityid to
|
||||
// clarify which camera the error refers to.
|
||||
// Use native Frigate card error handling.
|
||||
return dispatchErrorMessageEvent(this, this._error);
|
||||
}
|
||||
return html`
|
||||
<video
|
||||
${ref(this._videoRef)}
|
||||
id="video"
|
||||
?autoplay=${this.autoPlay}
|
||||
.muted=${this.muted}
|
||||
?playsinline=${this.playsInline}
|
||||
?controls=${this.controls}
|
||||
@loadeddata=${(e) => {
|
||||
dispatchMediaShowEvent(this, e);
|
||||
dispatchMediaLoadedEvent(this, e);
|
||||
}}
|
||||
></video>
|
||||
`;
|
||||
@@ -75,18 +95,24 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
unsafeCSS(liveHAComponentsStyle),
|
||||
css`
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
video {
|
||||
object-fit: contain;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`
|
||||
]
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"frigate-card-ha-hls-player": FrigateCardHaHlsPlayer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,43 +9,64 @@
|
||||
// available as compilation time.
|
||||
// ====================================================================
|
||||
|
||||
import { Ref, createRef, ref } from 'lit/directives/ref.js';
|
||||
import { TemplateResult, html } from 'lit';
|
||||
import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { dispatchErrorMessageEvent, dispatchMediaShowEvent } from '../common.js';
|
||||
import { query } from 'lit/decorators/query.js';
|
||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||
|
||||
customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
@customElement('frigate-card-ha-web-rtc-player')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
class FrigateCardHaWebRtcPlayer extends customElements.get('ha-web-rtc-player') {
|
||||
protected _videoRef: Ref<HTMLVideoElement> = createRef();
|
||||
// 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.
|
||||
@query('#remote-stream')
|
||||
protected _video: HTMLVideoElement;
|
||||
|
||||
/**
|
||||
* Play the video.
|
||||
*/
|
||||
public play(): void {
|
||||
this._videoRef.value?.play();
|
||||
this._video?.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the video.
|
||||
*/
|
||||
public pause(): void {
|
||||
this._videoRef.value?.pause();
|
||||
this._video?.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the video.
|
||||
*/
|
||||
public mute(): void {
|
||||
this.muted = true;
|
||||
// The muted property is only for the initial muted state. Must explicitly
|
||||
// set the muted on the video player to make the change dynamic.
|
||||
if (this._video) {
|
||||
this._video.muted = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the video.
|
||||
*/
|
||||
public unmute(): void {
|
||||
this.muted = false;
|
||||
// See note in mute().
|
||||
if (this._video) {
|
||||
this._video.muted = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video.
|
||||
*/
|
||||
public seek(seconds: number): void {
|
||||
if (this._video) {
|
||||
this._video.currentTime = seconds;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
@@ -56,23 +77,43 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
if (this._error) {
|
||||
// Use native Frigate card error handling, and attach the entityid to
|
||||
// clarify which camera the error refers to.
|
||||
return dispatchErrorMessageEvent(
|
||||
this,
|
||||
`${this._error} (${this.entityid})`);
|
||||
return dispatchErrorMessageEvent(this, `${this._error} (${this.entityid})`);
|
||||
}
|
||||
return html`
|
||||
<video
|
||||
${ref(this._videoRef)}
|
||||
id="remote-stream"
|
||||
?autoplay=${this.autoPlay}
|
||||
.muted=${this.muted}
|
||||
?playsinline=${this.playsInline}
|
||||
?controls=${this.controls}
|
||||
@loadeddata=${(e) => {
|
||||
dispatchMediaShowEvent(this, e);
|
||||
dispatchMediaLoadedEvent(this, e);
|
||||
}}
|
||||
></video>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
unsafeCSS(liveHAComponentsStyle),
|
||||
css`
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-ha-web-rtc-player': FrigateCardHaWebRtcPlayer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { homeAssistantWSRequest } from './common.js';
|
||||
import {
|
||||
BrowseMediaSource,
|
||||
ExtendedHomeAssistant,
|
||||
ResolvedMedia,
|
||||
resolvedMediaSchema,
|
||||
} from './types.js';
|
||||
import QuickLRU from 'quick-lru';
|
||||
|
||||
// It's important the cache size be at least as large as the largest likely
|
||||
// media query or media items will from a given query will be evicted for other
|
||||
// items in the same query (which would result in only partial results being
|
||||
// returned to the user).
|
||||
// Note: Each entry is about 400 bytes.
|
||||
|
||||
const RESOLVED_MEDIA_CACHE_SIZE = 1000;
|
||||
|
||||
export class ResolvedMediaCache {
|
||||
protected _cache: QuickLRU<string, ResolvedMedia>;
|
||||
|
||||
constructor() {
|
||||
this._cache = new QuickLRU({maxSize: RESOLVED_MEDIA_CACHE_SIZE});
|
||||
}
|
||||
|
||||
public has(id: string): boolean {
|
||||
return this._cache.has(id);
|
||||
}
|
||||
|
||||
public get(id: string): ResolvedMedia | undefined {
|
||||
return this._cache.get(id);
|
||||
}
|
||||
|
||||
public set(id: string, resolvedMedia: ResolvedMedia): void {
|
||||
this._cache.set(id, resolvedMedia);
|
||||
}
|
||||
}
|
||||
|
||||
export class ResolvedMediaUtil {
|
||||
static async resolveMedia(
|
||||
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||
mediaSource?: BrowseMediaSource,
|
||||
cache?: ResolvedMediaCache,
|
||||
): Promise<ResolvedMedia | null> {
|
||||
if (!mediaSource) {
|
||||
return null;
|
||||
}
|
||||
const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined;
|
||||
if (cachedValue) {
|
||||
return cachedValue;
|
||||
}
|
||||
const request = {
|
||||
type: 'media_source/resolve_media',
|
||||
media_content_id: mediaSource.media_content_id,
|
||||
};
|
||||
const resolvedMedia = await homeAssistantWSRequest(
|
||||
hass,
|
||||
resolvedMediaSchema,
|
||||
request,
|
||||
);
|
||||
if (cache && resolvedMedia) {
|
||||
cache.set(mediaSource.media_content_id, resolvedMedia);
|
||||
}
|
||||
return resolvedMedia;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
// Anchor absolutes to the outer part of the card.
|
||||
// Anchor absolutes to the outermost part of the card.
|
||||
position: relative;
|
||||
|
||||
// Different browsers use different colors as their fullscreen background,
|
||||
// this ensures the same experience across all browsers.
|
||||
background-color: var(--card-background-color);
|
||||
|
||||
// The primary border-radius used is the div.main. This is only useful for
|
||||
// keeping the background-color within the radius.
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
}
|
||||
|
||||
.container {
|
||||
:host([dark]) {
|
||||
filter: brightness(75%);
|
||||
}
|
||||
|
||||
div.main {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
@@ -14,49 +26,49 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
// Need to apply the border radius on the container level, as the ha-card has
|
||||
// overflow visible in order to allow a submenu to extend beyond the card
|
||||
// boundary.
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
}
|
||||
// Necessary to get Safari to show border-radius correctly.
|
||||
transform: translateZ(0);
|
||||
|
||||
.frigate-card-contents {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
margin: auto;
|
||||
overflow: auto;
|
||||
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
|
||||
scrollbar-width: none; /* Hide scrollbar: Firefox */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// Include the borders in the sizing (to keep the triggered warning pulse
|
||||
// visible in fullscreen).
|
||||
box-sizing: border-box;
|
||||
|
||||
// Hide scrollbar: Firefox
|
||||
scrollbar-width: none;
|
||||
// Hide scrollbar: IE and Edge
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.frigate-card-contents::-webkit-scrollbar {
|
||||
div.main::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* When forcing aspect ratio absolute positioning is required */
|
||||
.frigate-card-contents.absolute {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
// Need to apply the border radius on the container level, as the ha-card has
|
||||
// overflow visible in order to allow a submenu to extend beyond the card
|
||||
// boundary. Need to be able to selectively curve top or bottom depending on
|
||||
// whether the outside menu is being shown. There's no way to select 'preceding
|
||||
// element' in CSS, so this must be implemented in JS.
|
||||
div.main.curve-top {
|
||||
border-top-left-radius: var(--ha-card-border-radius, 4px);
|
||||
border-top-right-radius: var(--ha-card-border-radius, 4px);
|
||||
}
|
||||
div.main.curve-bottom {
|
||||
border-bottom-left-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 */
|
||||
frigate-card-menu[data-mode^=hover-] {
|
||||
frigate-card-menu[data-style='hover'] {
|
||||
z-index: 1;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.outer + frigate-card-menu[data-mode^=hover-] {
|
||||
opacity: 0.0;
|
||||
.main + frigate-card-menu[data-style='hover'] {
|
||||
opacity: 0;
|
||||
}
|
||||
.outer:hover + frigate-card-menu[data-mode^=hover-], frigate-card-menu[data-mode^=hover-]:hover {
|
||||
opacity: 1.0;
|
||||
frigate-card-menu[data-style='hover']:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
ha-card {
|
||||
@@ -70,34 +82,64 @@ ha-card {
|
||||
height: 100%;
|
||||
position: static;
|
||||
color: var(--secondary-text-color, white);
|
||||
background-color: var(--secondary-background-color, black);
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
}
|
||||
ha-card.triggered {
|
||||
@keyframes warning-pulse {
|
||||
0% {
|
||||
border: solid 2px rgba(0, 0, 0, 0);
|
||||
}
|
||||
50% {
|
||||
border: solid 2px var(--warning-color);
|
||||
}
|
||||
100% {
|
||||
border: solid 2px rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
animation: warning-pulse 5s infinite;
|
||||
}
|
||||
|
||||
frigate-card-live.hidden {
|
||||
|
||||
frigate-card-live.hidden {
|
||||
// Live view will be rendered but hidden for live preloading.
|
||||
display: none;
|
||||
}
|
||||
|
||||
/************
|
||||
* Fullscreen
|
||||
*************/
|
||||
|
||||
// Browsers will reject invalid whole CSS selectors if one selector is bad, so
|
||||
// need to use mixin here instead of just comma-separated selectors.
|
||||
// - Related: https://stackoverflow.com/questions/16982449/why-isnt-it-possible-to-combine-vendor-specific-pseudo-elements-classes-into-on
|
||||
@mixin fullscreen-ha-card {
|
||||
// Make the background black, to give it the expected fullscreen feel.
|
||||
background-color: black;
|
||||
// Hide corners on Safari fullscreen.
|
||||
border-radius: 0px;
|
||||
|
||||
// Theme styles may set these which are not helpful in fullscreen mode, reset
|
||||
// them.
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Need to use an id selector here to overcome theme styling that may
|
||||
// incorrectly apply to ha-card style.
|
||||
:host(:fullscreen) #ha-card {
|
||||
@include fullscreen-ha-card;
|
||||
}
|
||||
:host(:-webkit-full-screen) #ha-card {
|
||||
@include fullscreen-ha-card;
|
||||
}
|
||||
|
||||
@mixin fullscreen-no-rounded-corners {
|
||||
// Hide corners on Safari fullscreen.
|
||||
border-radius: 0px;
|
||||
}
|
||||
:host(:fullscreen) ha-card {
|
||||
@include fullscreen-ha-card;
|
||||
|
||||
:host(:fullscreen) div.main,
|
||||
:host(:fullscreen) frigate-card-menu {
|
||||
@include fullscreen-no-rounded-corners;
|
||||
}
|
||||
:host(:-webkit-full-screen) ha-card {
|
||||
@include fullscreen-ha-card;
|
||||
}
|
||||
:host(:-moz-full-screen) ha-card {
|
||||
@include fullscreen-ha-card;
|
||||
}
|
||||
:host(:-ms-fullscreen) ha-card {
|
||||
@include fullscreen-ha-card;
|
||||
:host(:-webkit-full-screen) div.main,
|
||||
:host(:-webkit-full-screen) frigate-card-menu {
|
||||
@include fullscreen-no-rounded-corners;
|
||||
}
|
||||
|
||||
@@ -4,16 +4,9 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
img,video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.embla {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -28,6 +21,12 @@ img,video {
|
||||
-khtml-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
:host([direction='vertical']) .embla__container {
|
||||
flex-direction: column;
|
||||
}
|
||||
:host([direction='horizontal']) .embla__container {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.embla__viewport {
|
||||
width: 100%;
|
||||
@@ -47,14 +46,10 @@ img,video {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.embla__slide {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
:host([direction='vertical']) ::slotted(.embla__slide) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
:host([direction='horizontal']) ::slotted(.embla__slide) {
|
||||
margin-right: 5px;
|
||||
overflow: visible;
|
||||
}
|
||||
.embla__slide img,video {
|
||||
// Letterbox media. <frigate-card-ha-hls-player> has similar added directly in
|
||||
// its element.
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
:host {
|
||||
--frigate-card-thumbnail-size-max: 175px;
|
||||
--frigate-card-thumbnail-details-width: calc(
|
||||
var(--frigate-card-thumbnail-size) + 200px
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
:host {
|
||||
// Drawer width sizes to contents.
|
||||
width: unset;
|
||||
}
|
||||
|
||||
#fs {
|
||||
// Hide the freespace screen.
|
||||
display: none;
|
||||
width: 100%;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
#d,
|
||||
#fs {
|
||||
// Override width/height to be 100% instead of 100v[wh].
|
||||
height: 100%;
|
||||
|
||||
// Position absolutely.
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#d {
|
||||
// Need to allow drawer controls to be visible.
|
||||
overflow: visible;
|
||||
|
||||
// Don't let the drawer exceed 90% of the full width of the relative parent.
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
:host([location=right]) #d {
|
||||
// Position to the right.
|
||||
left: unset;
|
||||
right: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
:host([location=right][open]) #d {
|
||||
transform: none;
|
||||
box-shadow: 0px 0px 25px 0px black;
|
||||
}
|
||||
|
||||
#ifs {
|
||||
// Override width/height to be 100% instead of 100v[wh].
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
$drawer-icon-size: 20px;
|
||||
$drawer-padding-extend: 20px;
|
||||
|
||||
side-drawer {
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
div.control-surround {
|
||||
position: absolute;
|
||||
bottom: 50%;
|
||||
transform: translateY(50%);
|
||||
z-index: 0;
|
||||
padding-top: $drawer-padding-extend;
|
||||
padding-bottom: $drawer-padding-extend;
|
||||
}
|
||||
:host([location=left]) div.control-surround {
|
||||
@if $drawer-icon-size < 32 {
|
||||
// Ensure the clickable area is at least 32px wide.
|
||||
padding-right: calc(32px - $drawer-icon-size);
|
||||
}
|
||||
left: 100%;
|
||||
}
|
||||
:host([location=right]) div.control-surround {
|
||||
@if $drawer-icon-size < 32 {
|
||||
// See note above.
|
||||
padding-left: calc(32px - $drawer-icon-size);
|
||||
}
|
||||
right: 100%;
|
||||
}
|
||||
:host([empty]), :host([empty]) > * {
|
||||
visibility: hidden;
|
||||
}
|
||||
:host(:not([empty])), :host(:not([empty])) > * {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
ha-icon.control {
|
||||
color: var(--secondary-color, white);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
opacity: 0.7;
|
||||
pointer-events: all;
|
||||
|
||||
--mdc-icon-size: #{$drawer-icon-size};
|
||||
padding-top: $drawer-padding-extend;
|
||||
padding-bottom: $drawer-padding-extend;
|
||||
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
:host([open]) ha-icon.control, ha-icon.control:hover {
|
||||
// When the drawer is open or hovered make the button to close it more
|
||||
// prominent.
|
||||
opacity: 1;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
:host([location=left]) ha-icon.control {
|
||||
border-top-right-radius: $drawer-icon-size;
|
||||
border-bottom-right-radius: $drawer-icon-size;
|
||||
}
|
||||
|
||||
:host([location=right]) ha-icon.control {
|
||||
border-top-left-radius: $drawer-icon-size;
|
||||
border-bottom-left-radius: $drawer-icon-size;
|
||||
}
|
||||
@@ -23,15 +23,14 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
.values {
|
||||
padding: 10px;
|
||||
background: var(--secondary-background-color);
|
||||
display: grid;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
ha-formfield {
|
||||
padding-bottom: 8px;
|
||||
// Space submenus from the next top-level options.
|
||||
.values + .option,
|
||||
.submenu + .option {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
div.upgrade {
|
||||
width: auto;
|
||||
border: 1px dotted var(--primary-color);
|
||||
@@ -44,38 +43,68 @@ div.upgrade span {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.camera-header {
|
||||
.submenu-header {
|
||||
display: flex;
|
||||
margin-top: 4px;
|
||||
padding: 10px;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
.camera-header * {
|
||||
.submenu.selected > .submenu-header {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.submenu-header * {
|
||||
flex-basis: auto;
|
||||
|
||||
// Only allow clicks on the outermost header.
|
||||
// Only allow clicks on the header.
|
||||
pointer-events: none;
|
||||
}
|
||||
.camera-header ha-icon {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.camera-header .new-camera {
|
||||
.submenu-header .new-camera {
|
||||
font-style: italic;
|
||||
}
|
||||
.submenu:not(.selected) > .submenu-header .new-camera {
|
||||
color: var(--secondary-text-color, 'black');
|
||||
}
|
||||
.cameras {
|
||||
margin: 5px 5px 10px 20px;
|
||||
|
||||
.submenu-header ha-icon,
|
||||
.submenu-header ha-svg-icon {
|
||||
padding-right: 15px;
|
||||
}
|
||||
.cameras .controls {
|
||||
|
||||
.submenu {
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
.submenu.selected {
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
// Nested menus should shrink width.
|
||||
.submenu .submenu {
|
||||
width: calc(100% - 10px * 2);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.submenu :not(.submenu.selected) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.submenu .controls {
|
||||
display: inline-block;
|
||||
margin-left: auto;
|
||||
margin-right: 0px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.cameras .controls ha-icon-button.button {
|
||||
.submenu .controls ha-icon-button.button {
|
||||
--mdc-icon-button-size: 32px;
|
||||
--mdc-icon-size: calc(var(--mdc-icon-button-size) / 2);
|
||||
}
|
||||
span.info {
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
ha-selector {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Modified from / inspired by:
|
||||
// https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js
|
||||
:host {
|
||||
position: relative;
|
||||
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
|
||||
--frigate-card-ptz-icon-size: 24px;
|
||||
}
|
||||
|
||||
/*****************
|
||||
* Main Containers
|
||||
*****************/
|
||||
.ptz {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
color: var(--light-primary-color);
|
||||
opacity: 0.4;
|
||||
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
:host([data-orientation='vertical']) .ptz {
|
||||
flex-direction: column;
|
||||
}
|
||||
:host([data-orientation='horizontal']) .ptz {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.ptz:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host([data-orientation='vertical']) .ptz div {
|
||||
width: calc(var(--frigate-card-ptz-icon-size) * 3);
|
||||
}
|
||||
:host([data-orientation='horizontal']) .ptz div {
|
||||
height: calc(var(--frigate-card-ptz-icon-size) * 3);
|
||||
}
|
||||
|
||||
.ptz-move,
|
||||
.ptz-zoom,
|
||||
.ptz-home {
|
||||
position: relative;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.ptz-move {
|
||||
height: calc(var(--frigate-card-ptz-icon-size) * 3);
|
||||
width: calc(var(--frigate-card-ptz-icon-size) * 3);
|
||||
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
:host([data-orientation='horizontal']) .ptz .ptz-zoom,
|
||||
:host([data-orientation='horizontal']) .ptz .ptz-home {
|
||||
width: calc(var(--frigate-card-ptz-icon-size) * 1.5);
|
||||
}
|
||||
:host([data-orientation='vertical']) .ptz .ptz-zoom,
|
||||
:host([data-orientation='vertical']) .ptz .ptz-home {
|
||||
height: calc(var(--frigate-card-ptz-icon-size) * 1.5);
|
||||
}
|
||||
|
||||
.ptz-zoom,
|
||||
.ptz-home {
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
}
|
||||
|
||||
/***********
|
||||
* PTZ Icons
|
||||
***********/
|
||||
ha-icon {
|
||||
position: absolute;
|
||||
--mdc-icon-size: var(--frigate-card-ptz-icon-size);
|
||||
}
|
||||
ha-icon:not(.disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.disabled {
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
.up {
|
||||
top: 5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.down {
|
||||
bottom: 5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.left {
|
||||
left: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.right {
|
||||
right: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
:host([data-orientation='vertical']) .zoom_in {
|
||||
right: 5px;
|
||||
top: 50%;
|
||||
}
|
||||
:host([data-orientation='vertical']) .zoom_out {
|
||||
left: 5px;
|
||||
top: 50%;
|
||||
}
|
||||
:host([data-orientation='horizontal']) .zoom_in {
|
||||
left: 50%;
|
||||
top: 5px;
|
||||
}
|
||||
:host([data-orientation='horizontal']) .zoom_out {
|
||||
left: 50%;
|
||||
bottom: 5px;
|
||||
}
|
||||
|
||||
:host([data-orientation='vertical']) .zoom_in,
|
||||
:host([data-orientation='vertical']) .zoom_out {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
:host([data-orientation='horizontal']) .zoom_in,
|
||||
:host([data-orientation='horizontal']) .zoom_out {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.home {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
:host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
// Don't let elements overflow.
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.element {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// Errors encountered by HA (not the card) during parsing of the configuration
|
||||
// (e.g. custom picture element that does not exist).
|
||||
hui-error-card.element {
|
||||
inset: 0px;
|
||||
background-color: var(--secondary-background-color, black);
|
||||
transform: unset;
|
||||
}
|
||||
@@ -1,46 +1,62 @@
|
||||
@use "@material/image-list/mdc-image-list";
|
||||
@use "@material/image-list";
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
|
||||
scrollbar-width: none; /* Hide scrollbar: Firefox */
|
||||
|
||||
// Hide scrollbar: IE and Edge
|
||||
-ms-overflow-style: none;
|
||||
|
||||
// Hide scrollbar: Firefox
|
||||
scrollbar-width: none;
|
||||
|
||||
--frigate-card-gallery-gap: 3px;
|
||||
--frigate-card-gallery-columns: 4;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
gap: var(--frigate-card-gallery-gap);
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
// Hide scrollbar for Chrome, Safari and Opera
|
||||
:host::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.frigate-card-gallery {
|
||||
// Note: In fullscreen, number of columns is overwritten in Javascript based
|
||||
// on dimensions.
|
||||
@include image-list.standard-columns(4, 5px);
|
||||
@include image-list.shape-radius(5px);
|
||||
}
|
||||
|
||||
.frigate-card-gallery .mdc-image-list__image {
|
||||
transition: transform 0.2s linear;
|
||||
}
|
||||
.frigate-card-gallery .mdc-image-list__image:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
ha-card.frigate-card-gallery-folder {
|
||||
:host ha-card {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
color: var(--secondary-text-color, white);
|
||||
border: 2px ridge var(--secondary-text-color, black);
|
||||
border-radius: 5px;
|
||||
color: var(--primary-text-color, white);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
|
||||
// Folder background color should match the thumbnail element background
|
||||
// color.
|
||||
background-color: var(--primary-background-color, black);
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
line-height: 1;
|
||||
}
|
||||
overflow: hidden;
|
||||
|
||||
// Some themes override these values (with high CSS specificity) which is
|
||||
// intended for full cards rather than internal ones. Since we use multiple of
|
||||
// these for folders, we cannot use an ID and need to resort to !important.
|
||||
margin: 0px !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
transition: transform 0.2s linear;
|
||||
}
|
||||
ha-card:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
ha-card,
|
||||
frigate-card-thumbnail {
|
||||
height: 100%;
|
||||
max-height: var(--frigate-card-thumbnail-size);
|
||||
}
|
||||
frigate-card-thumbnail:not([details]) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
@use 'media-layout.scss';
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: scale-down;
|
||||
|
||||
@include media-layout.media-layout();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.embla__slide {
|
||||
height: 100%;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@use 'media-layout.scss';
|
||||
|
||||
img, video {
|
||||
@include media-layout.media-layout();
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
@use 'media-layout.scss';
|
||||
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@include media-layout.media-layout();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@use 'media-layout.scss';
|
||||
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -5,11 +7,15 @@
|
||||
}
|
||||
|
||||
/* Don't drop shadow or have radius for nested webrtc card */
|
||||
webrtc-camera ha-card {
|
||||
box-shadow: none;
|
||||
#webrtc ha-card {
|
||||
border-radius: 0px;
|
||||
background-color: black;
|
||||
background-color: unset;
|
||||
|
||||
// To get the WebRTC player to line up correctly in Safari.
|
||||
// To get the WebRTC player to line up correctly with some themes.
|
||||
margin: 0px;
|
||||
}
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#webrtc #video {
|
||||
@include media-layout.media-layout();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,3 @@ frigate-card-live-carousel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
frigate-card-thumbnail-carousel {
|
||||
flex: 0 0 var(--frigate-card-carousel-thumbnail-size);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
--video-max-height: none;
|
||||
}
|
||||
|
||||
.embla__slide {
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
// Keep the controls relative to the media carousel itself.
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@mixin media-layout {
|
||||
object-fit: var(--frigate-card-media-layout-fit, contain);
|
||||
object-position: var(--frigate-card-media-layout-position-x, 50%)
|
||||
var(--frigate-card-media-layout-position-y, 50%);
|
||||
}
|
||||
@@ -11,26 +11,91 @@
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
:host([data-mode="above"]),:host([data-mode="below"]) {
|
||||
/***********************************
|
||||
* Aligned divs: matching & opposing
|
||||
***********************************/
|
||||
|
||||
div.matching,
|
||||
div.opposing {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
|
||||
// Allow the divs to be resized.
|
||||
min-width: 0px;
|
||||
min-height: 0px;
|
||||
}
|
||||
div.matching {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
div.opposing {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/********************
|
||||
* Outside menu style
|
||||
********************/
|
||||
|
||||
:host([data-style='outside']) {
|
||||
width: 100%;
|
||||
background: var(--secondary-background-color);
|
||||
}
|
||||
:host([data-mode="above"]) {
|
||||
|
||||
/************************************
|
||||
* Match menu rounded corners to card
|
||||
************************************/
|
||||
|
||||
:host([data-position='top']),
|
||||
:host([data-position='left']) {
|
||||
border-top-left-radius: var(--ha-card-border-radius, 4px);
|
||||
}
|
||||
:host([data-position='top']),
|
||||
:host([data-position='right']) {
|
||||
border-top-right-radius: var(--ha-card-border-radius, 4px);
|
||||
}
|
||||
:host([data-mode="below"]) {
|
||||
:host([data-position='bottom']),
|
||||
:host([data-position='left']) {
|
||||
border-bottom-left-radius: var(--ha-card-border-radius, 4px);
|
||||
}
|
||||
:host([data-position='bottom']),
|
||||
:host([data-position='right']) {
|
||||
border-bottom-right-radius: var(--ha-card-border-radius, 4px);
|
||||
}
|
||||
|
||||
:host([data-mode$="-left"]) {
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
/**************************************
|
||||
* Positioning for absolute menu styles
|
||||
**************************************/
|
||||
|
||||
:host(:not([data-style='outside'])[data-position='top']),
|
||||
:host(:not([data-style='outside'])[data-position='left'][data-alignment='top']),
|
||||
:host(:not([data-style='outside'])[data-position='right'][data-alignment='top']) {
|
||||
top: 0px;
|
||||
}
|
||||
:host(:not([data-style='outside'])[data-position='bottom']),
|
||||
:host(:not([data-style='outside'])[data-position='left'][data-alignment='bottom']),
|
||||
:host(:not([data-style='outside'])[data-position='right'][data-alignment='bottom']) {
|
||||
bottom: 0px;
|
||||
}
|
||||
:host(:not([data-style='outside'])[data-position='left']),
|
||||
:host(:not([data-style='outside'])[data-position='top'][data-alignment='left']),
|
||||
:host(:not([data-style='outside'])[data-position='bottom'][data-alignment='left']) {
|
||||
left: 0px;
|
||||
}
|
||||
:host(:not([data-style='outside'])[data-position='right']),
|
||||
:host(:not([data-style='outside'])[data-position='top'][data-alignment='right']),
|
||||
:host(:not([data-style='outside'])[data-position='bottom'][data-alignment='right']) {
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
/********************************************************
|
||||
* Hack: Ensure host & div expand for column flex layouts
|
||||
********************************************************/
|
||||
|
||||
:host(:not([data-style='outside'])[data-position='left']) {
|
||||
// Awful hack: Flexbox column wrapping doesn't work properly in most major
|
||||
// browsers -- the element boundary does not expand to cover the full wrapped
|
||||
// content as it should. This results in the wrapped 'content' appearing
|
||||
@@ -43,66 +108,80 @@
|
||||
// - https://bugs.chromium.org/p/chromium/issues/detail?id=507397
|
||||
writing-mode: vertical-lr;
|
||||
}
|
||||
|
||||
:host([data-mode$="-right"]) {
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
|
||||
:host(:not([data-style='outside'])[data-position='right']) {
|
||||
// See "Awful hack" above.
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
:host([data-mode$="-top"]) {
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
:host(:not([data-style='outside'])[data-style='overlay'][data-position='left']) div > *,
|
||||
:host(:not([data-style='outside'])[data-style='overlay'][data-position='right']) div > *,
|
||||
:host(:not([data-style='outside'])[data-style='hover'][data-position='left']) div > *,
|
||||
:host(:not([data-style='outside'])[data-style='hover'][data-position='right']) div > *,
|
||||
:host(:not([data-style='outside'])[data-style='hidden'][data-position='left']) div > *,
|
||||
:host(:not([data-style='outside'])[data-style='hidden'][data-position='right']) div > * {
|
||||
// See "Awful hack" above. Note that this "cancelation" of writing mode only
|
||||
// affects from beyond the divs, i.e. the writing mode hack applies to host
|
||||
// and divs both.
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
:host([data-mode$="-bottom"]) {
|
||||
left: 0px;
|
||||
bottom: 0px;
|
||||
/**********************
|
||||
* "Reverse" alignments
|
||||
**********************/
|
||||
|
||||
:host(:not([data-style='outside'])[data-position='left'][data-alignment='bottom']),
|
||||
:host(:not([data-style='outside'])[data-position='right'][data-alignment='bottom']),
|
||||
:host([data-position='top'][data-alignment='right']),
|
||||
:host([data-position='bottom'][data-alignment='right']),
|
||||
:host(:not([data-style='outside'])[data-position='left'][data-alignment='bottom']) div,
|
||||
:host(:not([data-style='outside'])[data-position='right'][data-alignment='bottom']) div,
|
||||
:host([data-position='top'][data-alignment='right']) div,
|
||||
:host([data-position='bottom'][data-alignment='right']) div {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/****************************
|
||||
* Wrap upwards on the bottom
|
||||
****************************/
|
||||
|
||||
:host(:not([data-style='outside'])[data-position='bottom']) div {
|
||||
// If the menu has more content that allows, "wrap upwards" to keep the
|
||||
// Frigate button in the same place.
|
||||
flex-wrap: wrap-reverse;
|
||||
}
|
||||
|
||||
:host([data-mode^="overlay-"]),:host([data-mode^="hover-"]),:host([data-mode^="hidden-"]) {
|
||||
/********************************************
|
||||
* Positioning for absolute based menu styles
|
||||
********************************************/
|
||||
|
||||
:host([data-style='overlay']),
|
||||
:host([data-style='hover']),
|
||||
:host([data-style='hidden']) {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
width: calc(var(--frigate-card-menu-button-size) + 6px);
|
||||
height: calc(var(--frigate-card-menu-button-size) + 6px);
|
||||
}
|
||||
|
||||
:host([data-mode="overlay-top"]),
|
||||
:host([data-mode="overlay-bottom"]),
|
||||
:host([data-mode="hover-top"]),
|
||||
:host([data-mode="hover-bottom"]),
|
||||
:host([data-mode="hidden-top"][expanded]),
|
||||
:host([data-mode="hidden-bottom"][expanded]) {
|
||||
:host([data-style='overlay'][data-position='top']),
|
||||
:host([data-style='overlay'][data-position='bottom']),
|
||||
:host([data-style='hover'][data-position='top']),
|
||||
:host([data-style='hover'][data-position='bottom']),
|
||||
:host([data-style='hidden'][data-position='top'][expanded]),
|
||||
:host([data-style='hidden'][data-position='bottom'][expanded]) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
background: linear-gradient(90deg, rgba(0,0,0,0.3), rgba(0,0,0,0));
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
:host([data-mode="overlay-left"]),
|
||||
:host([data-mode="overlay-right"]),
|
||||
:host([data-mode="hover-left"]),
|
||||
:host([data-mode="hover-right"]),
|
||||
:host([data-mode="hidden-left"][expanded]),
|
||||
:host([data-mode="hidden-right"][expanded]) {
|
||||
:host([data-style='overlay'][data-position='left']),
|
||||
:host([data-style='overlay'][data-position='right']),
|
||||
:host([data-style='hover'][data-position='left']),
|
||||
:host([data-style='hover'][data-position='right']),
|
||||
:host([data-style='hidden'][data-position='left'][expanded]),
|
||||
:host([data-style='hidden'][data-position='right'][expanded]) {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
overflow: visible;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.3), rgba(0,0,0,0));
|
||||
}
|
||||
|
||||
:host([data-mode="overlay-left"]) > *,
|
||||
:host([data-mode="hover-left"]) > *,
|
||||
:host([data-mode="hidden-left"]) > *,
|
||||
:host([data-mode="overlay-right"]) > *,
|
||||
:host([data-mode="hover-right"]) > *,
|
||||
:host([data-mode="hidden-right"]) > * {
|
||||
// See "Awful hack" above.
|
||||
writing-mode: horizontal-tb;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
// Ensure error messages are selectable.
|
||||
user-select: text;
|
||||
// Safari only has prefixed support.
|
||||
-webkit-user-select: text;
|
||||
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
div.wrapper {
|
||||
@@ -13,13 +22,17 @@ div.message {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 10%;
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.message div.contents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
div.message div.icon {
|
||||
@@ -37,4 +50,30 @@ div.message div.icon {
|
||||
|
||||
.message pre {
|
||||
margin-top: 20px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.dotdotdot:after {
|
||||
@keyframes dots {
|
||||
0%,
|
||||
20% {
|
||||
content: '.';
|
||||
}
|
||||
40% {
|
||||
content: '..';
|
||||
}
|
||||
60% {
|
||||
content: '...';
|
||||
}
|
||||
90%,
|
||||
100% {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
animation: dots 2s linear infinite;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 3em;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
|
||||
.controls.icons {
|
||||
top: calc(50% - (40px / 2));
|
||||
top: calc(50% - (var(--frigate-card-next-prev-size) / 2));
|
||||
}
|
||||
|
||||
.controls.thumbnails {
|
||||
@@ -32,6 +32,7 @@
|
||||
box-shadow: 0px 0px 20px 5px black;
|
||||
transition: all 0.2s ease-out;
|
||||
opacity: 0.8;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
.controls.thumbnails:hover {
|
||||
opacity: 1 !important;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
// Share the screen space with thumbnails that may be above/below.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Set the drawer relative to this host.
|
||||
position: relative;
|
||||
|
||||
// Hide any content outside the main pane (e.g. side drawers) to ensure the
|
||||
// user cannot scroll across to the drawers without opening them.
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::slotted:not([name]) {
|
||||
// Expand the main body to fill available content not otherwise used by the
|
||||
// surround.
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1,28 +1,34 @@
|
||||
@use 'const.scss';
|
||||
|
||||
:host {
|
||||
--frigate-card-carousel-thumbnail-size: 100px;
|
||||
--frigate-card-carousel-thumbnail-opacity: 0.6;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
--frigate-card-carousel-thumbnail-opacity: 1;
|
||||
}
|
||||
|
||||
:host([direction='vertical']) {
|
||||
height: 100%;
|
||||
}
|
||||
:host([direction='horizontal']) {
|
||||
// In fullscreen mode, without explicitly setting the height to auto Chrome
|
||||
// will construct a stylesheet with 100% height.
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.embla__slide {
|
||||
flex: 0 0 var(--frigate-card-carousel-thumbnail-size);
|
||||
flex: 0 0 auto;
|
||||
opacity: var(--frigate-card-carousel-thumbnail-opacity);
|
||||
transition: opacity 0.6s ease, transform 0.2s linear;
|
||||
}
|
||||
.embla__slide.slide-selected {
|
||||
opacity: 1.0;
|
||||
opacity: 1;
|
||||
}
|
||||
.embla__slide:hover {
|
||||
transform: scale(1.04);
|
||||
|
||||
frigate-card-thumbnail {
|
||||
width: var(--frigate-card-thumbnail-size);
|
||||
height: var(--frigate-card-thumbnail-size);
|
||||
}
|
||||
frigate-card-thumbnail[details] {
|
||||
width: var(--frigate-card-thumbnail-details-width);
|
||||
}
|
||||
.embla__slide img {
|
||||
border-radius: 5px;
|
||||
|
||||
// Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
|
||||
// should be clipped to fill the thumbnail div whilst maintaining
|
||||
// aspect-ratio.
|
||||
object-fit: cover;
|
||||
|
||||
// Restrict images to a maximum of thumbnail size.
|
||||
max-width: var(--frigate-card-carousel-thumbnail-size);
|
||||
max-height: var(--frigate-card-carousel-thumbnail-size);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
:host {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-left: 5px;
|
||||
padding: 8px;
|
||||
color: var(--primary-text-color);
|
||||
overflow: hidden;
|
||||
column-gap: 5%;
|
||||
}
|
||||
|
||||
div.right,
|
||||
div.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
div.right {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div.left {
|
||||
flex: 1;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
justify-content: center;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
column-gap: 5%;
|
||||
}
|
||||
|
||||
div.left div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
span.heading {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.larger,
|
||||
span.larger {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
:host {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
img,
|
||||
ha-icon,
|
||||
frigate-card-progress-indicator {
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
|
||||
max-width: var(--frigate-card-thumbnail-size);
|
||||
max-height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
// Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
|
||||
// should be clipped to fill the thumbnail div whilst maintaining
|
||||
// aspect-ratio.
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
ha-icon {
|
||||
--mdc-icon-size: 50%;
|
||||
color: var(--primary-text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
max-width: var(--frigate-card-thumbnail-size);
|
||||
max-height: var(--frigate-card-thumbnail-size);
|
||||
padding: 10px;
|
||||
|
||||
border: 1px solid var(--secondary-color);
|
||||
background-color: var(--secondary-background-color);
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
box-sizing: border-box;
|
||||
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||