Merge pull request #832 from dermotduffy/release-4.0.0

Release v4.0.0
This commit is contained in:
Dermot Duffy
2022-09-22 17:35:52 -07:00
committed by GitHub
133 changed files with 16270 additions and 6859 deletions
+10
View File
@@ -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"]
-4
View File
@@ -1,4 +0,0 @@
default_config:
lovelace:
mode: yaml
demo:
+32 -14
View File
@@ -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"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
set -euxo pipefail
git submodule update --init
.devcontainer/frigate-hass-integration/.devcontainer/set_dot_env.sh
+104
View File
@@ -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"
}
]
}
}
-7
View File
@@ -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
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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
+3
View File
@@ -2,3 +2,6 @@
/.rpt2_cache/
package-lock.json
/dist
.env
.envrc
+4
View File
@@ -0,0 +1,4 @@
[submodule "frigate-hass-integration"]
path = .devcontainer/frigate-hass-integration
url = https://github.com/blakeblackshear/frigate-hass-integration
branch = master
+2 -2
View File
@@ -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"
]
}
+25
View File
@@ -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
+127
View File
@@ -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'
+14
View File
@@ -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/**"]
}
]
}
+8
View File
@@ -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
}
+17 -3
View File
@@ -2,11 +2,25 @@
"version": "2.0.0",
"tasks": [
{
"label": "Rollup",
"type": "npm",
"script": "start",
"problemMatcher": [],
"label": "npm: start",
"detail": "rollup -c --watch"
"problemMatcher": {
"pattern": [
{
"regexp": ".",
"file": 1,
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "^bundles",
"endsPattern": "^created"
}
},
"isBackground": true
}
]
}
+1804 -168
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

+44 -31
View File
@@ -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"
}
+42 -11
View File
@@ -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 [
{
/**
* @type {import('rollup').RollupOptions}
*/
const config = {
input: 'src/card.ts',
output: {
file: 'dist/frigate-hass-card.js',
format: 'es',
...(dev && {
sourcemap: true,
}),
},
plugins: [...plugins],
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;
+17 -16
View File
@@ -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);
}
};
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;
}
}
-247
View File
@@ -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);
}
}
+11 -7
View File
@@ -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();
}
}
+22 -3
View File
@@ -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);
}
+939 -265
View File
File diff suppressed because it is too large Load Diff
-591
View File
@@ -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();
};
+261 -56
View File
@@ -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;
}
/**
@@ -41,45 +201,22 @@ export class FrigateCardCarousel extends LitElement {
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (!this._carousel) {
this.updateComplete.then(() => {
// Re-check for the carousel to prevent a double init.
if (!this._carousel) {
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;
}
}
+144
View File
@@ -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;
}
}
+209 -69
View File
@@ -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.
@@ -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;
/**
@@ -334,3 +350,127 @@ export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMen
@customElement('frigate-card-menu-submenu')
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;
}
}
+114 -24
View File
@@ -1,22 +1,48 @@
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> & {
export type AutoMediaType = CreatePluginType<
{
play: () => void;
pause: () => void;
mute: () => void;
unmute: () => void;
},
AutoMediaOptionsType
>;
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
autoMedia?: AutoMediaType
}
}
/**
@@ -25,10 +51,15 @@ export type AutoMediaPluginType = EmblaPluginType<AutoMediaPluginOptionsType> &
* @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);
if (
options.autoPauseCondition &&
['all', 'unselected'].includes(options.autoPauseCondition)
) {
carousel.on('select', pausePrevious);
}
carousel.on('destroy', mute);
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);
if (
options.autoPauseCondition &&
['all', 'unselected'].includes(options.autoPauseCondition)
) {
carousel.off('select', pausePrevious);
}
carousel.off('destroy', mute);
if (
options.autoMuteCondition &&
['all', 'unselected'].includes(options.autoMuteCondition)
) {
carousel.off('select', mutePrevious);
}
document.removeEventListener('visibilitychange', visibilityHandler);
}
@@ -66,14 +118,30 @@ export function AutoMediaPlugin(
* Handle document visibility changes.
*/
function visibilityHandler(): void {
if (document.visibilityState == 'hidden') {
pause();
mute();
} else if (document.visibilityState == 'visible') {
if (options.autoPlayWhenVisible) {
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.
*/
@@ -130,9 +209,18 @@ export function AutoMediaPlugin(
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;
+81 -46
View File
@@ -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;
+140 -75
View File
@@ -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,30 +211,16 @@ 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
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);
@@ -169,24 +228,19 @@ export class FrigateCardGalleryCore extends LitElement {
stopEventFromActivatingCardWideActions(ev);
}}
outlined=""
class="frigate-card-gallery-folder"
>
<ha-icon .icon=${'mdi:arrow-left'}></ha-icon>
</ha-card>
</div>
</div>
</li>`
</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">
html`
${child.can_expand
? html`<div class="mdc-image-list__image">
? html`
<ha-card
@click=${(ev) => {
if (this.hass && this.view) {
BrowseMediaUtil.fetchChildMediaAndDispatchViewChange(
fetchChildMediaAndDispatchViewChange(
this,
this.hass,
this.view,
@@ -196,35 +250,39 @@ export class FrigateCardGalleryCore extends LitElement {
stopEventFromActivatingCardWideActions(ev);
}}
outlined=""
class="frigate-card-gallery-folder"
>
<div style="${styleMap(folderStyle)}">${child.title}</div>
<div>${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}"
@click=${(ev) => {
? 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,
previous: this.view,
})
.dispatchChangeEvent(this);
}
stopEventFromActivatingCardWideActions(ev);
}}
/>`
>
</frigate-card-thumbnail>`
: ``}
</div>
</li>`,
`,
)}
</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;
}
}
+114 -79
View File
@@ -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) {
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
}
}
+528 -336
View File
File diff suppressed because it is too large Load Diff
+336 -165
View File
@@ -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;
/**
* 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.
*/
protected _mediaLoadedHandler(
slideIndex: number,
mediaShowInfo?: MediaShowInfo | null,
): void {
// isValidMediaShowInfo is used to prevent saving media info that will be
// isValidMediaLoadedInfo 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);
if (mediaLoadedInfo && isValidMediaLoadedInfo(mediaLoadedInfo)) {
this._mediaLoadedInfo[slideIndex] = mediaLoadedInfo;
if (this.frigateCardCarousel()?.getCarouselSelected()?.index === slideIndex) {
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
}
}
}
/**
* 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.
* Remove a media loaded info (i.e. a media item has unloaded).
* @param event The CarouselMediaUnloadedInfo event.
*/
const firstMediaLoad = !Object.keys(this._mediaShowInfo).length;
if (firstMediaLoad) {
const replacementImageSrc = getEmptyImageSrc(
mediaShowInfo.width,
mediaShowInfo.height,
protected _removeMediaLoadedInfo(event: CustomEvent<CarouselMediaUnloadedInfo>): void {
const slideIndex = event.detail.slide;
delete this._mediaLoadedInfo[slideIndex];
// 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,
);
this.renderRoot.querySelectorAll('.embla__container img').forEach((img) => {
const imageElement = img as HTMLImageElement;
if (imageElement.src === IMG_EMPTY) {
imageElement.src = replacementImageSrc;
}
});
}
}
// 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;
}
}
+111 -49
View File
@@ -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
}
}
+91 -15
View File
@@ -1,35 +1,48 @@
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">
const classes = {
dotdotdot: !!this.dotdotdot,
};
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>` : ''}
<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>`;
@@ -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;
}
}
+34 -10
View File
@@ -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') {
@@ -55,13 +67,10 @@ export class FrigateCardNextPreviousControl extends LitElement {
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}"
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;
}
}
+149 -27
View File
@@ -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;
}
}
+194
View File
@@ -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;
}
}
+77
View File
@@ -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
}
}
+193 -69
View File
@@ -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;
}
}
protected _config?: ThumbnailsControlConfig;
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 })
set highlightSelected(value: boolean) {
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));
}
@property({ attribute: false })
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', {
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;
}
}
+326
View File
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -83,3 +83,9 @@ export class FrigateCardTitleControl extends LitElement {
return unsafeCSS(titleStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
"frigate-card-title-control": FrigateCardTitleControl
}
}
+309 -287
View File
@@ -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) {
if (!this.view.target) {
// 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;
}
if (!this.view.target) {
BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange(
fetchLatestMediaAndDispatchViewChange(
this,
this.hass,
this.view,
browseMediaQueryParameters,
overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, {
mediaType: mediaType,
}),
);
return renderProgressIndicator();
}
return html` <frigate-card-viewer-core
return html` <frigate-card-surround-thumbnails
.hass=${this.hass}
.view=${this.view}
.config=${this.viewerConfig.controls.thumbnails}
.cameras=${this.cameras}
>
<frigate-card-viewer-carousel
.hass=${this.hass}
.view=${this.view}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.hass=${this.hass}
.browseMediaQueryParameters=${browseMediaQueryParameters}
.resolvedMediaCache=${this.resolvedMediaCache}
>
</frigate-card-viewer-core>`;
</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,41 +522,16 @@ 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 };
if (img) {
img.src = resolvedMedia.url;
} else if (hls_player) {
hls_player.url = 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;
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
url: string;
};
if (this._previousControlRef.value) {
updateNextPreviousControl(this._previousControlRef.value, 'previous');
}
if (this._nextControlRef.value) {
updateNextPreviousControl(this._nextControlRef.value, 'next');
if (img) {
img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
} else if (hls_player) {
hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) || '';
}
});
}
/**
@@ -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">
// 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
${ref(this._previousControlRef)}
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._nextPreviousHandler('previous');
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious();
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
<div class="embla__viewport">
<div class="embla__container">${slides}</div>
</div>
${slides}
<frigate-card-next-previous-control
${ref(this._nextControlRef)}
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._nextPreviousHandler('next');
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
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>`
: ``} `;
</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;
}
}
+324 -66
View File
@@ -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') {
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);
setConfigValue(obj, newPath, value);
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'),
];
+119 -37
View File
@@ -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;
+4
View File
@@ -1,2 +1,6 @@
declare module '*.scss';
declare module '*.jpg';
declare module 'view' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface ViewContext {}
}
+913 -396
View File
File diff suppressed because it is too large Load Diff
+300 -148
View File
@@ -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",
"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)",
"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)",
"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"
"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"
},
"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"
"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"
},
"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)"
"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": {
"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"
}
},
"options": "Event Gallery Controls",
"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"
"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"
}
}
},
"title": {
"mode": "Event Viewer media title display mode",
"image": {
"layout": "Image Layout",
"mode": "Image view 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"
"camera": "Home Assistant camera snapshot of camera entity",
"screensaver": "Embedded Frigate logo",
"url": "Arbitrary image specified by URL"
},
"duration_seconds": "Seconds to display popup title (0=forever)"
}
}
"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"
}
},
"image": {
"mode": "Image view 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": {
"screensaver": "Embedded Frigate logo",
"url": "Arbitrary image specified by URL",
"camera": "Home Assistant camera snapshot of camera entity"
"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"
},
"url": "Static image URL for image view",
"refresh_seconds": "Number of seconds after which to refresh (0=never)"
"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"
}
},
"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"
}
}
}
+361
View File
@@ -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",
"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 de cliente do Frigate (para >1 servidor da Frigate)",
"id": "ID exclusivo para esta câmera nesse cartão",
"client_id": "ID do cliente do Frigate (para >1 servidor Frigate)",
"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)",
"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"
"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"
}
},
"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)"
"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": {
"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"
}
},
"options": "Controles da Galeria de Eventos",
"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",
"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"
}
},
"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"
}
}
+33 -15
View File
@@ -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']);
}
+37 -12
View File
@@ -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;
}
}
+42 -16
View File
@@ -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
}
}
+54 -13
View File
@@ -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;
}
}
-66
View File
@@ -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;
}
}
+86 -44
View File
@@ -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 {
// 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;
}
+11 -16
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
:host {
--frigate-card-thumbnail-size-max: 175px;
--frigate-card-thumbnail-details-width: calc(
var(--frigate-card-thumbnail-size) + 200px
);
}
+45
View File
@@ -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%;
}
+65
View File
@@ -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;
}
+46 -17
View File
@@ -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);
}
+135
View File
@@ -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%);
}
+10 -1
View File
@@ -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;
}
+44 -28
View File
@@ -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%;
}
+4 -1
View File
@@ -1,6 +1,9 @@
@use 'media-layout.scss';
img {
width: 100%;
height: 100%;
display: block;
object-fit: scale-down;
@include media-layout.media-layout();
}
+4
View File
@@ -0,0 +1,4 @@
.embla__slide {
height: 100%;
flex: 0 0 100%;
}
+5
View File
@@ -0,0 +1,5 @@
@use 'media-layout.scss';
img, video {
@include media-layout.media-layout();
}
+5 -2
View File
@@ -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();
}
+3
View File
@@ -0,0 +1,3 @@
.hidden {
display: none;
}
+10 -4
View File
@@ -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();
}
-4
View File
@@ -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);
}
+5 -3
View File
@@ -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;
}
+5
View File
@@ -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%);
}
+121 -42
View File
@@ -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));
}
: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;
}
+43 -4
View File
@@ -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;
}
+2 -1
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
:host {
width: 100%;
height: 100%;
display: block;
}
+21
View File
@@ -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;
}
+24 -18
View File
@@ -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);
}
.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);
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);
}
+46
View File
@@ -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;
}
+33
View File
@@ -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;
}
+27
View File
@@ -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;
}

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