Merge pull request #111 from dermotduffy/elements

Add full picture elements support
This commit is contained in:
Dermot Duffy
2021-10-09 23:55:04 -07:00
committed by GitHub
18 changed files with 1121 additions and 228 deletions
+203 -18
View File
@@ -134,12 +134,15 @@ dimensions:
aspect_ratio: '4:3' aspect_ratio: '4:3'
``` ```
<a name="advanced-options"></a>
### Advanced Options ### Advanced Options
| Option | Default | Description | | Option | Default | Description |
| ------------- | - | --------------------------------------------- | | ------------- | - | --------------------------------------------- |
| `label` | | A label used to filter events (clips & snapshots), e.g. 'person'.| | `label` | | A label used to filter events (clips & snapshots), e.g. 'person'.|
| `zone` | | A zone used to filter events (clips & snapshots), e.g. 'front_door'.| | `zone` | | A zone used to filter events (clips & snapshots), e.g. 'front_door'.|
| `update_entities` | | A list of entity ids that should cause the whole card to re-render, this can be useful in the `clip` or `snapshot` mode to (for example) cause a motion sensor to trigger a card refresh. Configurable in YAML only. Entities used in picture elements / included in the menu do not need to be explicitly included here to be kept updated. |
<a name="webrtc"></a> <a name="webrtc"></a>
@@ -189,20 +192,6 @@ webrtc:
See [WebRTC configuration](https://github.com/AlexxIT/WebRTC#configuration) for full configuration options. See [WebRTC configuration](https://github.com/AlexxIT/WebRTC#configuration) for full configuration options.
<a name="entities"></a>
### Entities
Additional entities may be configured to trigger updates to the card, and
optionally to appear in the menu. An `entities` section may be added to the card
configuration containing a list with entries of the following format:
| Option | Default | Description |
| ------------- | - | -------------------------------------------- |
| `entity` | | Entity ID to use to trigger updates, and optionally appear in the menu. |
| `icon` | [default entity icon] | An optional manual override of the icon to use in the menu, e.g. `mdi:car`. |
| `show`| `true` | Whether or not to show the entity in the menu. When `false` the entity ID will trigger card updates only, but not appear in the menu. |
#### Example #### Example
This example allows access to the detection, recordings and snapshots switches This example allows access to the detection, recordings and snapshots switches
@@ -218,6 +207,202 @@ entities:
show: false show: false
``` ```
## Picture Elements / Menu customizations
This card supports the [Picture Elements configuration
syntax](https://www.home-assistant.io/lovelace/picture-elements/) to seamlessly
allow the user to add custom elements to the card, which may be configured to
perform a variety of actions on `tap`, `double_tap` and `hold`.
In the card YAML configuration, elements may be manually added under an
`elements` key.
See the [action
documentation](https://www.home-assistant.io/lovelace/actions/#hold-action) for
more information on the action options available.
### Special Elements
This card supports all [Picture Elements](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) using the same syntax. The card also supports a handful of custom special elements to add special Frigate card functionality.
| Element name | Description |
| ------------- | --------------------------------------------- |
| `custom:frigate-card-menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture Elements Icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) except with a type name of `custom:frigate-card-menu-icon`.|
| `custom:frigate-card-menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture Elements State Icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon) except with a type name of `custom:frigate-card-menu-state-icon`.|
| `custom:frigate-card-conditional` | Restrict a set of elements to only render when the card is showing particular a particular [view](#views). See [configuration below](#frigate-card-conditional).|
<a name="frigate-card-conditional"></a>
### `custom:frigate-card-conditional`
Parameters for the `custom:frigate-card-conditional` element:
| Parameter | Description |
| ------------- | --------------------------------------------- |
| `type` | Must be `custom:frigate-card-conditional`. |
| `conditions` | A set of conditions that must evaluate to true in order for the elements to be rendered. |
| `conditions.view` | A list of [views](#views) in which these elements should be rendered. |
| `elements` | The elements to render. Can be any supported element, include additional condition or custom elements. |
See the [PTZ example below](#frigate-card-conditional-example) for a real-world example.
### Elements Examples
#### Menu icons
You can add custom icons to the menu with arbitrary actions.
<details>
<summary>Expand: Custom menu icon</summary>
This example adds an icon that navigates the brower to the releases page for this
card:
```yaml
- type: custom:frigate-card-menu-icon
icon: mdi:book
tap_action:
action: url
url_path: https://github.com/dermotduffy/frigate-hass-card/releases
```
</details>
#### Menu state icons
You can add custom state icons to the menu to show the state of an entity and complete arbitrary actions.
<details>
<summary>Expand: Custom menu state icon</summary>
This example adds an icon that represents the state of the
`light.office_main_lights` entity, that toggles the light on double click.
```yaml
elements:
- type: custom:frigate-card-menu-state-icon
entity: light.office_main_lights
tap_action:
action: toggle
```
</details>
#### State badges
You can adds a state badge to the card showing arbitrary entity states.
<details>
<summary>Expand: State badge</summary>
This example adds a state badge showing the temperature and hides the label text:
```yaml
- type: state-badge
entity: sensor.kitchen_temperature
style:
right: '-20px'
top: 100px
color: rgba(0,0,0,0)
opacity: 0.5
```
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/picture_elements_temperature.png" alt="Picture elements temperature example" width="400px">
</details>
#### Conditional menu icons
You can have icons conditionally added to the menu based on entity state.
<details>
<summary>Expand: Conditional menu icons</summary>
This example only adds the light entity to the menu if a light is on.
```yaml
- type: conditional
conditions:
- entity: light.kitchen
state: 'on'
elements:
- type: custom:frigate-card-menu-state-icon
entity: light.kitchen
tap_action:
action: toggle
```
</details>
<a name="frigate-card-conditional-example"></a>
#### Restricting icons to certain views
You can restrict icons to only show for certain [views](#views) using a
`custom:frigate-card-conditional` element (e.g. PTZ controls)
<details>
<summary>Expand: View-based conditions (e.g. PTZ controls)</summary>
This example shows PTZ icons that call a PTZ service, but only in the `live` view.
```yaml
elements:
- type: custom:frigate-card-conditional
conditions:
view:
- live
elements:
- type: icon
icon: mdi:arrow-up
style:
background: rgba(255, 255, 255, 0.25)
border-radius: 5px
right: 25px
bottom: 50px
tap_action:
action: call-service
service: amcrest.ptz_control
service_data:
entity_id: camera.kitchen
movement: up
- type: icon
icon: mdi:arrow-down
style:
background: rgba(255, 255, 255, 0.25)
border-radius: 5px
right: 25px
bottom: 0px
tap_action:
action: call-service
service: amcrest.ptz_control
service_data:
entity_id: camera.kitchen
movement: down
- type: icon
icon: mdi:arrow-left
style:
background: rgba(255, 255, 255, 0.25)
border-radius: 5px
right: 50px
bottom: 25px
tap_action:
action: call-service
service: amcrest.ptz_control
service_data:
entity_id: camera.kitchen
movement: left
- type: icon
icon: mdi:arrow-right
style:
background: rgba(255, 255, 255, 0.25)
border-radius: 5px
right: 0px
bottom: 25px
tap_action:
action: call-service
service: amcrest.ptz_control
service_data:
entity_id: camera.kitchen
movement: right
```
</details>
<a name="views"></a> <a name="views"></a>
## Views ## Views
@@ -235,18 +420,18 @@ This card supports several different views.
### Automatic updates in the `clip` or `snapshot` view ### Automatic updates in the `clip` or `snapshot` view
Updates will occur whenever on every change of the state of the `camera_entity` Updates will occur whenever on every change of the state of the `camera_entity`
or any entity configured under `entities`. In particular, if the desire is or any entity configured under `update_entities`. In particular, if the desire is
to have an auto-refreshing view of the most recent event, the `camera_entity` to have an auto-refreshing view of the most recent event, the `camera_entity`
will not be sufficient alone since the Home Assistant state for Frigate camera will not be sufficient alone since the Home Assistant state for Frigate camera
entities does not change often. Instead, use the Frigate binary_sensor for that entities does not change often. Instead, use the Frigate binary_sensor for that
camera (or any other entity at your discretion) to trigger the update: camera (or any other entity at your discretion) to trigger the update:
```yaml ```yaml
entities: update_entities:
- entity: binary_sensor.office_person_motion - binary_sensor.office_person_motion
``` ```
See [entities](#entities) above. See the [advanced options](#advanced-options) above.
### Getting from a snapshot to a clip ### Getting from a snapshot to a clip
Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

+10 -10
View File
@@ -16,28 +16,28 @@
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^5.0.1", "@cycjimmy/jsmpeg-player": "^5.0.1",
"@material/image-list": "^12.0.0", "@material/image-list": "^12.0.0",
"custom-card-helpers": "^1.7.2", "custom-card-helpers": "^1.8.0",
"dayjs": "^1.10.6", "dayjs": "^1.10.7",
"home-assistant-js-websocket": "^5.11.1", "home-assistant-js-websocket": "^5.11.1",
"lit": "^2.0.0-rc.2", "lit": "^2.0.0",
"screenfull": "^5.1.0", "screenfull": "^5.1.0",
"zod": "^3.8.2" "zod": "^3.9.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.15.5", "@babel/core": "^7.15.5",
"@babel/plugin-proposal-class-properties": "^7.14.5", "@babel/plugin-proposal-class-properties": "^7.14.5",
"@babel/plugin-proposal-decorators": "^7.15.4", "@babel/plugin-proposal-decorators": "^7.15.4",
"@rollup/plugin-json": "^4.1.0", "@rollup/plugin-json": "^4.1.0",
"@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/eslint-plugin": "^4.32.0",
"@typescript-eslint/parser": "^4.30.0", "@typescript-eslint/parser": "^4.32.0",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-config-airbnb-base": "^14.2.1", "eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.24.2", "eslint-plugin-import": "^2.24.2",
"eslint-plugin-prettier": "^3.4.1", "eslint-plugin-prettier": "^3.4.1",
"npm-check-updates": "^11.8.5", "npm-check-updates": "^11.8.5",
"prettier": "^2.3.2", "prettier": "^2.4.1",
"rollup": "^2.56.3", "rollup": "^2.58.0",
"rollup-plugin-babel": "^4.4.0", "rollup-plugin-babel": "^4.4.0",
"rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-node-resolve": "^5.2.0",
@@ -45,8 +45,8 @@
"rollup-plugin-styles": "^3.14.1", "rollup-plugin-styles": "^3.14.1",
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.30.0", "rollup-plugin-typescript2": "^0.30.0",
"sass": "^1.39.0", "sass": "^1.42.1",
"typescript": "^4.4.2" "typescript": "^4.4.3"
}, },
"scripts": { "scripts": {
"start": "rollup -c rollup.config.dev.js --watch", "start": "rollup -c rollup.config.dev.js --watch",
+2 -4
View File
@@ -7,10 +7,8 @@ import {
} from 'custom-card-helpers/dist/types'; } from 'custom-card-helpers/dist/types';
import { fireEvent } from 'custom-card-helpers'; import { fireEvent } from 'custom-card-helpers';
const isTouch = // eslint-disable-next-line @typescript-eslint/no-explicit-any
'ontouchstart' in window || const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || (navigator as any).msMaxTouchPoints > 0;
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0;
interface ActionHandler extends HTMLElement { interface ActionHandler extends HTMLElement {
holdTime: number; holdTime: number;
+121 -84
View File
@@ -14,13 +14,12 @@ import { until } from 'lit/directives/until';
import { import {
HomeAssistant, HomeAssistant,
LovelaceCardEditor, LovelaceCardEditor,
fireEvent,
getLovelace, getLovelace,
stateIcon, handleAction,
} from 'custom-card-helpers'; } from 'custom-card-helpers';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { entitySchema, frigateCardConfigSchema } from './types'; import { entitySchema, frigateCardConfigSchema, Message } from './types';
import type { import type {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
Entity, Entity,
@@ -33,11 +32,16 @@ import type {
import { CARD_VERSION } from './const'; import { CARD_VERSION } from './const';
import { FrigateCardMenu, MENU_HEIGHT } from './components/menu'; import { FrigateCardMenu, MENU_HEIGHT } from './components/menu';
import { View } from './view'; import { View } from './view';
import { getParseErrorKeys, homeAssistantWSRequest } from './common'; import {
getParseErrorKeys,
homeAssistantWSRequest,
shouldUpdateBasedOnHass,
} from './common';
import { localize } from './localize/localize'; import { localize } from './localize/localize';
import { renderErrorMessage, renderProgressIndicator } from './components/message'; import { renderMessage, renderProgressIndicator } from './components/message';
import './editor'; import './editor';
import './components/elements';
import './components/gallery'; import './components/gallery';
import './components/live'; import './components/live';
import './components/menu'; import './components/menu';
@@ -47,6 +51,7 @@ import './patches/ha-camera-stream';
import './patches/ha-hls-player'; import './patches/ha-hls-player';
import cardStyle from './scss/card.scss'; import cardStyle from './scss/card.scss';
import { FrigateCardElements } from './components/elements';
const MEDIA_HEIGHT_CUTOFF = 50; const MEDIA_HEIGHT_CUTOFF = 50;
const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF; const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF;
@@ -83,34 +88,6 @@ console.info(
description: localize('common.frigate_card_description'), description: localize('common.frigate_card_description'),
}); });
// Determine whether the card should be updated based on Home Assistant changes.
function shouldUpdateBasedOnHass(
newHass: HomeAssistant | null,
oldHass: HomeAssistant | undefined,
entities: string[] | null,
): boolean {
if (!newHass || !entities) {
return false;
}
if (!entities.length) {
return false;
}
if (oldHass) {
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (!entity) {
continue;
}
if (oldHass.states[entity] !== newHass.states[entity]) {
return true;
}
}
return false;
}
return false;
}
// Main FrigateCard class. // Main FrigateCard class.
@customElement('frigate-card') @customElement('frigate-card')
export class FrigateCard extends LitElement { export class FrigateCard extends LitElement {
@@ -128,6 +105,9 @@ export class FrigateCard extends LitElement {
@query('frigate-card-menu') @query('frigate-card-menu')
_menu!: FrigateCardMenu; _menu!: FrigateCardMenu;
@query('frigate-card-elements')
_elements!: FrigateCardElements;
// Whether or not media is actively playing (live or clip). // Whether or not media is actively playing (live or clip).
protected _mediaPlaying = false; protected _mediaPlaying = false;
@@ -142,9 +122,23 @@ export class FrigateCard extends LitElement {
// derived). // derived).
protected _frigateCameraName: string | null = null; protected _frigateCameraName: string | null = null;
// Error/info message to render.
protected _message: Message | null = null;
set hass(hass: HomeAssistant & ExtendedHomeAssistant) { set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
this._hass = hass; this._hass = hass;
this._updateMenu();
// Manually set hass in the menu & elements. This is to allow these to
// update, without necessarily re-rendering the entire card (re-rendering
// interrupts clip playing).
if (this._hass) {
if (this._menu) {
this._menu.hass = this._hass;
}
if (this._elements) {
this._elements.hass = this._hass;
}
}
} }
// Get the configuration element. // Get the configuration element.
@@ -157,67 +151,57 @@ export class FrigateCard extends LitElement {
return {}; return {};
} }
protected _updateMenu(): void { protected _getMenuButtons(): MenuButton[] {
// Manually set hass in the menu. This is to allow the menu to update, const buttons: MenuButton[] = [];
// without necessarily re-rendering the entire card (re-rendering interrupts
// clip playing).
if (!this._menu || !this._hass) {
return;
}
this._menu.buttons = this._getMenuButtons();
}
protected _getMenuButtons(): Map<string, MenuButton> {
const buttons: Map<string, MenuButton> = new Map();
if (this.config.menu_buttons?.frigate ?? true) { if (this.config.menu_buttons?.frigate ?? true) {
buttons.set('frigate', { description: localize('menu.frigate') }); buttons.push({
type: 'internal-menu-icon',
card_action: 'frigate',
title: localize('menu.frigate'),
});
} }
if (this.config.menu_buttons?.live ?? true) { if (this.config.menu_buttons?.live ?? true) {
buttons.set('live', { buttons.push({
type: 'internal-menu-icon',
card_action: 'live',
title: localize('menu.live'),
icon: 'mdi:cctv', icon: 'mdi:cctv',
description: localize('menu.live'),
emphasize: this._view.is('live'), emphasize: this._view.is('live'),
}); });
} }
if (this.config.menu_buttons?.clips ?? true) { if (this.config.menu_buttons?.clips ?? true) {
buttons.set('clips', { buttons.push({
type: 'internal-menu-icon',
card_action: 'clips',
title: localize('menu.clips'),
icon: 'mdi:filmstrip', icon: 'mdi:filmstrip',
description: localize('menu.clips'),
emphasize: this._view.is('clips'), emphasize: this._view.is('clips'),
}); });
} }
if (this.config.menu_buttons?.snapshots ?? true) { if (this.config.menu_buttons?.snapshots ?? true) {
buttons.set('snapshots', { buttons.push({
type: 'internal-menu-icon',
card_action: 'snapshots',
title: localize('menu.snapshots'),
icon: 'mdi:camera', icon: 'mdi:camera',
description: localize('menu.snapshots'),
emphasize: this._view.is('snapshots'), emphasize: this._view.is('snapshots'),
}); });
} }
if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) { if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) {
buttons.set('frigate_ui', { buttons.push({
type: 'internal-menu-icon',
card_action: 'frigate_ui',
title: localize('menu.frigate_ui'),
icon: 'mdi:web', icon: 'mdi:web',
description: localize('menu.frigate_ui'),
}); });
} }
if ((this.config.menu_buttons?.fullscreen ?? true) && screenfull.isEnabled) { if ((this.config.menu_buttons?.fullscreen ?? true) && screenfull.isEnabled) {
buttons.set('fullscreen', { buttons.push({
type: 'internal-menu-icon',
card_action: 'fullscreen',
title: localize('menu.fullscreen'),
icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
description: localize('menu.fullscreen'),
});
}
const entities = this.config.entities || [];
for (let i = 0; this._hass && i < entities.length; i++) {
if (!entities[i].show) {
continue;
}
const entity = entities[i].entity;
const state = this._hass.states[entity];
buttons.set(entity, {
description: state.attributes.friendly_name || entity,
emphasize: ['on', 'active', 'home'].includes(state.state),
icon: entities[i].icon || stateIcon(state),
}); });
} }
return buttons; return buttons;
@@ -285,13 +269,15 @@ export class FrigateCard extends LitElement {
this.config = config; this.config = config;
this._entitiesToMonitor = [ this._entitiesToMonitor = [
...(this.config.entities || []).map((entity) => entity.entity), ...(this.config.update_entities || []),
this.config.camera_entity, this.config.camera_entity,
]; ];
this._changeView(); this._changeView();
} }
protected _changeView(view?: View | undefined): void { protected _changeView(view?: View | undefined): void {
this._message = null;
if (view === undefined) { if (view === undefined) {
this._view = new View({ view: this.config.view_default }); this._view = new View({ view: this.config.view_default });
} else { } else {
@@ -328,15 +314,20 @@ export class FrigateCard extends LitElement {
return true; return true;
} }
protected _menuActionHandler(name: string): void { protected _menuActionHandler(action: string, button: MenuButton): void {
switch (name) { if (button.type != 'internal-menu-icon') {
handleAction(this, this._hass as HomeAssistant, button, action);
return;
}
switch (button.card_action) {
case 'frigate': case 'frigate':
this._changeView(); this._changeView();
break; break;
case 'live': case 'live':
case 'clips': case 'clips':
case 'snapshots': case 'snapshots':
this._changeView(new View({ view: name })); this._changeView(new View({ view: button.card_action }));
break; break;
case 'frigate_ui': case 'frigate_ui':
const frigate_url = this._getFrigateURLFromContext(); const frigate_url = this._getFrigateURLFromContext();
@@ -350,8 +341,7 @@ export class FrigateCard extends LitElement {
} }
break; break;
default: default:
// If it's unknown, it's assumed to be an entity_id. console.warn(`Frigate card received unknown menu action: ${button.card_action}`);
fireEvent(this, 'hass-more-info', { entityId: name });
} }
} }
@@ -389,6 +379,7 @@ export class FrigateCard extends LitElement {
return html` return html`
<frigate-card-menu <frigate-card-menu
class="${classMap(classes)}" class="${classMap(classes)}"
.hass=${this._hass}
.actionCallback=${this._menuActionHandler.bind(this)} .actionCallback=${this._menuActionHandler.bind(this)}
.menuMode=${this.config.menu_mode} .menuMode=${this.config.menu_mode}
.buttons=${this._getMenuButtons()} .buttons=${this._getMenuButtons()}
@@ -418,6 +409,18 @@ export class FrigateCard extends LitElement {
this._mediaPlaying = false; this._mediaPlaying = false;
} }
protected _setMessageAndUpdate(message: Message): void {
// Register the first message, or prioritize errors if there's pre-render competition.
if (!this._message || (message.type == 'error' && this._message.type != 'error')) {
this._message = message;
this.requestUpdate();
}
}
protected _messageHandler(e: CustomEvent<Message>): void {
return this._setMessageAndUpdate(e.detail);
}
protected _mediaLoadHandler(e: CustomEvent<MediaLoadInfo>): void { protected _mediaLoadHandler(e: CustomEvent<MediaLoadInfo>): void {
const mediaInfo = e.detail; const mediaInfo = e.detail;
// In Safari, with WebRTC, 0x0 is occasionally returned during loading, // In Safari, with WebRTC, 0x0 is occasionally returned during loading,
@@ -524,7 +527,9 @@ export class FrigateCard extends LitElement {
window.innerWidth / window.innerHeight window.innerWidth / window.innerHeight
) { ) {
// If the menu is outside the media (i.e. above/below) allow space for it. // If the menu is outside the media (i.e. above/below) allow space for it.
const allowance = ["above", "below"].includes(this.config.menu_mode) ? MENU_HEIGHT : 0; const allowance = ['above', 'below'].includes(this.config.menu_mode)
? MENU_HEIGHT
: 0;
innerStyle['max-width'] = `calc(${ innerStyle['max-width'] = `calc(${
(100 * this._mediaInfo.width) / this._mediaInfo.height (100 * this._mediaInfo.width) / this._mediaInfo.height
}vh - ${allowance}px )`; }vh - ${allowance}px )`;
@@ -539,29 +544,41 @@ export class FrigateCard extends LitElement {
${this.config.menu_mode == 'above' ? this._renderMenu() : ''} ${this.config.menu_mode == 'above' ? this._renderMenu() : ''}
<div class="container outer" style="${styleMap(outerStyle)}"> <div class="container outer" style="${styleMap(outerStyle)}">
<div class="${classMap(contentClasses)}" style="${styleMap(innerStyle)}"> <div class="${classMap(contentClasses)}" style="${styleMap(innerStyle)}">
${until(this._render(), renderProgressIndicator())} ${this._message
? renderMessage(this._message)
: until(this._render(), renderProgressIndicator())}
</div> </div>
</div> </div>
${this.config.menu_mode != 'above' ? this._renderMenu() : ''} ${this.config.menu_mode != 'above' ? this._renderMenu() : ''}
</ha-card>`; </ha-card>`;
} }
protected async _render(): Promise<TemplateResult> { protected async _render(): Promise<TemplateResult | void> {
if (!this._frigateCameraName) { if (!this._frigateCameraName) {
this._frigateCameraName = await this._getFrigateCameraName(); this._frigateCameraName = await this._getFrigateCameraName();
} }
const mediaQueryParameters = this._getBrowseMediaQueryParameters(); const mediaQueryParameters = this._getBrowseMediaQueryParameters();
if (!this._frigateCameraName || !mediaQueryParameters) { if (!this._hass || !this._frigateCameraName || !mediaQueryParameters) {
return renderErrorMessage(localize('error.no_frigate_camera_name')); return this._setMessageAndUpdate({
message: localize('error.no_frigate_camera_name'),
type: 'error',
});
} }
const pictureElementsClasses = {
'picture-elements': true,
gallery: this._view.isGalleryView(),
};
return html` return html`
<div class="${classMap(pictureElementsClasses)}">
${this._view.is('clips') || this._view.is('snapshots') ${this._view.is('clips') || this._view.is('snapshots')
? html` <frigate-card-gallery ? html` <frigate-card-gallery
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
.browseMediaQueryParameters=${mediaQueryParameters} .browseMediaQueryParameters=${mediaQueryParameters}
@frigate-card:change-view=${this._changeViewHandler} @frigate-card:change-view=${this._changeViewHandler}
@frigate-card:message=${this._messageHandler}
> >
</frigate-card-gallery>` </frigate-card-gallery>`
: ``} : ``}
@@ -576,20 +593,40 @@ export class FrigateCard extends LitElement {
@frigate-card:media-load=${this._mediaLoadHandler} @frigate-card:media-load=${this._mediaLoadHandler}
@frigate-card:pause=${this._pauseHandler} @frigate-card:pause=${this._pauseHandler}
@frigate-card:play=${this._playHandler} @frigate-card:play=${this._playHandler}
@frigate-card:message=${this._messageHandler}
> >
</frigate-card-viewer>` </frigate-card-viewer>`
: ``} : ``}
${this._view.is('live') ${this._view.is('live')
? html` <frigate-card-live ? html`
<frigate-card-live
.hass=${this._hass} .hass=${this._hass}
.config=${this.config} .config=${this.config}
.frigateCameraName=${this._frigateCameraName} .frigateCameraName=${this._frigateCameraName}
@frigate-card:media-load=${this._mediaLoadHandler} @frigate-card:media-load=${this._mediaLoadHandler}
@frigate-card:pause=${this._pauseHandler} @frigate-card:pause=${this._pauseHandler}
@frigate-card:play=${this._playHandler} @frigate-card:play=${this._playHandler}
@frigate-card:message=${this._messageHandler}
> >
</frigate-card-live>` </frigate-card-live>
`
: ``} : ``}
<frigate-card-elements
.hass=${this._hass}
.elements=${this.config.elements}
@frigate-card:message=${this._messageHandler}
@frigate-card:menu-add=${(e) => {
this._menu.addButton(e.detail);
}}
@frigate-card:menu-remove=${(e) => {
this._menu.removeButton(e.detail);
}}
@frigate-card:state-request=${(e) => {
e.view = this._view;
}}
>
</frigate-card-elements>
</div>
`; `;
} }
+57 -3
View File
@@ -7,6 +7,7 @@ import type {
BrowseMediaSource, BrowseMediaSource,
ExtendedHomeAssistant, ExtendedHomeAssistant,
MediaLoadInfo, MediaLoadInfo,
Message,
} from './types'; } from './types';
import { browseMediaSourceSchema } from './types'; import { browseMediaSourceSchema } from './types';
@@ -108,14 +109,17 @@ export function dispatchEvent<T>(element: HTMLElement, name: string, detail?: T)
} }
export function dispatchPlayEvent(element: HTMLElement): void { export function dispatchPlayEvent(element: HTMLElement): void {
dispatchEvent(element, 'play') dispatchEvent(element, 'play');
} }
export function dispatchPauseEvent(element: HTMLElement): void { export function dispatchPauseEvent(element: HTMLElement): void {
dispatchEvent(element, 'pause') dispatchEvent(element, 'pause');
} }
export function dispatchMediaLoadEvent(element: HTMLElement, source: Event | HTMLElement): void { export function dispatchMediaLoadEvent(
element: HTMLElement,
source: Event | HTMLElement,
): void {
let target: HTMLElement | EventTarget; let target: HTMLElement | EventTarget;
if (source instanceof Event) { if (source instanceof Event) {
target = source.composedPath()[0]; target = source.composedPath()[0];
@@ -140,3 +144,53 @@ export function dispatchMediaLoadEvent(element: HTMLElement, source: Event | HTM
}); });
} }
} }
export function dispatchMessageEvent(
element: HTMLElement,
message: string,
icon?: string,
): void {
dispatchEvent<Message>(element, 'message', {
message: message,
type: 'info',
icon: icon,
});
}
export function dispatchErrorMessageEvent(
element: HTMLElement,
message: string,
): void {
dispatchEvent<Message>(element, 'message', {
message: message,
type: 'error',
});
}
// Determine whether the card should be updated based on Home Assistant changes.
export function shouldUpdateBasedOnHass(
newHass: HomeAssistant | null,
oldHass: HomeAssistant | undefined,
entities: string[] | null,
): boolean {
if (!newHass || !entities) {
return false;
}
if (!entities.length) {
return false;
}
if (oldHass) {
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (!entity) {
continue;
}
if (oldHass.states[entity] !== newHass.states[entity]) {
return true;
}
}
return false;
}
return false;
}
+286
View File
@@ -0,0 +1,286 @@
import { LitElement, TemplateResult, html, CSSResultGroup, unsafeCSS } from 'lit';
import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property, query } from 'lit/decorators';
import {
ExtendedHomeAssistant,
FrigateConditional,
MenuButton,
MenuIcon,
MenuStateIcon,
PictureElements,
} from '../types';
import { dispatchErrorMessageEvent, dispatchEvent } from '../common';
import elementsStyle from '../scss/elements.scss';
import { localize } from '../localize/localize';
import { View } from '../view';
/* A note on picture element rendering:
*
* To avoid needing to deal with the rendering of all the picture elements
* ourselves, instead the card relies on a stock conditional element (with no
* conditions) to render elements (this._root). This has a few advantages:
*
* - Does not depend on (much of!) an internal API -- conditional picture
* elements are unlikely to go away or change.
* - Forces usage of elements that HA understands. If the rendering is done
* directly, it is (ask me how I know!) very tempting to render things in such
* a way that a nested conditional element would not be able to render, i.e.
* the custom rendering logic would only apply at the first level.
*/
/* A note on custom elements:
*
* The native HA support for custom elements is used for the menu-icon and
* menu-state-icon elements. This ensures multi-nested conditionals will work
* correctly. These custom elements 'render' by firing events that are caught by
* the card to call for inclusion/exclusion of the menu icon in question.
*
* One major complexity here is that the top <frigate-card-elements> element
* will not necessarily know when a menu icon is no longer rendered because of a
* conditional that no-longer evaluates to true. As such, it cannot know when to
* signal for the menu icon removal. Furthermore, the menu icon element itself
* will only know it's been removed _after_ it's been disconnected from the DOM,
* so normal event propagation at that point will not work. Instead, we must
* catch the menu icon _addition_ and register the eventhandler for the removal
* directly on the child (which will have no parent at time of calling). That
* then triggers <frigate-card-elements> to re-dispatch a removal event for
* upper layers to handle correctly.
*/
// 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 {
@property({ attribute: false })
protected elements: PictureElements;
protected _root: HTMLElement | null = null;
protected _hass!: HomeAssistant & ExtendedHomeAssistant;
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;
}
// Transparent to elements.
createRenderRoot(): LitElement {
return this;
}
protected _createRoot(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const elementConstructor = customElements.get('hui-conditional-element') as any;
if (!elementConstructor) {
throw new Error(localize('error.could_not_render_elements'));
}
const element = new elementConstructor();
element.hass = this._hass;
const config = {
type: 'conditional',
conditions: [],
elements: this.elements,
};
try {
element.setConfig(config);
} catch (e) {
console.error(e, (e as Error).stack);
throw new Error(localize('error.invalid_elements_config'));
}
this._root = element;
}
protected render(): TemplateResult | void {
if (!this._root) {
try {
this._createRoot();
} catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message);
}
}
return html`${this._root || ''}`;
}
}
// THe master <frigate-card-elements> class, handles event listeners and styles.
@customElement('frigate-card-elements')
export class FrigateCardElements extends LitElement {
@property({ attribute: false })
protected elements: PictureElements;
protected _hass!: HomeAssistant & ExtendedHomeAssistant;
@query('frigate-card-elements-core')
_core!: FrigateCardElementsCore;
set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
if (this._core) {
this._core.hass = hass;
}
this._hass = hass;
}
protected _menuRemoveHandler(ev: Event): void {
// Re-dispatch event from this element (instead of the disconnected one, as
// there is no parent of the disconnected element).
dispatchEvent<MenuButton>(this, 'menu-remove', (ev as CustomEvent).detail);
}
protected _menuAddHandler(ev: Event): void {
ev = ev as CustomEvent<MenuButton>;
const path = ev.composedPath();
if (!path.length) {
return;
}
// See 'A note on custom elements' above to explain what's going on here.
// Ensure listener is only attached 1 time by removing it first.
path[0].removeEventListener(
'frigate-card:menu-remove',
this._menuRemoveHandler.bind(this),
);
path[0].addEventListener(
'frigate-card:menu-remove',
this._menuRemoveHandler.bind(this),
);
}
connectedCallback(): void {
super.connectedCallback();
// Catch icons being added to the menu (so their removal can be subsequently
// handled).
this.addEventListener('frigate-card:menu-add', this._menuAddHandler);
}
disconnectedCallback(): void {
this.removeEventListener('frigate-card:menu-add', this._menuAddHandler);
super.disconnectedCallback();
}
protected render(): TemplateResult {
return html` <frigate-card-elements-core
.hass=${this._hass}
.elements=${this.elements}
>
</frigate-card-elements-core>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(elementsStyle);
}
}
class StateRequestEvent extends Event {
public view: View | undefined;
}
// An element that can render others based on Frigate state (e.g. only show
// overlays in particular views). This is the Frigate Card equivalent to the HA
// conditional card.
@customElement('frigate-card-conditional')
export class FrigateCardElementsConditional extends LitElement {
protected _config: FrigateConditional | null = null;
protected _hass!: HomeAssistant & ExtendedHomeAssistant;
@query('frigate-card-elements-core')
_core!: FrigateCardElementsCore;
set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
if (this._core) {
this._core.hass = hass;
}
this._hass = hass;
}
public setConfig(config: FrigateConditional): void {
this._config = config;
}
// Transparent to elements.
createRenderRoot(): LitElement {
return this;
}
protected evaluate(stateEvent: StateRequestEvent): boolean {
if (stateEvent.view && this._config.conditions.view) {
return this._config.conditions.view.includes(stateEvent.view.view);
}
return true;
}
connectedCallback(): void {
super.connectedCallback();
// HA will automatically attach the 'element' class to picture elements. As
// this is a transparent 'conditional' element (just like the stock HA
// 'conditional' element), it should not have positioning.
this.className = '';
}
protected render(): TemplateResult | void {
const stateEvent = new StateRequestEvent(`frigate-card:state-request`, {
bubbles: true,
composed: true,
});
/* Special note on what's going on here:
*
* Picture elements all are descendents of <frigate-card-elements>, but
* there may be arbitrary complexity and layers (that this card doesn't
* control) between that master element and this custom conditional element.
* This element needs Frigate card state to function (e.g. view), but
* there's no clean way to pass state from the rest of card down through
* these layers. Instead, we dispatch a "request for state"
* (StateRequestEvent) event upwards which is caught by the outer card and
* state added to the event object. Because event propagation is handled
* synchronously, the state will be added to the event before the flow
* proceeds.
*/
this.dispatchEvent(stateEvent);
if (this.evaluate(stateEvent)) {
return html` <frigate-card-elements-core
.hass=${this._hass}
.elements=${this._config.elements}
>
</frigate-card-elements-core>`;
}
}
}
// A base class for rendering menu icons / menu state icons.
export class FrigateCardElementsBaseMenuIcon<T> extends LitElement {
@property({ attribute: false })
protected _config: T | null = null;
public setConfig(config: T): void {
this._config = config;
}
connectedCallback(): void {
super.connectedCallback();
if (this._config) {
dispatchEvent<T>(this, 'menu-add', this._config);
}
}
disconnectedCallback(): void {
if (this._config) {
dispatchEvent<T>(this, 'menu-remove', this._config);
}
super.disconnectedCallback();
}
}
@customElement('frigate-card-menu-icon')
export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon<MenuIcon> {}
@customElement('frigate-card-menu-state-icon')
export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon<MenuStateIcon> {}
+12 -5
View File
@@ -11,9 +11,15 @@ import type {
} from '../types'; } from '../types';
import { View } from '../view'; import { View } from '../view';
import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common'; import {
browseMedia,
browseMediaQuery,
dispatchErrorMessageEvent,
dispatchMessageEvent,
getFirstTrueMediaChildIndex,
} from '../common';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { renderMessage, renderErrorMessage, renderProgressIndicator } from './message'; import { renderProgressIndicator } from './message';
import galleryStyle from '../scss/gallery.scss'; import galleryStyle from '../scss/gallery.scss';
import { styleMap } from 'lit/directives/style-map.js'; import { styleMap } from 'lit/directives/style-map.js';
@@ -65,7 +71,7 @@ export class FrigateCardGallery extends LitElement {
return html`${until(this._render(), renderProgressIndicator())}`; return html`${until(this._render(), renderProgressIndicator())}`;
} }
protected async _render(): Promise<TemplateResult> { protected async _render(): Promise<TemplateResult | void> {
let parent: BrowseMediaSource | null; let parent: BrowseMediaSource | null;
try { try {
if (this.view.target) { if (this.view.target) {
@@ -74,11 +80,12 @@ export class FrigateCardGallery extends LitElement {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
} }
} catch (e: any) { } catch (e: any) {
return renderErrorMessage(e.message); return dispatchErrorMessageEvent(this, e.message);
} }
if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) { if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) {
return renderMessage( return dispatchMessageEvent(
this,
this._getMediaType() == 'clips' this._getMediaType() == 'clips'
? localize('common.no_clips') ? localize('common.no_clips')
: localize('common.no_snapshots'), : localize('common.no_snapshots'),
+14 -9
View File
@@ -8,16 +8,14 @@ import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { import {
dispatchErrorMessageEvent,
dispatchMediaLoadEvent, dispatchMediaLoadEvent,
dispatchMessageEvent,
dispatchPauseEvent, dispatchPauseEvent,
dispatchPlayEvent, dispatchPlayEvent,
homeAssistantWSRequest, homeAssistantWSRequest,
} from '../common'; } from '../common';
import { import { renderProgressIndicator } from '../components/message';
renderMessage,
renderErrorMessage,
renderProgressIndicator,
} from '../components/message';
import JSMpeg from '@cycjimmy/jsmpeg-player'; import JSMpeg from '@cycjimmy/jsmpeg-player';
@@ -74,7 +72,11 @@ export class FrigateCardViewerFrigate extends LitElement {
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!(this.cameraEntity in this.hass.states)) { if (!(this.cameraEntity in this.hass.states)) {
return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off'); return dispatchMessageEvent(
this,
localize('error.no_live_camera'),
'mdi:camera-off',
);
} }
return html` <frigate-card-ha-camera-stream return html` <frigate-card-ha-camera-stream
.hass=${this.hass} .hass=${this.hass}
@@ -120,7 +122,7 @@ export class FrigateCardViewerWebRTC extends LitElement {
try { try {
this._createWebRTC(); this._createWebRTC();
} catch (e) { } catch (e) {
return renderErrorMessage((e as Error).message); return dispatchErrorMessageEvent(this, (e as Error).message);
} }
} }
return html`${this._webRTCElement}`; return html`${this._webRTCElement}`;
@@ -213,7 +215,7 @@ export class FrigateCardViewerJSMPEG extends LitElement {
return html`${until(this._render(), renderProgressIndicator())}`; return html`${until(this._render(), renderProgressIndicator())}`;
} }
protected async _render(): Promise<TemplateResult> { protected async _render(): Promise<TemplateResult | void> {
if (!this._jsmpegCanvasElement) { if (!this._jsmpegCanvasElement) {
this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement = document.createElement('canvas');
this._jsmpegCanvasElement.className = 'media'; this._jsmpegCanvasElement.className = 'media';
@@ -223,7 +225,10 @@ export class FrigateCardViewerJSMPEG extends LitElement {
const jsmpeg_url = await this._getURL(); const jsmpeg_url = await this._getURL();
if (!jsmpeg_url) { if (!jsmpeg_url) {
return renderErrorMessage('Could not retrieve or sign JSMPEG websocket path'); return dispatchErrorMessageEvent(
this,
'Could not retrieve or sign JSMPEG websocket path',
);
} }
let videoDecoded = false; let videoDecoded = false;
+89 -24
View File
@@ -1,19 +1,26 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { HomeAssistant, hasAction, stateIcon } from 'custom-card-helpers';
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS, PropertyValues } from 'lit';
import { actionHandler } from '../action-handler-directive';
import { customElement, property } from 'lit/decorators'; import { customElement, property } from 'lit/decorators';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { MenuButton } from '../types'; import { MenuButton } from '../types';
import type { FrigateMenuMode } from '../types'; import type { ExtendedHomeAssistant, FrigateMenuMode } from '../types';
import menuStyle from '../scss/menu.scss'; import menuStyle from '../scss/menu.scss';
import { HassEntity } from 'home-assistant-js-websocket';
import { shouldUpdateBasedOnHass } from '../common';
type FrigateCardMenuCallback = (name: string) => void; type FrigateCardMenuCallback = (name: string, button: MenuButton) => void;
export const MENU_HEIGHT = 46; export const MENU_HEIGHT = 46;
// A menu for the Frigate card. // A menu for the Frigate card.
@customElement('frigate-card-menu') @customElement('frigate-card-menu')
export class FrigateCardMenu extends LitElement { export class FrigateCardMenu extends LitElement {
@property({ attribute: false })
public hass!: HomeAssistant & ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
protected menuMode: FrigateMenuMode = 'hidden-top'; protected menuMode: FrigateMenuMode = 'hidden-top';
@@ -24,12 +31,22 @@ export class FrigateCardMenu extends LitElement {
protected actionCallback: FrigateCardMenuCallback | null = null; protected actionCallback: FrigateCardMenuCallback | null = null;
@property({ attribute: false }) @property({ attribute: false })
public buttons: Map<string, MenuButton> = new Map(); public buttons: MenuButton[] = [];
public addButton(button: MenuButton): void {
if (!this.buttons.includes(button)) {
this.buttons = [...this.buttons, button];
}
}
public removeButton(target: MenuButton): void {
this.buttons = this.buttons.filter(button => button != target);
}
// Call the callback. // Call the callback.
protected _callAction(name: string): void { protected _callAction(ev: CustomEvent, button: MenuButton): void {
if (this.menuMode.startsWith('hidden-')) { if (this.menuMode.startsWith('hidden-')) {
if (name == 'frigate') { if (button.type == 'internal-menu-icon' && button.card_action === 'frigate') {
this.expand = !this.expand; this.expand = !this.expand;
return; return;
} }
@@ -38,42 +55,94 @@ export class FrigateCardMenu extends LitElement {
} }
if (this.actionCallback) { if (this.actionCallback) {
this.actionCallback(name); this.actionCallback(ev.detail.action, button);
} }
} }
// Determine whether the menu should be updated.
protected shouldUpdate(changedProps: PropertyValues): boolean {
const oldHass = changedProps.get('hass') as HomeAssistant | undefined;
if (changedProps.size > 1 || !oldHass) {
return true;
}
// Extract the entities the menu rendering depends on (if any).
const entities: string[] = []
for (let i = 0; i < this.buttons.length; i++) {
const button = this.buttons[i];
if (button.type == 'custom:frigate-card-menu-state-icon') {
entities.push(button.entity);
}
}
return shouldUpdateBasedOnHass(this.hass, oldHass, entities);
}
// Render a menu button. // Render a menu button.
protected _renderButton(name: string, button: MenuButton): TemplateResult { protected _renderButton(button: MenuButton): TemplateResult {
let state: HassEntity | null = null;
let emphasize = false;
let title = button.title;
let icon = button.icon;
const style = ('style' in button ? button.style : {}) || {};
if (button.type === 'custom:frigate-card-menu-state-icon') {
state = this.hass.states[button.entity];
emphasize =
!!state && button.state_color && ['on', 'active', 'home'].includes(state.state);
title = title ?? (state.attributes.friendly_name || button.entity);
icon = icon ?? stateIcon(state);
} else if (button.type === 'internal-menu-icon') {
emphasize = button.emphasize ?? false;
}
let hasHold = false;
let hasDoubleClick = false;
if (button.type != 'internal-menu-icon') {
hasHold = hasAction(button.hold_action);
hasDoubleClick = hasAction(button.double_tap_action);
}
const classes = { const classes = {
button: true, button: true,
emphasize: button.emphasize ?? false, emphasize: emphasize,
}; };
return html` <ha-icon-button return html` <ha-icon-button
class="${classMap(classes)}" class="${classMap(classes)}"
icon=${button.icon || 'mdi:gesture-tap-button'} style="${styleMap(style)}"
title=${button.description} icon=${icon || 'mdi:gesture-tap-button'}
@click=${() => this._callAction(name)} title=${title || ''}
@action=${(ev) => this._callAction(ev, button)}
.actionHandler=${actionHandler({
hasHold: hasHold,
hasDoubleClick: hasDoubleClick,
})}
></ha-icon-button>`; ></ha-icon-button>`;
} }
// Render the Frigate menu button. // Render the Frigate menu button.
protected _renderFrigateButton(name: string, button: MenuButton): TemplateResult { protected _renderFrigateButton(button: MenuButton): TemplateResult {
const icon = const icon =
this.menuMode.startsWith('hidden-') && !this.expand this.menuMode.startsWith('hidden-') && !this.expand
? 'mdi:alpha-f-box-outline' ? 'mdi:alpha-f-box-outline'
: 'mdi:alpha-f-box'; : 'mdi:alpha-f-box';
return this._renderButton(name, Object.assign({}, button, { icon: icon })); return this._renderButton(Object.assign({}, button, { icon: icon }));
} }
// Render the menu. // Render the menu.
protected render(): TemplateResult { protected render(): TemplateResult {
const isFrigateButton = function (button: MenuButton): boolean {
return button.type === 'internal-menu-icon' && button.card_action === 'frigate';
};
// If the menu is off, or if it's in hidden mode but there's no button to // If the menu is off, or if it's in hidden mode but there's no button to
// unhide it, just show nothing. // unhide it, just show nothing.
if ( if (
this.menuMode == 'none' || this.menuMode == 'none' ||
(this.menuMode.startsWith('hidden-') && !this.buttons.get('frigate')) (this.menuMode.startsWith('hidden-') && !this.buttons.find(isFrigateButton))
) { ) {
return html``; return html``;
} }
@@ -103,14 +172,10 @@ export class FrigateCardMenu extends LitElement {
return html` return html`
<div class=${classMap(classes)}> <div class=${classMap(classes)}>
${Array.from(this.buttons.keys()).map((name) => { ${Array.from(this.buttons).map((button) => {
const button = this.buttons.get(name); return isFrigateButton(button)
if (button) { ? this._renderFrigateButton(button)
return name === 'frigate' : this._renderButton(button);
? this._renderFrigateButton(name, button)
: this._renderButton(name, button);
}
return html``;
})} })}
</div> </div>
`; `;
+21 -14
View File
@@ -3,6 +3,8 @@ import { customElement, property } from 'lit/decorators';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { Message } from '../types';
import messageStyle from '../scss/message.scss'; import messageStyle from '../scss/message.scss';
const URL_TROUBLESHOOTING = const URL_TROUBLESHOOTING =
@@ -14,14 +16,17 @@ export class FrigateCardMessage extends LitElement {
protected message = ''; protected message = '';
@property({ attribute: false }) @property({ attribute: false })
protected icon = 'mdi:information-outline'; protected icon?;
// Render the menu. // Render the menu.
protected render(): TemplateResult { protected render(): TemplateResult {
const icon = this.icon ? this.icon : 'mdi:information-outline';
return html` <div class="message"> return html` <div class="message">
<span> <span>
<ha-icon icon="${this.icon}"> </ha-icon> <ha-icon icon="${icon}"> </ha-icon>
${this.message ? html`&nbsp;${this.message}` : ''} </span>
<span>
${this.message ? html`${this.message}` : ''}
</span> </span>
</div>`; </div>`;
} }
@@ -39,7 +44,7 @@ export class FrigateCardErrorMessage extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return html` <frigate-card-message return html` <frigate-card-message
.message=${html` ${this.error}. .message=${html` ${this.error}.
<a href="${URL_TROUBLESHOOTING}"> ${localize('error.troubleshooting')} </a>.`} <a href="${URL_TROUBLESHOOTING}"> ${localize('error.troubleshooting')}</a>.`}
.icon=${'mdi:alert-circle'} .icon=${'mdi:alert-circle'}
> >
</frigate-card-message>`; </frigate-card-message>`;
@@ -59,16 +64,18 @@ export class FrigateCardProgressIndicator extends LitElement {
} }
} }
export function renderErrorMessage(error: string): TemplateResult { export function renderMessage(message: Message): TemplateResult {
return html` if (message.type == 'error') {
<frigate-card-error-message .error=${error}></frigate-card-error-message> return html` <frigate-card-error-message
`; .error=${message.message}
} ></frigate-card-error-message>`;
} else if (message.type == 'info') {
export function renderMessage(message: string, icon: string): TemplateResult { return html` <frigate-card-message
return html` .message=${message.message}
<frigate-card-message .message=${message} .icon=${icon}></frigate-card-message> .icon=${message.icon}
`; ></frigate-card-message>`;
}
return html``;
} }
export function renderProgressIndicator(): TemplateResult { export function renderProgressIndicator(): TemplateResult {
+7 -6
View File
@@ -18,7 +18,9 @@ import type {
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { import {
browseMediaQuery, browseMediaQuery,
dispatchErrorMessageEvent,
dispatchMediaLoadEvent, dispatchMediaLoadEvent,
dispatchMessageEvent,
dispatchPauseEvent, dispatchPauseEvent,
dispatchPlayEvent, dispatchPlayEvent,
getFirstTrueMediaChildIndex, getFirstTrueMediaChildIndex,
@@ -27,8 +29,6 @@ import {
import { View } from '../view'; import { View } from '../view';
import { import {
renderMessage,
renderErrorMessage,
renderProgressIndicator, renderProgressIndicator,
} from '../components/message'; } from '../components/message';
@@ -158,7 +158,7 @@ export class FrigateCardViewer extends LitElement {
return html`${until(this._render(), renderProgressIndicator())}`; return html`${until(this._render(), renderProgressIndicator())}`;
} }
protected async _render(): Promise<TemplateResult> { protected async _render(): Promise<TemplateResult | void> {
let autoplay = true; let autoplay = true;
let parent: BrowseMediaSource | null = null; let parent: BrowseMediaSource | null = null;
@@ -173,11 +173,12 @@ export class FrigateCardViewer extends LitElement {
try { try {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
} catch (e) { } catch (e) {
return renderErrorMessage((e as Error).message); return dispatchErrorMessageEvent(this, (e as Error).message);
} }
childIndex = getFirstTrueMediaChildIndex(parent); childIndex = getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) { if (!parent || !parent.children || childIndex == null) {
return renderMessage( return dispatchMessageEvent(
this,
this.view.is('clip') this.view.is('clip')
? localize('common.no_clip') ? localize('common.no_clip')
: localize('common.no_snapshot'), : localize('common.no_snapshot'),
@@ -196,7 +197,7 @@ export class FrigateCardViewer extends LitElement {
const resolvedMedia = await this._resolveMedia(mediaToRender); const resolvedMedia = await this._resolveMedia(mediaToRender);
if (!mediaToRender || !resolvedMedia) { if (!mediaToRender || !resolvedMedia) {
// Home Assistant could not resolve media item. // Home Assistant could not resolve media item.
return renderErrorMessage(localize('error.could_not_resolve')); return dispatchErrorMessageEvent(this, localize('error.could_not_resolve'));
} }
const neighbors = this._getMediaNeighbors(parent, childIndex); const neighbors = this._getMediaNeighbors(parent, childIndex);
+3 -1
View File
@@ -95,6 +95,8 @@
"no_live_camera": "No live camera", "no_live_camera": "No live camera",
"invalid_configuration": "Invalid configuration", "invalid_configuration": "Invalid configuration",
"missing_webrtc": "WebRTC component not found", "missing_webrtc": "WebRTC component not found",
"no_frigate_camera_name": "Cannot derive frigate_camera_name, you may need to set it manually" "no_frigate_camera_name": "Cannot derive frigate_camera_name, you may need to set it manually",
"could_not_render_elements": "Could not render picture elements",
"invalid_elements_config": "Invalid picture elements configuration"
} }
} }
+15 -3
View File
@@ -1,15 +1,16 @@
.container { .container {
position: relative; position: relative;
overflow: auto; overflow: auto;
height: 100%;
width: 100%; width: 100%;
height: 100%;
margin: auto; margin: auto;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.frigate-card-contents { .frigate-card-contents {
width: 100%; width: inherit;
height: inherit;
margin: auto; margin: auto;
overflow: auto; overflow: auto;
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */ -ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
@@ -43,6 +44,17 @@
.outer:hover + .hover-menu, .hover-menu:hover { .outer:hover + .hover-menu, .hover-menu:hover {
opacity: 1.0; opacity: 1.0;
} }
/* A relative div to place absolute picture elements onto */
.picture-elements {
position: relative;
width: inherit;
}
/* Enforce picture elements to only be the size of the card/fullscreen (and not
larger) when in gallery mode so that the picture elements do not scroll. */
.picture-elements.gallery {
height: 100%;
}
ha-card { ha-card {
display: flex; display: flex;
@@ -56,7 +68,7 @@ ha-card {
background-color: var(--secondary-background-color, black); background-color: var(--secondary-background-color, black);
} }
frigate-card-gallery, frigate-card-viewer, frigate-card-live { frigate-card-gallery, frigate-card-viewer, frigate-card-live, frigate-card-message, frigate-card-error-message {
width: 100%; width: 100%;
display: block; display: block;
} }
+12
View File
@@ -0,0 +1,12 @@
.element {
position: absolute;
transform: translate(-50%, -50%);
}
// 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;
}
+11
View File
@@ -1,6 +1,17 @@
@use "@material/image-list/mdc-image-list"; @use "@material/image-list/mdc-image-list";
@use "@material/image-list"; @use "@material/image-list";
:host {
overflow: auto;
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
scrollbar-width: none; /* Hide scrollbar: Firefox */
}
/* Hide scrollbar for Chrome, Safari and Opera */
:host::-webkit-scrollbar {
display: none;
}
.frigate-card-gallery { .frigate-card-gallery {
// Note: In fullscreen, number of columns is overwritten in Javascript based // Note: In fullscreen, number of columns is overwritten in Javascript based
// on dimensions. // on dimensions.
+4
View File
@@ -10,3 +10,7 @@
.message a { .message a {
color: var(--primary-text-color, white); color: var(--primary-text-color, white);
} }
span {
padding: 10px;
}
+221 -14
View File
@@ -1,4 +1,12 @@
import { LovelaceCard, LovelaceCardEditor } from 'custom-card-helpers'; import {
CallServiceActionConfig,
LovelaceCard,
LovelaceCardEditor,
MoreInfoActionConfig,
NavigateActionConfig,
ToggleActionConfig,
UrlActionConfig,
} from 'custom-card-helpers';
import { z } from 'zod'; import { z } from 'zod';
declare global { declare global {
@@ -46,6 +54,195 @@ export type NextPreviousControlStyle = typeof NEXT_PREVIOUS_CONTROL_STYLES[numbe
export const LIVE_PROVIDERS = ['frigate', 'frigate-jsmpeg', 'webrtc'] as const; export const LIVE_PROVIDERS = ['frigate', 'frigate-jsmpeg', 'webrtc'] as const;
export type LiveProvider = typeof LIVE_PROVIDERS[number]; export type LiveProvider = typeof LIVE_PROVIDERS[number];
/**
* Action Types (for "Picture Elements" / Menu)
*/
// Declare schemas to existing types:
// - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330
const schemaForType =
<T>() =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<S extends z.ZodType<T, any, any>>(arg: S) => {
return arg;
};
const toggleActionSchema = schemaForType<ToggleActionConfig>()(
z.object({
action: z.literal('toggle'),
}),
);
const callServiceActionSchema = schemaForType<CallServiceActionConfig>()(
z.object({
action: z.literal('call-service'),
service: z.string(),
service_data: z.object({}).passthrough().optional(),
}),
);
const navigateActionSchema = schemaForType<NavigateActionConfig>()(
z.object({
action: z.literal('navigate'),
navigation_path: z.string(),
}),
);
const urlActionSchema = schemaForType<UrlActionConfig>()(
z.object({
action: z.literal('url'),
url_path: z.string(),
}),
);
const moreInfoActionSchema = schemaForType<MoreInfoActionConfig>()(
z.object({
action: z.literal('more-info'),
}),
);
const elementsActionSchema = z.union([
toggleActionSchema,
callServiceActionSchema,
navigateActionSchema,
urlActionSchema,
moreInfoActionSchema,
]);
export type ElementsActionType = z.infer<typeof elementsActionSchema>;
const elementsBaseSchema = z.object({
style: z.object({}).passthrough().optional(),
title: z.string().nullable().optional(),
tap_action: elementsActionSchema.optional(),
hold_action: elementsActionSchema.optional(),
double_tap_action: elementsActionSchema.optional(),
});
/**
* Picture Element Types
*
* All picture element types are validated (not just the Frigate card custom
* ones) as a convenience to present the user with a consistent error display
* up-front regardless of where they made their error.
*/
// https://www.home-assistant.io/lovelace/picture-elements/#state-badge
const stateBadgeIconSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('state-badge'),
entity: z.string(),
}));
// https://www.home-assistant.io/lovelace/picture-elements/#state-icon
const stateIconSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('state-icon'),
entity: z.string(),
icon: z.string().optional(),
state_color: z.boolean().default(true),
}));
// https://www.home-assistant.io/lovelace/picture-elements/#state-label
const stateLabelSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('state-label'),
entity: z.string(),
attribute: z.string().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
}));
// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button
const serviceCallButtonSchema =
elementsBaseSchema.merge(z
.object({
type: z.literal('service-button'),
// Title is required for service button.
title: z.string(),
service: z.string(),
service_data: z.object({}).passthrough().optional(),
})
)
// https://www.home-assistant.io/lovelace/picture-elements/#icon
const iconSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('icon'),
icon: z.string(),
entity: z.string().optional(),
}));
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
const imageSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('image'),
entity: z.string().optional(),
image: z.string().optional(),
camera_image: z.string().optional(),
camera_view: z.string().optional(),
state_image: z.object({}).passthrough().optional(),
filter: z.string().optional(),
state_filter: z.object({}).passthrough().optional(),
aspect_ratio: z.string().optional(),
}));
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
const conditionalSchema = z.object({
type: z.literal('conditional'),
conditions: z.object({
entity: z.string(),
state: z.string().optional(),
state_not: z.string().optional(),
}).array(),
elements: z.lazy(() => pictureElementsSchema),
});
// https://www.home-assistant.io/lovelace/picture-elements/#custom-elements
const customSchema = z.object({
// Insist that Frigate card custom elements are handled by other schemas.
type: z.string().regex(/^custom:(?!frigate-card).+/),
}).passthrough();
/**
* Custom Element Types
*/
export const menuIconSchema = iconSchema.merge(
z.object({
type: z.literal('custom:frigate-card-menu-icon'),
}));
export type MenuIcon = z.infer<typeof menuIconSchema>;
export const menuStateIconSchema = stateIconSchema.merge(
z.object({
type: z.literal('custom:frigate-card-menu-state-icon'),
}));
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
const frigateConditionalSchema = z.object({
type: z.literal('custom:frigate-card-conditional'),
conditions: z.object({
view: z.string().array().optional(),
}),
elements: z.lazy(() => pictureElementsSchema),
});
export type FrigateConditional = z.infer<typeof frigateConditionalSchema>;
// 'internalMenuIconSchema' is excluded to disallow the user from manually
// changing the internal menu buttons.
const pictureElementSchema = z.union([
menuStateIconSchema,
menuIconSchema,
frigateConditionalSchema,
stateBadgeIconSchema,
stateIconSchema,
stateLabelSchema,
serviceCallButtonSchema,
iconSchema,
imageSchema,
conditionalSchema,
customSchema,
]);
export type PictureElement = z.infer<typeof pictureElementSchema>;
const pictureElementsSchema = pictureElementSchema.array().optional();
export type PictureElements = z.infer<typeof pictureElementsSchema>;
export const frigateCardConfigSchema = z.object({ export const frigateCardConfigSchema = z.object({
camera_entity: z.string(), camera_entity: z.string(),
// No URL validation to allow relative URLs within HA (e.g. addons). // No URL validation to allow relative URLs within HA (e.g. addons).
@@ -85,14 +282,8 @@ export const frigateCardConfigSchema = z.object({
fullscreen: z.boolean().default(true), fullscreen: z.boolean().default(true),
}) })
.optional(), .optional(),
entities: z update_entities: z.string().array().optional(),
.object({ elements: pictureElementsSchema,
entity: z.string(),
show: z.boolean().default(true),
icon: z.string().optional(),
})
.array()
.optional(),
controls: z controls: z
.object({ .object({
nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'), nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'),
@@ -125,12 +316,22 @@ export const frigateCardConfigSchema = z.object({
}); });
export type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>; export type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>;
export interface MenuButton { // Schema for card (non-user configured) menu icons.
icon?: string; const internalMenuIconSchema = z
description: string; .object({
emphasize?: boolean; type: z.literal('internal-menu-icon'),
} title: z.string(),
icon: z.string().optional(),
emphasize: z.boolean().default(false).optional(),
card_action: z.string(),
});
const menuButtonSchema = z.union([
menuIconSchema,
menuStateIconSchema,
internalMenuIconSchema,
]);
export type MenuButton = z.infer<typeof menuButtonSchema>;
export interface ExtendedHomeAssistant { export interface ExtendedHomeAssistant {
hassUrl(path?): string; hassUrl(path?): string;
} }
@@ -158,6 +359,12 @@ export interface MediaLoadInfo {
height: number; height: number;
} }
export interface Message {
message: string;
type: 'error' | 'info';
icon?: string;
}
/** /**
* Home Assistant API types. * Home Assistant API types.
*/ */