Merge pull request #169 from dermotduffy/config-reorganize

Significant configuration re-organization to simplify config & code
This commit is contained in:
Dermot Duffy
2021-11-06 15:09:44 -07:00
committed by GitHub
17 changed files with 1365 additions and 822 deletions
+87 -55
View File
@@ -65,27 +65,56 @@ lovelace:
## Options ## Options
### Basic ### Basic options
| Option | Default | Description | | Option | Default | Description |
| ------------- | - | --------------------------------------------- | | - | - | - |
| `camera_entity` | | The optional Frigate camera entity to use in the `frigate` live provider view. Also used to automatically detect the `frigate_camera_name`.| | `camera_entity` | | The optional Frigate camera entity to use in the `frigate` live provider view. Also used to automatically detect the value of `frigate.camera_name`.|
| `frigate_camera_name` | Autodetected from `camera_entity` if that is specified. | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view. To view the birdseye view set this to `birdseye` and use the `frigate-jsmpeg` live provider.|
| `view_default` | `live` | The view to show by default. See [views](#views) below.|
### Optional ### Frigate server options
All variables listed are under a `frigate:` section.
| Option | Default | Description | | Option | Default | Description |
| ------------- | --------------------------------------------- | - | | - | - | - |
| `live_provider` | `frigate` | The means through which the live camera view is displayed. See [Live Provider](#live-provider) below.| | `camera_name` | Autodetected from `camera_entity` if that is specified. | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view. To view the birdseye view set this to `birdseye` and use the `frigate-jsmpeg` live provider.|
| `frigate_client_id` | `frigate` | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://blakeblackshear.github.io/frigate/usage/home-assistant/#multiple-instance-support).| | `url` | | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. |
| `view_timeout` | | A numbers of seconds of inactivity after which the card will reset to the default configured view. Inactivity is defined as lack of interaction with the Frigate menu.| | `label` | | A Frigate label / object filter used to filter events (clips & snapshots), e.g. 'person'.|
| `frigate_url` | | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. | | `zone` | | A Frigate zone used to filter events (clips & snapshots), e.g. 'front_door'.|
| `autoplay_clip` | `false` | Whether or not to autoplay clips in the 'clip' [view](#views). Clips manually chosen in the clips gallery will still autoplay.| | `client_id` | `frigate` | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://blakeblackshear.github.io/frigate/usage/home-assistant/#multiple-instance-support).|
| `live_preload` | `false` | Whether or not to preload the live view. Preloading causes the live view to render in the background so it's instantly available when requested. This consumes additional network/CPU resources continually.|
| `event_viewer.lazy_load` | `true` | Whether or not to lazily load media in the event viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. |
#### Live Provider ### View options
All variables listed are under a `view:` section.
| Option | Default | Description |
| - | - | - |
| `default` | `live` | The view to show in the card by default. See [views](#views) below.|
| `timeout` | | A numbers of seconds of inactivity after which the card will reset to the default configured view. Inactivity is defined as lack of interaction with the Frigate menu.|
### Menu options
All variables listed are under a `menu:` section.
| Option | Default | Description |
| - | - | - |
| `mode` | `hidden-top` | The menu mode to show by default. See [menu modes](#menu-modes) below.|
| `button_size` | `40px` | The size of the menu buttons (in CSS Units)[https://www.w3schools.com/cssref/css_units.asp].|
| `buttons.{frigate, live, clips, snapshots, image, download, frigate_ui, fullscreen}` | `true`, except for `image` | Whether or not to show these builtin actions in the card menu. |
### Live options
All variables listed are under a `live:` section.
| Option | Default | Description |
| - | - | - |
| `preload` | `false` | Whether or not to preload the live view. Preloading causes the live view to render in the background so it's instantly available when requested. This consumes additional network/CPU resources continually.|
| `provider` | `frigate` | The means through which the live camera view is displayed. See [Live Provider](#live-provider) below.|
| `webrtc.url` | | The RTSP url to pass to WebRTC. Specify this OR `webrtc.entity` (below).|
| `webrtc.entity` | | The RTSP entity to pass WebRTC. Specify this OR `webrtc.url` (above). |
| `webrtc.*`| | Any other options in a `webrtc:` YAML dictionary are silently passed through to WebRTC. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides.|
#### Available Live Providers
|Live Provider|Latency|Frame Rate|Installation|Description| |Live Provider|Latency|Frame Rate|Installation|Description|
| -- | -- | -- | -- | -- | | -- | -- | -- | -- | -- |
@@ -93,26 +122,33 @@ lovelace:
|`frigate-jsmpeg`|Lower|Low|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.| |`frigate-jsmpeg`|Lower|Low|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.|
|`webrtc`|Lowest|High|Separate installation required|Uses [WebRTC](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc).| |`webrtc`|Lowest|High|Separate installation required|Uses [WebRTC](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc).|
### Appearance ### Event Viewer options
All variables listed are under a `event_viewer:` section.
| Option | Default | Description | | Option | Default | Description |
| ------------- | --------------------------------------------- | - | | - | - | - |
| `menu_mode` | `hidden-top` | The menu mode to show by default. See [menu modes](#menu-modes) below.| | `autoplay_clip` | `false` | Whether or not to autoplay clips in the 'clip' [view](#views). Clips manually chosen in the clips gallery will still autoplay.|
| `menu_buttons.{frigate, live, clips, snapshots, download, frigate_ui, fullscreen}` | `true` | Whether or not to show these builtin actions in the card menu. | | `lazy_load` | `true` | Whether or not to lazily load media in the event viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. |
| `controls.nextprev` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | | `controls.next_previous.style` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . |
| `dimensions.aspect_ratio_mode` | `dynamic` | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.| | `controls.next_previous.size` | `48px` | The size of the next/previous controls (in CSS Units)[https://www.w3schools.com/cssref/css_units.asp].|
| `dimensions.aspect_ratio` | `16:9` | The aspect ratio to use. Acceptable values: `<W>:<H>` or `<W>/<H>`. See [aspect ratios](#aspect-ratios) below.|
| `image` | [embedded image](https://www.flickr.com/photos/dianasch/47543120431) | A static image URL for use with the `image` [view](#views).|
<a name="aspect-ratios"></a> ### Image options
#### Aspect Ratio All variables listed are under a `image:` section.
The card can show live cameras, stored events (clip or snapshot) and an event gallery (clips or snapshots). Of these [views](#views), the gallery views have no intrinsic aspect-ratio, whereas the other views have the aspect-ratio of the media. | Option | Default | Description |
| - | - | - |
| `src` | [embedded image](https://www.flickr.com/photos/dianasch/47543120431) | A static image URL for use with the `image` [view](#views).|
The card aspect ratio can be changed with the `dimensions.aspect_ratio_mode` and `dimensions.aspect_ratio` appearance options. ### Dimension options
If no aspect ratio is specified or available, but one is needed then `16:9` will be used by default. All variables listed are under a `dimensions:` section.
| Option | Default | Description |
| - | - | - |
| `aspect_ratio_mode` | `dynamic` | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.|
| `aspect_ratio` | `16:9` | The aspect ratio to use. Acceptable values: `<W>:<H>` or `<W>/<H>`. See [aspect ratios](#aspect-ratios) below.|
#### `dimensions.aspect_ratio_mode`: #### `dimensions.aspect_ratio_mode`:
@@ -128,6 +164,22 @@ If no aspect ratio is specified or available, but one is needed then `16:9` will
* `4 / 3` or `4:3`: Default fullscreen ratio. * `4 / 3` or `4:3`: Default fullscreen ratio.
* `<W>/<H>` or `<W>:<H>`: Any arbitrary aspect-ratio. * `<W>/<H>` or `<W>:<H>`: Any arbitrary aspect-ratio.
<a name="aspect-ratios"></a>
#### Aspect Ratio
The card can show live cameras, stored events (clip or snapshot) and an event
gallery (clips or snapshots). Of these [views](#views), the gallery views have
no intrinsic aspect-ratio, whereas the other views have the aspect-ratio of the
media.
The card aspect ratio can be changed with the `dimensions.aspect_ratio_mode` and
`dimensions.aspect_ratio` options described above.
If no aspect ratio is specified or available, but one is needed then `16:9` will
be used by default.
#### Example aspect ratio configuration #### Example aspect ratio configuration
Have the card aspect-ratio dynamically follow the last loaded media, but use `4:3` as the default when there is no such media: Have the card aspect-ratio dynamically follow the last loaded media, but use `4:3` as the default when there is no such media:
@@ -138,19 +190,17 @@ dimensions:
aspect_ratio: '4:3' aspect_ratio: '4:3'
``` ```
<a name="advanced-options"></a> <a name="other-options"></a>
### Advanced Options ### Other Options
| Option | Default | Description | | Option | Default | Description |
| ------------- | - | --------------------------------------------- | | - | - | - |
| `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'.|
| `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. | | `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>
### WebRTC Options ### Using WebRTC
WebRTC support blends the use of the ultra-realtime [WebRTC live WebRTC support blends the use of the ultra-realtime [WebRTC live
view](https://github.com/AlexxIT/WebRTC) with convenient access to Frigate view](https://github.com/AlexxIT/WebRTC) with convenient access to Frigate
@@ -158,11 +208,6 @@ events/snapshots/UI. A perfect combination!
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/webrtc.png" alt="Live viewing" width="400px"> <img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/webrtc.png" alt="Live viewing" width="400px">
| Option | Default | Description |
| ------------- | - | -------------------------------------------- |
| `webrtc.url` | | The RTSP url to pass to WebRTC. Specify this OR `webrtc.entity` (below).|
| `webrtc.entity` | | The RTSP entity to pass WebRTC. Specify this OR `webrtc.url` (above). |
| `webrtc.*`| | Any other options in a `webrtc:` YAML dictionary are silently passed through to WebRTC. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides.|
**Note**: WebRTC must be installed and configured separately (see [details](https://github.com/AlexxIT/WebRTC)) before it can be used with this card. **Note**: WebRTC must be installed and configured separately (see [details](https://github.com/AlexxIT/WebRTC)) before it can be used with this card.
@@ -180,6 +225,7 @@ specify the WebRTC source camera:
```yaml ```yaml
[rest of Frigate card configuration] [rest of Frigate card configuration]
live:
webrtc: webrtc:
entity: 'camera.front_door_rstp` entity: 'camera.front_door_rstp`
``` ```
@@ -190,27 +236,13 @@ webrtc:
```yaml ```yaml
[rest of Frigate card configuration] [rest of Frigate card configuration]
live:
webrtc: webrtc:
url: 'rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH' url: 'rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH'
``` ```
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.
#### Example
This example allows access to the detection, recordings and snapshots switches
from the menu. It also enables a different entity to trigger a card update (but
without appearing in the menu).
```yaml
entities:
- entity: switch.front_door_recordings
- entity: switch.front_door_snapshots
- entity: switch.front_door_detect
- entity: binary_sensor.front_door_person_motion
show: false
```
## Picture Elements / Menu customizations ## Picture Elements / Menu customizations
This card supports the [Picture Elements configuration This card supports the [Picture Elements configuration
@@ -436,7 +468,7 @@ update_entities:
- binary_sensor.office_person_motion - binary_sensor.office_person_motion
``` ```
See the [advanced options](#advanced-options) above. See the [other options](#other-options) above.
### Getting from a snapshot to a clip ### Getting from a snapshot to a clip
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

After

Width:  |  Height:  |  Size: 152 KiB

+2
View File
@@ -19,6 +19,8 @@
"@material/image-list": "^12.0.0", "@material/image-list": "^12.0.0",
"custom-card-helpers": "^1.8.0", "custom-card-helpers": "^1.8.0",
"dayjs": "^1.10.7", "dayjs": "^1.10.7",
"dlv": "github:developit/dlv",
"dset": "^3.1.1",
"embla-carousel": "^5.0.1", "embla-carousel": "^5.0.1",
"home-assistant-js-websocket": "^5.11.1", "home-assistant-js-websocket": "^5.11.1",
"lit": "^2.0.2", "lit": "^2.0.2",
+67 -91
View File
@@ -20,7 +20,12 @@ import {
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { z } from 'zod'; import { z } from 'zod';
import { entitySchema, frigateCardConfigSchema, MenuInteraction } from './types.js'; import {
entitySchema,
frigateCardConfigSchema,
MenuInteraction,
RawFrigateCardConfig,
} from './types.js';
import type { import type {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
Entity, Entity,
@@ -58,6 +63,7 @@ import './patches/ha-hls-player.js';
import cardStyle from './scss/card.scss'; import cardStyle from './scss/card.scss';
import { ResolvedMediaCache } from './resolved-media.js'; import { ResolvedMediaCache } from './resolved-media.js';
import { BrowseMediaUtil } from './browse-media-util.js'; import { BrowseMediaUtil } from './browse-media-util.js';
import { copyConfig, isConfigUpgradeable, upgradeConfig } from './config-mgmt.js';
/** A note on media callbacks: /** A note on media callbacks:
* *
@@ -192,72 +198,72 @@ export class FrigateCard extends LitElement {
protected _getMenuButtons(): MenuButton[] { protected _getMenuButtons(): MenuButton[] {
const buttons: MenuButton[] = []; const buttons: MenuButton[] = [];
if (this.config.menu_buttons?.frigate ?? true) { if (this.config.menu.buttons.frigate) {
buttons.push({ buttons.push({
type: 'internal-menu-icon', type: 'internal-menu-icon',
tap_action: 'frigate', tap_action: 'frigate',
title: localize('menu.frigate'), title: localize('config.menu.buttons.frigate'),
}); });
} }
if (this.config.menu_buttons?.live ?? true) { if (this.config.menu.buttons.live) {
buttons.push({ buttons.push({
type: 'internal-menu-icon', type: 'internal-menu-icon',
tap_action: 'live', tap_action: 'live',
title: localize('menu.live'), title: localize('config.view.views.live'),
icon: 'mdi:cctv', icon: 'mdi:cctv',
emphasize: this._view.is('live'), emphasize: this._view.is('live'),
}); });
} }
if (this.config.menu_buttons?.clips ?? true) { if (this.config.menu.buttons.clips) {
buttons.push({ buttons.push({
type: 'internal-menu-icon', type: 'internal-menu-icon',
tap_action: 'clips', tap_action: 'clips',
hold_action: 'clip', hold_action: 'clip',
title: localize('menu.clips'), title: localize('config.view.views.clips'),
icon: 'mdi:filmstrip', icon: 'mdi:filmstrip',
emphasize: this._view.is('clips'), emphasize: this._view.is('clips'),
}); });
} }
if (this.config.menu_buttons?.snapshots ?? true) { if (this.config.menu.buttons.snapshots) {
buttons.push({ buttons.push({
type: 'internal-menu-icon', type: 'internal-menu-icon',
tap_action: 'snapshots', tap_action: 'snapshots',
hold_action: 'snapshot', hold_action: 'snapshot',
title: localize('menu.snapshots'), title: localize('config.view.views.snapshots'),
icon: 'mdi:camera', icon: 'mdi:camera',
emphasize: this._view.is('snapshots'), emphasize: this._view.is('snapshots'),
}); });
} }
if (this.config.menu_buttons?.image ?? false) { if (this.config.menu.buttons.image) {
buttons.push({ buttons.push({
type: 'internal-menu-icon', type: 'internal-menu-icon',
tap_action: 'image', tap_action: 'image',
title: localize('menu.image'), title: localize('config.view.views.image'),
icon: 'mdi:image', icon: 'mdi:image',
emphasize: this._view.is('image'), emphasize: this._view.is('image'),
}); });
} }
if (this._view.isViewerView() && (this.config.menu_buttons?.download ?? true)) { if (this.config.menu.buttons.download && this._view.isViewerView()) {
buttons.push({ buttons.push({
type: 'internal-menu-icon', type: 'internal-menu-icon',
tap_action: 'download', tap_action: 'download',
title: localize('menu.download'), title: localize('config.menu.buttons.download'),
icon: 'mdi:download', icon: 'mdi:download',
}); });
} }
if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) { if (this.config.menu.buttons.frigate_ui && this.config.frigate.url) {
buttons.push({ buttons.push({
type: 'internal-menu-icon', type: 'internal-menu-icon',
tap_action: 'frigate_ui', tap_action: 'frigate_ui',
title: localize('menu.frigate_ui'), title: localize('config.menu.buttons.frigate_ui'),
icon: 'mdi:web', icon: 'mdi:web',
}); });
} }
if ((this.config.menu_buttons?.fullscreen ?? true) && screenfull.isEnabled) { if (this.config.menu.buttons.fullscreen && screenfull.isEnabled) {
buttons.push({ buttons.push({
type: 'internal-menu-icon', type: 'internal-menu-icon',
tap_action: 'fullscreen', tap_action: 'fullscreen',
title: localize('menu.fullscreen'), title: localize('config.menu.buttons.fullscreen'),
icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
}); });
} }
@@ -300,8 +306,8 @@ export class FrigateCard extends LitElement {
} }
// Option 1: Name specified in config -> done! // Option 1: Name specified in config -> done!
if (this.config.frigate_camera_name) { if (this.config.frigate.camera_name) {
return this.config.frigate_camera_name; return this.config.frigate.camera_name;
} }
if (this.config.camera_entity) { if (this.config.camera_entity) {
@@ -401,15 +407,21 @@ export class FrigateCard extends LitElement {
* Set the card configuration. * Set the card configuration.
* @param inputConfig The card configuration. * @param inputConfig The card configuration.
*/ */
public setConfig(inputConfig: FrigateCardConfig): void { public setConfig(inputConfig: RawFrigateCardConfig): void {
if (!inputConfig) { if (!inputConfig) {
throw new Error(localize('error.invalid_configuration:')); throw new Error(localize('error.invalid_configuration'));
} }
const configUpgradeable = isConfigUpgradeable(inputConfig);
const parseResult = frigateCardConfigSchema.safeParse(inputConfig); const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
if (!parseResult.success) { if (!parseResult.success) {
const hint = this._getParseErrorPaths(parseResult.error); const hint = this._getParseErrorPaths(parseResult.error);
let upgradeMessage = '';
if (configUpgradeable && getLovelace().mode !== 'yaml') {
upgradeMessage = `${localize('error.upgrade_available')}. `;
}
throw new Error( throw new Error(
upgradeMessage +
`${localize('error.invalid_configuration')}: ` + `${localize('error.invalid_configuration')}: ` +
(hint.length (hint.length
? JSON.stringify(hint, null, ' ') ? JSON.stringify(hint, null, ' ')
@@ -436,7 +448,7 @@ export class FrigateCard extends LitElement {
this._message = null; 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 {
this._view = view; this._view = view;
} }
@@ -505,7 +517,7 @@ export class FrigateCard extends LitElement {
} }
const path = const path =
`/api/frigate/${this.config.frigate_client_id}` + `/api/frigate/${this.config.frigate.client_id}` +
`/notifications/${event_id}/` + `/notifications/${event_id}/` +
`${this._view.isClipRelatedView() ? 'clip.mp4' : 'snapshot.jpg'}` + `${this._view.isClipRelatedView() ? 'clip.mp4' : 'snapshot.jpg'}` +
`?download=true`; `?download=true`;
@@ -595,22 +607,22 @@ export class FrigateCard extends LitElement {
* @returns The URL or null if unavailable. * @returns The URL or null if unavailable.
*/ */
protected _getFrigateURLFromContext(): string | null { protected _getFrigateURLFromContext(): string | null {
if (!this.config.frigate_url) { if (!this.config.frigate.url) {
return null; return null;
} }
if (!this._frigateCameraName) { if (!this._frigateCameraName) {
return this.config.frigate_url; return this.config.frigate.url;
} else if (this._view.is('live')) { } else if (this._view.is('live')) {
return `${this.config.frigate_url}/cameras/${this._frigateCameraName}`; return `${this.config.frigate.url}/cameras/${this._frigateCameraName}`;
} }
return `${this.config.frigate_url}/events?camera=${this._frigateCameraName}`; return `${this.config.frigate.url}/events?camera=${this._frigateCameraName}`;
} }
/** /**
* Handle interaction with the card. * Handle interaction with the card.
*/ */
protected _interactionHandler(): void { protected _interactionHandler(): void {
if (!this.config.view_timeout) { if (!this.config.view.timeout) {
return; return;
} }
if (this._interactionTimerID) { if (this._interactionTimerID) {
@@ -619,7 +631,7 @@ export class FrigateCard extends LitElement {
this._interactionTimerID = window.setTimeout(() => { this._interactionTimerID = window.setTimeout(() => {
this._interactionTimerID = null; this._interactionTimerID = null;
this._changeView(); this._changeView();
}, this.config.view_timeout * 1000); }, this.config.view.timeout * 1000);
} }
/** /**
@@ -628,15 +640,14 @@ export class FrigateCard extends LitElement {
*/ */
protected _renderMenu(): TemplateResult | void { protected _renderMenu(): TemplateResult | void {
const classes = { const classes = {
'hover-menu': this.config.menu_mode.startsWith('hover-'), 'hover-menu': this.config.menu.mode.startsWith('hover-'),
}; };
return html` return html`
<frigate-card-menu <frigate-card-menu
class="${classMap(classes)}"
.hass=${this._hass} .hass=${this._hass}
.menuMode=${this.config.menu_mode} .menuConfig=${this.config.menu}
.buttons=${this._getMenuButtons()} .buttons=${this._getMenuButtons()}
.buttonSize=${this.config.menu_button_size} class="${classMap(classes)}"
@frigate-card:menu-interaction=${this._menuInteractionHandler.bind(this)} @frigate-card:menu-interaction=${this._menuInteractionHandler.bind(this)}
></frigate-card-menu> ></frigate-card-menu>
`; `;
@@ -655,10 +666,10 @@ export class FrigateCard extends LitElement {
} }
return { return {
mediaType: this._view.isClipRelatedView() ? 'clips' : 'snapshots', mediaType: this._view.isClipRelatedView() ? 'clips' : 'snapshots',
clientId: this.config.frigate_client_id, clientId: this.config.frigate.client_id,
cameraName: this._frigateCameraName, cameraName: this._frigateCameraName,
label: this.config.label, label: this.config.frigate.label,
zone: this.config.zone, zone: this.config.frigate.zone,
}; };
} }
@@ -760,7 +771,7 @@ export class FrigateCard extends LitElement {
* context. * context.
*/ */
protected _isAspectRatioEnforced(): boolean { protected _isAspectRatioEnforced(): boolean {
const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic'; const aspectRatioMode = this.config.dimensions.aspect_ratio_mode;
// Do not artifically constrain aspect ratio if: // Do not artifically constrain aspect ratio if:
// - It's fullscreen. // - It's fullscreen.
@@ -769,8 +780,8 @@ export class FrigateCard extends LitElement {
return !( return !(
(screenfull.isEnabled && screenfull.isFullscreen) || (screenfull.isEnabled && screenfull.isFullscreen) ||
aspect_ratio_mode == 'unconstrained' || aspectRatioMode == 'unconstrained' ||
(aspect_ratio_mode == 'dynamic' && this._view.isMediaView()) (aspectRatioMode == 'dynamic' && this._view.isMediaView())
); );
} }
@@ -784,14 +795,14 @@ export class FrigateCard extends LitElement {
return null; return null;
} }
const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic'; const aspectRatioMode = this.config.dimensions.aspect_ratio_mode;
if (aspect_ratio_mode == 'dynamic' && this._mediaShowInfo) { if (aspectRatioMode == 'dynamic' && this._mediaShowInfo) {
return (this._mediaShowInfo.height / this._mediaShowInfo.width) * 100; return (this._mediaShowInfo.height / this._mediaShowInfo.width) * 100;
} }
const default_aspect_ratio = this.config.dimensions?.aspect_ratio; const defaultAspectRatio = this.config.dimensions.aspect_ratio;
if (default_aspect_ratio) { if (defaultAspectRatio) {
return (default_aspect_ratio[1] / default_aspect_ratio[0]) * 100; return (defaultAspectRatio[1] / defaultAspectRatio[0]) * 100;
} else { } else {
return (9 / 16) * 100; return (9 / 16) * 100;
} }
@@ -801,13 +812,6 @@ export class FrigateCard extends LitElement {
* Master render method for the card. * Master render method for the card.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (this.config.show_warning) {
return this._showWarning(localize('common.show_warning'));
}
if (this.config.show_error) {
return this._showError(localize('common.show_error'));
}
const padding = this._getAspectRatioPadding(); const padding = this._getAspectRatioPadding();
const outerStyle = {}, const outerStyle = {},
innerStyle = {}; innerStyle = {};
@@ -836,7 +840,7 @@ 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) const allowance = ['above', 'below'].includes(this.config.menu.mode)
? MENU_HEIGHT ? MENU_HEIGHT
: 0; : 0;
innerStyle['max-width'] = `calc(${ innerStyle['max-width'] = `calc(${
@@ -850,7 +854,7 @@ export class FrigateCard extends LitElement {
}; };
return html` <ha-card @click=${this._interactionHandler}> return html` <ha-card @click=${this._interactionHandler}>
${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)}">
${this._frigateCameraName == undefined ${this._frigateCameraName == undefined
@@ -864,7 +868,7 @@ export class FrigateCard extends LitElement {
: this._render()} : this._render()}
</div> </div>
</div> </div>
${this.config.menu_mode != 'above' ? this._renderMenu() : ''} ${this.config.menu.mode != 'above' ? this._renderMenu() : ''}
</ha-card>`; </ha-card>`;
} }
@@ -891,16 +895,16 @@ export class FrigateCard extends LitElement {
gallery: this._view.isGalleryView(), gallery: this._view.isGalleryView(),
}; };
const galleryClasses = { const galleryClasses = {
hidden: this.config.live_preload && !this._view.isGalleryView(), hidden: this.config.live.preload && !this._view.isGalleryView(),
}; };
const viewerClasses = { const viewerClasses = {
hidden: this.config.live_preload && !this._view.isViewerView(), hidden: this.config.live.preload && !this._view.isViewerView(),
}; };
const liveClasses = { const liveClasses = {
hidden: this.config.live_preload && this._view.view != 'live', hidden: this.config.live.preload && this._view.view != 'live',
}; };
const imageClasses = { const imageClasses = {
hidden: this.config.live_preload && this._view.view != 'image', hidden: this.config.live.preload && this._view.view != 'image',
}; };
return html` return html`
@@ -908,7 +912,7 @@ export class FrigateCard extends LitElement {
${this._message ? renderMessage(this._message) : ``} ${this._message ? renderMessage(this._message) : ``}
${!this._message && this._view.is('image') ${!this._message && this._view.is('image')
? html` <frigate-card-image ? html` <frigate-card-image
.image=${this.config.image} .imageConfig=${this.config.image}
class="${classMap(imageClasses)}" class="${classMap(imageClasses)}"
@frigate-card:media-show=${this._mediaShowHandler} @frigate-card:media-show=${this._mediaShowHandler}
@frigate-card:message=${this._messageHandler} @frigate-card:message=${this._messageHandler}
@@ -931,11 +935,8 @@ export class FrigateCard extends LitElement {
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
.browseMediaQueryParameters=${mediaQueryParameters} .browseMediaQueryParameters=${mediaQueryParameters}
.nextPreviousControlStyle=${this.config.controls?.nextprev ?? 'thumbnails'} .viewerConfig=${this.config.event_viewer}
.nextPreviousControlSize=${this.config.controls?.nextprev_size}
.autoplayClip=${this.config.autoplay_clip}
.resolvedMediaCache=${this._resolvedMediaCache} .resolvedMediaCache=${this._resolvedMediaCache}
.lazyLoad=${this.config.event_viewer?.lazy_load ?? true}
class="${classMap(viewerClasses)}" class="${classMap(viewerClasses)}"
@frigate-card:change-view=${this._changeViewHandler} @frigate-card:change-view=${this._changeViewHandler}
@frigate-card:media-show=${this._mediaShowHandler} @frigate-card:media-show=${this._mediaShowHandler}
@@ -947,14 +948,14 @@ export class FrigateCard extends LitElement {
: ``} : ``}
${ ${
// Note the subtle difference in condition below vs the other views in order // Note the subtle difference in condition below vs the other views in order
// to always render the live view for live_preload mode. // to always render the live view for live.preload mode.
(!this._message && this._view.is('live')) || this.config.live_preload (!this._message && this._view.is('live')) || this.config.live.preload
? html` ? html`
<frigate-card-live <frigate-card-live
.hass=${this._hass} .hass=${this._hass}
.config=${this.config} .config=${this.config}
.frigateCameraName=${this._frigateCameraName} .frigateCameraName=${this._frigateCameraName}
.preload=${this.config.live_preload && !this._view.is('live')} .preload=${this.config.live.preload && !this._view.is('live')}
class="${classMap(liveClasses)}" class="${classMap(liveClasses)}"
@frigate-card:media-show=${this._mediaShowHandler} @frigate-card:media-show=${this._mediaShowHandler}
@frigate-card:pause=${this._pauseHandler} @frigate-card:pause=${this._pauseHandler}
@@ -991,31 +992,6 @@ export class FrigateCard extends LitElement {
`; `;
} }
/**
* Show a warning card.
* @param warning The warning message.
* @returns A rendered template.
*/
private _showWarning(warning: string): TemplateResult {
return html` <hui-warning> ${warning} </hui-warning> `;
}
/**
* Show an error card.
* @param error The error message.
* @returns A rendered template.
*/
private _showError(error: string): TemplateResult {
const errorCard = document.createElement('hui-error-card');
errorCard.setConfig({
type: 'error',
error,
origConfig: this.config,
});
return html` ${errorCard} `;
}
/** /**
* Return compiled CSS styles (thus safe to use with unsafeCSS). * Return compiled CSS styles (thus safe to use with unsafeCSS).
*/ */
+2 -2
View File
@@ -214,8 +214,8 @@ export function dispatchErrorMessageEvent(element: HTMLElement, message: string)
* @returns A boolean indicating whether or not to allow an update. * @returns A boolean indicating whether or not to allow an update.
*/ */
export function shouldUpdateBasedOnHass( export function shouldUpdateBasedOnHass(
newHass: HomeAssistant | null, newHass: HomeAssistant | undefined | null,
oldHass: HomeAssistant | undefined, oldHass: HomeAssistant | undefined | null,
entities: string[] | null, entities: string[] | null,
): boolean { ): boolean {
if (!newHass || !entities) { if (!newHass || !entities) {
+5 -3
View File
@@ -3,17 +3,19 @@ import { customElement, property } from 'lit/decorators.js';
import { dispatchMediaShowEvent } from '../common.js'; import { dispatchMediaShowEvent } from '../common.js';
import imageStyle from '../scss/image.scss'; import type { ImageViewConfig } from '../types.js';
import defaultImage from '../images/frigate-bird-in-sky.jpg' import defaultImage from '../images/frigate-bird-in-sky.jpg'
import imageStyle from '../scss/image.scss';
@customElement('frigate-card-image') @customElement('frigate-card-image')
export class FrigateCardImage extends LitElement { export class FrigateCardImage extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected image?: string; protected imageConfig?: ImageViewConfig;
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
return html` <img return html` <img
src=${this.image || defaultImage} src=${this.imageConfig?.src || defaultImage}
@load=${(e) => { @load=${(e) => {
dispatchMediaShowEvent(this, e); dispatchMediaShowEvent(this, e);
}} }}
+6 -6
View File
@@ -1,5 +1,5 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import type { ExtendedHomeAssistant, FrigateCardConfig, MediaShowInfo } from '../types.js'; import type { ExtendedHomeAssistant, FrigateCardConfig, MediaShowInfo, WebRTCConfig } from '../types.js';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js'; import { until } from 'lit/directives/until.js';
@@ -75,24 +75,24 @@ export class FrigateCardLive extends LitElement {
return; return;
} }
return html` ${this.config.live_provider == 'frigate' return html` ${this.config.live.provider == 'frigate'
? html` <frigate-card-live-frigate ? html` <frigate-card-live-frigate
.hass=${this.hass} .hass=${this.hass}
.cameraEntity=${this.config.camera_entity} .cameraEntity=${this.config.camera_entity}
@frigate-card:media-show=${this._mediaShowHandler} @frigate-card:media-show=${this._mediaShowHandler}
> >
</frigate-card-live-frigate>` </frigate-card-live-frigate>`
: this.config.live_provider == 'webrtc' : this.config.live.provider == 'webrtc'
? html`<frigate-card-live-webrtc ? html`<frigate-card-live-webrtc
.hass=${this.hass} .hass=${this.hass}
.webRTCConfig=${this.config.webrtc || {}} .webRTCConfig=${this.config.live.webrtc || {}}
@frigate-card:media-show=${this._mediaShowHandler} @frigate-card:media-show=${this._mediaShowHandler}
> >
</frigate-card-live-webrtc>` </frigate-card-live-webrtc>`
: html` <frigate-card-live-jsmpeg : html` <frigate-card-live-jsmpeg
.hass=${this.hass} .hass=${this.hass}
.cameraName=${this.frigateCameraName} .cameraName=${this.frigateCameraName}
.clientId=${this.config.frigate_client_id} .clientId=${this.config.frigate.client_id}
@frigate-card:media-show=${this._mediaShowHandler} @frigate-card:media-show=${this._mediaShowHandler}
> >
</frigate-card-live-jsmpeg>`}`; </frigate-card-live-jsmpeg>`}`;
@@ -152,7 +152,7 @@ export class FrigateCardLiveFrigate extends LitElement {
@customElement('frigate-card-live-webrtc') @customElement('frigate-card-live-webrtc')
export class FrigateCardLiveWebRTC extends LitElement { export class FrigateCardLiveWebRTC extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected webRTCConfig?: Record<string, unknown>; protected webRTCConfig?: WebRTCConfig;
protected hass?: HomeAssistant & ExtendedHomeAssistant; protected hass?: HomeAssistant & ExtendedHomeAssistant;
protected _webRTCElement: HTMLElement | null = null; protected _webRTCElement: HTMLElement | null = null;
+38 -33
View File
@@ -16,8 +16,8 @@ import { actionHandler } from '../action-handler-directive.js';
import type { import type {
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateMenuMode,
MenuButton, MenuButton,
MenuConfig,
MenuInteraction, MenuInteraction,
} from '../types.js'; } from '../types.js';
import { dispatchFrigateCardEvent, shouldUpdateBasedOnHass } from '../common.js'; import { dispatchFrigateCardEvent, shouldUpdateBasedOnHass } from '../common.js';
@@ -30,10 +30,16 @@ export const MENU_HEIGHT = 46;
@customElement('frigate-card-menu') @customElement('frigate-card-menu')
export class FrigateCardMenu extends LitElement { export class FrigateCardMenu extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public hass!: HomeAssistant & ExtendedHomeAssistant; public hass?: HomeAssistant & ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
protected menuMode: FrigateMenuMode = 'hidden-top'; set menuConfig(menuConfig: MenuConfig) {
this._menuConfig = menuConfig;
if (menuConfig) {
this.style.setProperty('--frigate-card-menu-button-size', menuConfig.button_size);
}
}
public _menuConfig?: MenuConfig;
@property({ attribute: false }) @property({ attribute: false })
protected expand = false; protected expand = false;
@@ -41,13 +47,8 @@ export class FrigateCardMenu extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public buttons: MenuButton[] = []; public buttons: MenuButton[] = [];
@property({ attribute: false })
set buttonSize(buttonSize: string) {
this.style.setProperty('--frigate-card-menu-button-size', buttonSize);
}
protected _interactionHandler(ev: CustomEvent, button: MenuButton): void { protected _interactionHandler(ev: CustomEvent, button: MenuButton): void {
if (this.menuMode.startsWith('hidden-')) { if (this._menuConfig?.mode.startsWith('hidden-')) {
if (button.type == 'internal-menu-icon' && button.tap_action === 'frigate') { if (button.type == 'internal-menu-icon' && button.tap_action === 'frigate') {
this.expand = !this.expand; this.expand = !this.expand;
return; return;
@@ -82,7 +83,7 @@ export class FrigateCardMenu extends LitElement {
} }
// Render a menu button. // Render a menu button.
protected _renderButton(button: MenuButton): TemplateResult { protected _renderButton(button: MenuButton): TemplateResult | void {
let state: HassEntity | null = null; let state: HassEntity | null = null;
let emphasize = false; let emphasize = false;
let title = button.title; let title = button.title;
@@ -90,10 +91,13 @@ export class FrigateCardMenu extends LitElement {
const style = ('style' in button ? button.style : {}) || {}; const style = ('style' in button ? button.style : {}) || {};
if (button.type === 'custom:frigate-card-menu-state-icon') { if (button.type === 'custom:frigate-card-menu-state-icon') {
if (!this.hass) {
return;
}
state = this.hass.states[button.entity]; state = this.hass.states[button.entity];
emphasize = emphasize =
!!state && button.state_color && ['on', 'active', 'home'].includes(state.state); !!state && button.state_color && ['on', 'active', 'home'].includes(state.state);
title = title ?? (state.attributes.friendly_name || button.entity); title = title ?? (state?.attributes?.friendly_name || button.entity);
icon = icon ?? stateIcon(state); icon = icon ?? stateIcon(state);
} else if (button.type === 'internal-menu-icon') { } else if (button.type === 'internal-menu-icon') {
emphasize = button.emphasize ?? false; emphasize = button.emphasize ?? false;
@@ -133,9 +137,9 @@ export class FrigateCardMenu extends LitElement {
} }
// Render the Frigate menu button. // Render the Frigate menu button.
protected _renderFrigateButton(button: MenuButton): TemplateResult { protected _renderFrigateButton(button: MenuButton): TemplateResult | void {
const icon = const icon =
this.menuMode.startsWith('hidden-') && !this.expand this._menuConfig?.mode.startsWith('hidden-') && !this.expand
? 'mdi:alpha-f-box-outline' ? 'mdi:alpha-f-box-outline'
: 'mdi:alpha-f-box'; : 'mdi:alpha-f-box';
@@ -143,7 +147,12 @@ export class FrigateCardMenu extends LitElement {
} }
// Render the menu. // Render the menu.
protected render(): TemplateResult { protected render(): TemplateResult | void {
if (!this._menuConfig) {
return;
}
const mode = this._menuConfig.mode;
const isFrigateButton = function (button: MenuButton): boolean { const isFrigateButton = function (button: MenuButton): boolean {
return button.type === 'internal-menu-icon' && button.tap_action === 'frigate'; return button.type === 'internal-menu-icon' && button.tap_action === 'frigate';
}; };
@@ -151,33 +160,29 @@ export class FrigateCardMenu extends LitElement {
// 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' || mode == 'none' ||
(this.menuMode.startsWith('hidden-') && !this.buttons.find(isFrigateButton)) (mode.startsWith('hidden-') && !this.buttons.find(isFrigateButton))
) { ) {
return html``; return;
} }
const classes = { const classes = {
'frigate-card-menu': true, 'frigate-card-menu': true,
'overlay-hidden': 'overlay-hidden':
this.menuMode.startsWith('hidden-') || mode.startsWith('hidden-') ||
this.menuMode.startsWith('overlay-') || mode.startsWith('overlay-') ||
this.menuMode.startsWith('hover-'), mode.startsWith('hover-'),
'expanded-horizontal': 'expanded-horizontal':
(this.menuMode.startsWith('overlay-') || (mode.startsWith('overlay-') || mode.startsWith('hover-') || this.expand) &&
this.menuMode.startsWith('hover-') || (mode.endsWith('-top') || mode.endsWith('-bottom')),
this.expand) &&
(this.menuMode.endsWith('-top') || this.menuMode.endsWith('-bottom')),
'expanded-vertical': 'expanded-vertical':
(this.menuMode.startsWith('overlay-') || (mode.startsWith('overlay-') || mode.startsWith('hover-') || this.expand) &&
this.menuMode.startsWith('hover-') || (mode.endsWith('-left') || mode.endsWith('-right')),
this.expand) && full: mode == 'above' || mode == 'below',
(this.menuMode.endsWith('-left') || this.menuMode.endsWith('-right')), left: mode.endsWith('-left'),
full: ['above', 'below'].includes(this.menuMode), right: mode.endsWith('-right'),
left: this.menuMode.endsWith('-left'), top: mode.endsWith('-top'),
right: this.menuMode.endsWith('-right'), bottom: mode.endsWith('-bottom'),
top: this.menuMode.endsWith('-top'),
bottom: this.menuMode.endsWith('-bottom'),
}; };
return html` return html`
+14 -15
View File
@@ -2,30 +2,29 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { NextPreviousControlStyle } from '../types.js'; import { NextPreviousControlConfig } from '../types.js';
import controlStyle from '../scss/next-previous-control.scss'; import controlStyle from '../scss/next-previous-control.scss';
@customElement('frigate-card-next-previous-control') @customElement('frigate-card-next-previous-control')
export class FrigateCardMessage extends LitElement { export class FrigateCardNextPreviousControl extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected direction?: 'next' | 'previous'; protected direction?: 'next' | 'previous';
@property({ attribute: false }) @property({ attribute: false })
protected controlStyle?: NextPreviousControlStyle; set controlConfig(controlConfig: NextPreviousControlConfig | undefined) {
if (controlConfig?.size) {
this.style.setProperty('--frigate-card-next-prev-size', controlConfig.size);
}
this._controlConfig = controlConfig;
}
protected _controlConfig?: NextPreviousControlConfig;
@property({ attribute: false }) @property({ attribute: false })
protected thumbnail?: string; protected thumbnail?: string;
@property({ attribute: false })
set controlSize(controlSize: string | undefined) {
if (controlSize) {
this.style.setProperty('--frigate-card-next-prev-size', controlSize);
}
}
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.controlStyle || this.controlStyle == 'none') { if (!this._controlConfig || this._controlConfig.style == 'none') {
return html``; return html``;
} }
@@ -33,12 +32,12 @@ export class FrigateCardMessage extends LitElement {
controls: true, controls: true,
previous: this.direction == 'previous', previous: this.direction == 'previous',
next: this.direction == 'next', next: this.direction == 'next',
thumbnails: this.controlStyle == 'thumbnails', thumbnails: this._controlConfig.style == 'thumbnails',
chevrons: this.controlStyle == 'chevrons', chevrons: this._controlConfig.style == 'chevrons',
button: this.controlStyle == 'chevrons', button: this._controlConfig.style == 'chevrons',
}; };
if (this.controlStyle == 'chevrons') { if (this._controlConfig.style == 'chevrons') {
const icon = this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right'; const icon = this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right';
// TODO: Upon a safe distance from the release of HA 2021.11 these // TODO: Upon a safe distance from the release of HA 2021.11 these
+39 -55
View File
@@ -19,7 +19,7 @@ import type {
BrowseMediaSource, BrowseMediaSource,
ExtendedHomeAssistant, ExtendedHomeAssistant,
MediaShowInfo, MediaShowInfo,
NextPreviousControlStyle, ViewerConfig,
} from '../types.js'; } from '../types.js';
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
import { View } from '../view.js'; import { View } from '../view.js';
@@ -49,24 +49,15 @@ export class FrigateCardViewer extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected view?: View; protected view?: View;
@property({ attribute: false })
protected viewerConfig?: ViewerConfig;
@property({ attribute: false }) @property({ attribute: false })
protected browseMediaQueryParameters?: BrowseMediaQueryParameters; protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
@property({ attribute: false })
protected nextPreviousControlStyle?: NextPreviousControlStyle;
@property({ attribute: false })
protected nextPreviousControlSize?: string;
@property({ attribute: false })
protected autoplayClip?: boolean;
@property({ attribute: false }) @property({ attribute: false })
protected resolvedMediaCache?: ResolvedMediaCache; protected resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
protected lazyLoad?: boolean;
/** /**
* Resolve all the given media for a target. * Resolve all the given media for a target.
* @param target The target to resolve media from. * @param target The target to resolve media from.
@@ -92,21 +83,23 @@ export class FrigateCardViewer extends LitElement {
return errorFree; return errorFree;
} }
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
return html`${until(this._render(), renderProgressIndicator())}`; return html`${until(this._render(), renderProgressIndicator())}`;
} }
/** /**
* Asyncronously render the element. * Asyncronously render the element.
* @returns A template to render. * @returns A rendered template.
*/ */
protected async _render(): Promise<TemplateResult | void> { protected async _render(): Promise<TemplateResult | void> {
if (!this.hass || !this.view || !this.browseMediaQueryParameters) { if (!this.hass || !this.view || !this.browseMediaQueryParameters) {
return html``; return html``;
} }
let autoplay = true;
if (this.view.is('clip') || this.view.is('snapshot')) { if (this.view.is('clip') || this.view.is('snapshot')) {
let parent: BrowseMediaSource | null = null; let parent: BrowseMediaSource | null = null;
try { try {
@@ -129,13 +122,6 @@ export class FrigateCardViewer extends LitElement {
} }
this.view.target = parent; this.view.target = parent;
this.view.childIndex = childIndex; this.view.childIndex = childIndex;
// In this block, no clip has been manually selected, so this is loading
// the most recent clip on card load. In this mode, autoplay of the clip
// may be disabled by configuration. If does not make sense to disable
// autoplay when the user has explicitly picked an event to play in the
// gallery.
autoplay = this.autoplayClip ?? true;
} }
if (this.view.target && !(await this._resolveAllMediaForTarget(this.view.target))) { if (this.view.target && !(await this._resolveAllMediaForTarget(this.view.target))) {
@@ -144,13 +130,10 @@ export class FrigateCardViewer extends LitElement {
return html` <frigate-card-viewer-core return html` <frigate-card-viewer-core
.view=${this.view} .view=${this.view}
.nextPreviousControlStyle=${this.nextPreviousControlStyle} .viewerConfig=${this.viewerConfig}
.nextPreviousControlSize=${this.nextPreviousControlSize}
.resolvedMediaCache=${this.resolvedMediaCache} .resolvedMediaCache=${this.resolvedMediaCache}
.autoplayClip=${autoplay}
.hass=${this.hass} .hass=${this.hass}
.browseMediaQueryParameters=${this.browseMediaQueryParameters} .browseMediaQueryParameters=${this.browseMediaQueryParameters}
.lazyLoad=${this.lazyLoad}
> >
</frigate-card-viewer-core>`; </frigate-card-viewer-core>`;
} }
@@ -165,29 +148,20 @@ export class FrigateCardViewer extends LitElement {
@customElement('frigate-card-viewer-core') @customElement('frigate-card-viewer-core')
export class FrigateCardViewerCore extends LitElement { export class FrigateCardViewerCore extends LitElement {
@property({ attribute: false })
protected hass?: HomeAssistant & ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
protected view?: View; protected view?: View;
@property({ attribute: false }) @property({ attribute: false })
protected nextPreviousControlStyle?: NextPreviousControlStyle; protected viewerConfig?: ViewerConfig;
@property({ attribute: false })
protected nextPreviousControlSize?: string;
@property({ attribute: false })
protected resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
protected autoplayClip?: boolean;
@property({ attribute: false })
protected hass?: HomeAssistant & ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
protected browseMediaQueryParameters?: BrowseMediaQueryParameters; protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
@property({ attribute: false }) @property({ attribute: false })
protected lazyLoad?: boolean; protected resolvedMediaCache?: ResolvedMediaCache;
// Media carousel object. // Media carousel object.
protected _carousel?: EmblaCarouselType; protected _carousel?: EmblaCarouselType;
@@ -418,7 +392,7 @@ export class FrigateCardViewerCore extends LitElement {
* Lazily load media in the carousel. * Lazily load media in the carousel.
*/ */
protected _lazyLoadMediaHandler(): void { protected _lazyLoadMediaHandler(): void {
if (!this.lazyLoad || !this._carousel) { if (!this.viewerConfig?.lazy_load || !this._carousel) {
return; return;
} }
const slides = this._carousel.slideNodes(); const slides = this._carousel.slideNodes();
@@ -504,8 +478,7 @@ export class FrigateCardViewerCore extends LitElement {
${neighbors && neighbors.previous ${neighbors && neighbors.previous
? html`<frigate-card-next-previous-control ? html`<frigate-card-next-previous-control
.direction=${'previous'} .direction=${'previous'}
.controlStyle=${this.nextPreviousControlStyle} .controlConfig=${this.viewerConfig?.controls.next_previous}
.controlSize=${this.nextPreviousControlSize}
.thumbnail=${neighbors.previous.thumbnail} .thumbnail=${neighbors.previous.thumbnail}
.title=${neighbors.previous.title} .title=${neighbors.previous.title}
@click=${() => this._nextPreviousHandler('previous')} @click=${() => this._nextPreviousHandler('previous')}
@@ -519,8 +492,7 @@ export class FrigateCardViewerCore extends LitElement {
${neighbors && neighbors.next ${neighbors && neighbors.next
? html`<frigate-card-next-previous-control ? html`<frigate-card-next-previous-control
.direction=${'next'} .direction=${'next'}
.controlStyle=${this.nextPreviousControlStyle} .controlConfig=${this.viewerConfig?.controls.next_previous}
.controlSize=${this.nextPreviousControlSize}
.thumbnail=${neighbors.next.thumbnail} .thumbnail=${neighbors.next.thumbnail}
.title=${neighbors.next.title} .title=${neighbors.next.title}
@click=${() => this._nextPreviousHandler('next')} @click=${() => this._nextPreviousHandler('next')}
@@ -594,7 +566,7 @@ export class FrigateCardViewerCore extends LitElement {
): TemplateResult | void { ): TemplateResult | void {
// media that can be expanded (folders) cannot be resolved to a single media // media that can be expanded (folders) cannot be resolved to a single media
// item, skip them. // item, skip them.
if (!this.view || !BrowseMediaUtil.isTrueMedia(mediaToRender)) { if (!this.view || !this.viewerConfig || !BrowseMediaUtil.isTrueMedia(mediaToRender)) {
return; return;
} }
@@ -603,20 +575,32 @@ export class FrigateCardViewerCore extends LitElement {
return; return;
} }
// In this block, no clip has been manually selected, so this is loading
// the most recent clip on card load. In this mode, autoplay of the clip
// may be disabled by configuration. If does not make sense to disable
// autoplay when the user has explicitly picked an event to play in the
// gallery.
let autoplay = true;
if (this.view.is('clip') || this.view.is('snapshot')) {
autoplay = this.viewerConfig.autoplay_clip;
}
const lazyLoad = this.viewerConfig.lazy_load;
return html` return html`
<div class="embla__slide"> <div class="embla__slide">
${this.view.isClipRelatedView() ${this.view.isClipRelatedView()
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
? html`<frigate-card-ha-hls-player ? html`<frigate-card-ha-hls-player
.hass=${this.hass} .hass=${this.hass}
url=${ifDefined(this.lazyLoad ? undefined : resolvedMedia.url)} url=${ifDefined(lazyLoad ? undefined : resolvedMedia.url)}
data-url=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)} data-url=${ifDefined(lazyLoad ? resolvedMedia.url : undefined)}
title="${mediaToRender.title}" title="${mediaToRender.title}"
muted muted
controls controls
playsinline playsinline
allow-exoplayer allow-exoplayer
?autoplay="${this.autoplayClip}" ?autoplay="${autoplay}"
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) => @frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) =>
this._mediaShowEventHandler(slideIndex, e)} this._mediaShowEventHandler(slideIndex, e)}
> >
@@ -626,7 +610,7 @@ export class FrigateCardViewerCore extends LitElement {
muted muted
controls controls
playsinline playsinline
?autoplay="${this.autoplayClip}" ?autoplay="${autoplay}"
@loadedmetadata="${(e: Event) => { @loadedmetadata="${(e: Event) => {
this._mediaShowInfoHandler(slideIndex, createMediaShowInfo(e)); this._mediaShowInfoHandler(slideIndex, createMediaShowInfo(e));
}}" }}"
@@ -634,14 +618,14 @@ export class FrigateCardViewerCore extends LitElement {
@pause=${() => dispatchPauseEvent(this)} @pause=${() => dispatchPauseEvent(this)}
> >
<source <source
src=${ifDefined(this.lazyLoad ? undefined : resolvedMedia.url)} src=${ifDefined(lazyLoad ? undefined : resolvedMedia.url)}
data-src=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)} data-src=${ifDefined(lazyLoad ? resolvedMedia.url : undefined)}
type="${resolvedMedia.mime_type}" type="${resolvedMedia.mime_type}"
/> />
</video>` </video>`
: html`<img : html`<img
src=${ifDefined(this.lazyLoad ? IMG_EMPTY : resolvedMedia.url)} src=${ifDefined(lazyLoad ? IMG_EMPTY : resolvedMedia.url)}
data-src=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)} data-src=${ifDefined(lazyLoad ? resolvedMedia.url : undefined)}
title="${mediaToRender.title}" title="${mediaToRender.title}"
@click=${() => { @click=${() => {
if (this._carousel?.clickAllowed()) { if (this._carousel?.clickAllowed()) {
+152
View File
@@ -0,0 +1,152 @@
import delve from 'dlv';
import { dset } from 'dset';
import { RawFrigateCardConfig } from './types';
/**
* Set a configuration value.
* @param obj The configuration.
* @param key The key to the property to set.
* @param value The value to set.
*/
export const setConfigValue = (
obj: RawFrigateCardConfig,
key: string,
value: unknown,
): void => {
dset(obj, key, value);
};
/**
* Get a configuration value.
* @param obj The configuration.
* @param key The key to the property to retrieve.
* @returns The property or undefined if not found.
*/
export const getConfigValue = (obj: RawFrigateCardConfig, key: string, def?: unknown): unknown => {
return delve(obj, key, def);
};
/**
* Delete a configuration value.
* @param obj The configuration.
* @param key The key to the property to delete.
*/
export const deleteConfigValue = (obj: RawFrigateCardConfig, key: string): void => {
let id = key;
let targetObj: unknown = obj;
if (key && key.split && key.includes('.')) {
const keys = key.split('.');
id = keys[keys.length - 1];
targetObj = getConfigValue(obj, keys.slice(0, -1).join('.'));
}
if (targetObj && typeof targetObj === 'object') {
delete targetObj[id];
}
};
/**
* Upgrade a configuration.
* @param obj The configuration to upgrade.
* @returns `true` if the configuration is modified.
*/
export const upgradeConfig = function (obj: RawFrigateCardConfig): boolean {
let upgraded = false;
for (let i = 0; i < UPGRADES.length; i++) {
upgraded = UPGRADES[i](obj) || upgraded;
}
trimConfig(obj);
return upgraded;
};
/**
* Determine if a configuration is automatically upgradeable.
* @param obj The configuration. It is not modified.
* @returns `true` if the configuration is upgradeable.
*/
export const isConfigUpgradeable = function (obj: RawFrigateCardConfig): boolean {
const newObj = JSON.parse(JSON.stringify(obj));
return upgradeConfig(newObj);
};
/**
* Remove empty sections from a configuration.
* @param obj Configuration object.
*/
export const trimConfig = function (obj: RawFrigateCardConfig): void {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (typeof obj[key] === 'object' && obj[key] != null) {
trimConfig(obj[key] as RawFrigateCardConfig);
if (!Object.keys(obj[key] as RawFrigateCardConfig).length) {
delete obj[key];
}
}
}
};
/**
* Copy a configuration.
* @param obj Configuration to copy.
* @returns A new deeply-copied configuration.
*/
export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardConfig {
return JSON.parse(JSON.stringify(obj));
}
/**
* Determines if a property is not an object.
* @param value The property.
* @returns `true` is the value is not an object.
*/
const isNotObject = function (value: unknown) {
return typeof value !== 'object' ? value : undefined;
};
/**
* Move a property from one location to another.
* @param oldPath The old property path.
* @param newPath The new property path.
* @param transform An optional transform for the value.
* @returns `true` if the configuration was modified.
*/
const upgradeMoveTo = function (
oldPath: string,
newPath: string,
transform?: (valueIn: unknown) => unknown,
): (obj: RawFrigateCardConfig) => boolean {
return function (obj: RawFrigateCardConfig): boolean {
let value = getConfigValue(obj, oldPath);
if (transform) {
value = transform(value);
}
if (typeof value !== 'undefined') {
deleteConfigValue(obj, oldPath);
setConfigValue(obj, newPath, value);
return true;
}
return false;
};
};
const UPGRADES = [
// v1.2.1 -> v2.0.0
upgradeMoveTo('frigate_url', 'frigate.url'),
upgradeMoveTo('frigate_client_id', 'frigate.client_id'),
upgradeMoveTo('frigate_camera_name', 'frigate.camera_name'),
upgradeMoveTo('label', 'frigate.label'),
upgradeMoveTo('zone', 'frigate.zone'),
upgradeMoveTo('view_default', 'view.default'),
upgradeMoveTo('view_timeout', 'view.timeout'),
upgradeMoveTo('live_provider', 'live.provider'),
upgradeMoveTo('live_preload', 'live.preload'),
upgradeMoveTo('webrtc', 'live.webrtc'),
upgradeMoveTo('autoplay_clip', 'event_viewer.autoplay_clip'),
upgradeMoveTo('controls.nextprev', 'event_viewer.controls.next_previous.style'),
upgradeMoveTo('controls.nextprev_size', 'event_viewer.controls.next_previous.size'),
upgradeMoveTo('menu_mode', 'menu.mode'),
upgradeMoveTo('menu_buttons', 'menu.buttons'),
upgradeMoveTo('menu_button_size', 'menu.button_size'),
upgradeMoveTo('image', 'image.src', isNotObject),
];
+555 -334
View File
File diff suppressed because it is too large Load Diff
+101 -72
View File
@@ -1,94 +1,122 @@
{ {
"common": { "common": {
"version": "Version", "version": "Version",
"show_warning": "Show Warning", "frigate_card": "Frigate card",
"show_error": "Show Error",
"frigate_card": "Frigate Card",
"frigate_card_description": "A Lovelace card for use with Frigate", "frigate_card_description": "A Lovelace card for use with Frigate",
"no_snapshots": "No snapshots", "no_snapshots": "No snapshots",
"no_clips": "No clips", "no_clips": "No clips",
"no_snapshot": "No recent snapshot", "no_snapshot": "No recent snapshot",
"no_clip": "No recent clip" "no_clip": "No recent clip"
}, },
"editor": { "config": {
"basic": "Basic", "camera_entity": "Camera Entity",
"basic_secondary": "Basic options for most users", "frigate": {
"optional": "Optional", "camera_name": "Frigate camera name (Optional, autodetected from entity)",
"optional_secondary": "Optional configuration to tweak card behavior", "client_id": "Frigate client id (For >1 Frigate server)",
"appearance": "Appearance", "label": "Frigate label/object filter",
"appearance_secondary": "Appearance of the card", "url": "Frigate server URL",
"webrtc": "WebRTC", "zone": "Frigate zone"
"webrtc_secondary": "Optional WebRTC parameters",
"advanced": "Advanced",
"advanced_secondary": "Advanced options",
"camera_entity": "Camera Entity (Optional)",
"frigate_camera_name": "Frigate camera name (Optional, autodetected from entity)",
"default_view": "Default view (Optional)",
"frigate_client_id": "Frigate client id (Optional, for >1 Frigate server)",
"view_timeout": "View timeout (seconds)",
"frigate_url": "Frigate URL (Optional, for Frigate UI button)",
"autoplay_clip": "Autoplay latest clip",
"menu_mode": "Menu mode (Optional)",
"show_button": "Show button",
"zone": "Zone",
"label": "Frigate label/object filter (Optional)",
"live_provider": "Live view provider (Optional)",
"live_preload": "Preload live view",
"image": "Static image URL for image view (Optional)",
"lazy_load": "Lazily load event media"
}, },
"menu": { "view": {
"frigate": "Frigate Menu / Default View", "default": "Default view",
"live": "Live View", "views": {
"clips": "Clips Gallery", "live": "Live view",
"snapshots": "Snapshots Gallery", "clip": "Most recent clip",
"clip": "Latest Clip", "clips": "Clips gallery",
"snapshot": "Latest Snapshot", "snapshot": "Most recent snapshot",
"frigate_ui": "Frigate User Interface", "snapshots": "Snapshots gallery",
"fullscreen": "Fullscreen", "image": "Static image"
"image": "Static Image",
"download": "Download event media"
}, },
"control": { "timeout": "View timeout secs (before returning to default, 0=never)"
"nextprev": "Media Next & Previous Controls", },
"event_viewer": {
"autoplay_clip": "Autoplay most recent clip (In 'clip' view)",
"lazy_load": "Lazily load event media",
"controls": {
"next_previous": {
"style": "Event Viewer Next & Previous Control style",
"size": "Event Viewer Next & Previous Control size (e.g. '48px')",
"styles": {
"thumbnails": "Thumbnails", "thumbnails": "Thumbnails",
"chevrons": "Chevrons", "chevrons": "Chevrons",
"none": "None" "none": "None"
}
}
}
}, },
"dimensions": { "live": {
"aspect_ratio_mode": "Aspect Ratio Mode", "preload": "Preload live view",
"aspect_ratio": "Default aspect ratio (Optional)" "provider": "Live view provider",
}, "providers": {
"aspect_ratio_mode": {
"unconstrained": "Unconstrained aspect ratio",
"dynamic": "Aspect ratio adjusts to media",
"static": "Static aspect ratio"
},
"menu_mode": {
"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"
},
"live_provider": {
"frigate": "Frigate", "frigate": "Frigate",
"frigate-jsmpeg": "Frigate JSMpeg", "frigate-jsmpeg": "Frigate JSMpeg",
"webrtc": "WebRTC" "webrtc": "WebRTC"
}, },
"webrtc": { "webrtc": {
"entity": "Optional WebRTC Camera Entity (Not a Frigate camera)", "entity": "WebRTC Camera Entity (Not a Frigate camera)",
"url": "Optional WebRTC Camera URL" "url": "WebRTC Camera URL"
}
},
"image": {
"src": "Static image URL/data for image view"
},
"menu": {
"buttons": {
"frigate": "Frigate menu / Default view",
"frigate_ui": "Frigate user Interface",
"fullscreen": "Fullscreen",
"download": "Download event media"
},
"mode": "Menu mode",
"modes": {
"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"
}
}
},
"editor": {
"basic": "Basic",
"basic_secondary": "Options for most users",
"frigate": "Frigate",
"frigate_secondary": "Frigate server options",
"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_viewer": "Event viewer",
"event_viewer_secondary": "Snapshots & clips gallery options",
"image": "Image",
"image_secondary": "Static image view options",
"dimensions": "Dimensions",
"dimensions_secondary": "Dimensions & shape options",
"show_button": "Show button",
"upgrade": "Upgrade",
"upgrade_available": "An automatic card configuration upgrade is available"
}, },
"error": { "error": {
"empty_response": "Received empty response from Home Assistant for request", "empty_response": "Received empty response from Home Assistant for request",
@@ -100,8 +128,9 @@
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider", "no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
"invalid_configuration": "Invalid configuration", "invalid_configuration": "Invalid configuration",
"invalid_configuration_no_hint": "No location hint available (bad or missing type?)", "invalid_configuration_no_hint": "No location hint available (bad or missing type?)",
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
"missing_webrtc": "WebRTC component not found", "missing_webrtc": "WebRTC component not found",
"no_frigate_camera_name": "Cannot autodetect Frigate camera name, you need to either set camera_entity and / or frigate_camera_name", "no_frigate_camera_name": "Cannot autodetect Frigate camera name, you need to either set camera_entity and / or frigate.camera_name",
"could_not_render_elements": "Could not render picture elements", "could_not_render_elements": "Could not render picture elements",
"invalid_elements_config": "Invalid picture elements configuration", "invalid_elements_config": "Invalid picture elements configuration",
"jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path",
+12
View File
@@ -25,3 +25,15 @@
ha-formfield { ha-formfield {
padding-bottom: 8px; padding-bottom: 8px;
} }
div.upgrade {
width: auto;
border: 1px dotted var(--primary-color);
margin: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
div.upgrade span {
padding: 10px;
}
+4
View File
@@ -1,3 +1,7 @@
:host {
--video-max-height: none;
}
canvas { canvas {
width: 100%; width: 100%;
display: block; display: block;
+5
View File
@@ -1,6 +1,11 @@
:host {
--video-max-height: none;
}
ha-hls-player { ha-hls-player {
transform-style: preserve-3d; transform-style: preserve-3d;
} }
img,video { img,video {
width: 100%; width: 100%;
height: 100%; height: 100%;
+174 -54
View File
@@ -38,7 +38,7 @@ export type FrigateCardView =
| typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number] | typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number]
| typeof FRIGATE_CARD_VIEWS_INTERNAL[number]; | typeof FRIGATE_CARD_VIEWS_INTERNAL[number];
export const FRIGATE_MENU_MODES = [ const FRIGATE_MENU_MODES = [
'none', 'none',
'hidden-top', 'hidden-top',
'hidden-left', 'hidden-left',
@@ -55,13 +55,8 @@ export const FRIGATE_MENU_MODES = [
'above', 'above',
'below', 'below',
] as const; ] as const;
export type FrigateMenuMode = typeof FRIGATE_MENU_MODES[number];
export const NEXT_PREVIOUS_CONTROL_STYLES = ['none', 'thumbnails', 'chevrons'] as const; export const NEXT_PREVIOUS_CONTROL_STYLES = ['none', 'thumbnails', 'chevrons'] as const;
export type NextPreviousControlStyle = typeof NEXT_PREVIOUS_CONTROL_STYLES[number];
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];
/** /**
* Action Types (for "Picture Elements" / Menu) * Action Types (for "Picture Elements" / Menu)
@@ -122,7 +117,7 @@ const elementsBaseSchema = z.object({
}); });
/** /**
* Picture Element Types * Picture Element Configuration.
* *
* All picture element types are validated (not just the Frigate card custom * 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 * ones) as a convenience to present the user with a consistent error display
@@ -214,7 +209,7 @@ const customSchema = z
if (!val.match(/^custom:(?!frigate-card).+/)) { if (!val.match(/^custom:(?!frigate-card).+/)) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: "Frigate-card custom elements must match specific schemas", message: 'Frigate-card custom elements must match specific schemas',
fatal: true, fatal: true,
}); });
} }
@@ -223,7 +218,7 @@ const customSchema = z
.passthrough(); .passthrough();
/** /**
* Custom Element Types * Custom Element Types.
*/ */
export const menuIconSchema = iconSchema.merge( export const menuIconSchema = iconSchema.merge(
@@ -269,15 +264,37 @@ export type PictureElement = z.infer<typeof pictureElementSchema>;
const pictureElementsSchema = pictureElementSchema.array().optional(); const pictureElementsSchema = pictureElementSchema.array().optional();
export type PictureElements = z.infer<typeof pictureElementsSchema>; export type PictureElements = z.infer<typeof pictureElementsSchema>;
export const frigateCardConfigSchema = z.object({ /**
camera_entity: z.string().optional(), * Frigate configuration section.
*/
const frigateConfigDefault = {
client_id: 'frigate' as const,
};
const frigateConfigDefaultSchema = z
.object({
// No URL validation to allow relative URLs within HA (e.g. addons). // No URL validation to allow relative URLs within HA (e.g. addons).
frigate_url: z.string().optional(), url: z.string().optional(),
frigate_client_id: z.string().optional().default('frigate'), client_id: z.string().optional().default(frigateConfigDefault.client_id),
frigate_camera_name: z.string().optional(), camera_name: z.string().optional(),
view_default: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED).optional().default('live'), label: z.string().optional(),
view_timeout: z zone: z.string().optional(),
})
.default(frigateConfigDefault);
/**
* View configuration section.
*/
const viewConfigDefault = {
default: 'live' as const,
timeout: 180,
};
const viewConfigSchema = z
.object({
default: z
.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED)
.optional()
.default(viewConfigDefault.default),
timeout: z
.number() .number()
.or( .or(
z z
@@ -286,52 +303,129 @@ export const frigateCardConfigSchema = z.object({
.transform((val) => Number(val)), .transform((val) => Number(val)),
) )
.optional() .optional()
.default(180), .default(viewConfigDefault.timeout),
live_provider: z.enum(LIVE_PROVIDERS).default('frigate'), })
live_preload: z.boolean().default(false), .default(viewConfigDefault);
image: z.string().optional(),
webrtc: z /**
* Image view configuration section.
*/
const imageConfigSchema = z
.object({
src: z.string().optional(),
})
.optional();
export type ImageViewConfig = z.infer<typeof imageConfigSchema>;
/**
* Live view configuration section.
*/
const liveConfigDefault = {
provider: 'frigate' as const,
preload: false,
};
const webrtcConfigSchema = z
.object({ .object({
entity: z.string().optional(), entity: z.string().optional(),
url: z.string().optional(), url: z.string().optional(),
}) })
.passthrough() .passthrough()
.optional(), .optional();
label: z.string().optional(), export type WebRTCConfig = z.infer<typeof webrtcConfigSchema>;
zone: z.string().optional(),
autoplay_clip: z.boolean().default(false), const liveConfigSchema = z
event_viewer: z
.object({ .object({
lazy_load: z.boolean().default(true), provider: z.enum(LIVE_PROVIDERS).default(liveConfigDefault.provider),
preload: z.boolean().default(liveConfigDefault.preload),
webrtc: webrtcConfigSchema,
}) })
.optional(), .default(liveConfigDefault);
menu_mode: z.enum(FRIGATE_MENU_MODES).optional().default('hidden-top'),
menu_buttons: z /**
* Menu configuration section.
*/
const menuConfigDefault = {
mode: 'hidden-top' as const,
buttons: {
frigate: true,
live: true,
clips: true,
snapshots: true,
image: false,
download: true,
frigate_ui: true,
fullscreen: true,
},
button_size: '40px',
};
const menuConfigSchema = z
.object({ .object({
frigate: z.boolean().default(true), mode: z.enum(FRIGATE_MENU_MODES).optional().default(menuConfigDefault.mode),
live: z.boolean().default(true), buttons: z
clips: z.boolean().default(true), .object({
snapshots: z.boolean().default(true), frigate: z.boolean().default(menuConfigDefault.buttons.frigate),
image: z.boolean().default(false), live: z.boolean().default(menuConfigDefault.buttons.live),
download: z.boolean().default(true), clips: z.boolean().default(menuConfigDefault.buttons.clips),
frigate_ui: z.boolean().default(true), snapshots: z.boolean().default(menuConfigDefault.buttons.snapshots),
fullscreen: z.boolean().default(true), image: z.boolean().default(menuConfigDefault.buttons.image),
download: z.boolean().default(menuConfigDefault.buttons.download),
frigate_ui: z.boolean().default(menuConfigDefault.buttons.frigate_ui),
fullscreen: z.boolean().default(menuConfigDefault.buttons.fullscreen),
}) })
.optional(), .default(menuConfigDefault.buttons),
menu_button_size: z.string().default('40px'), button_size: z.string().default(menuConfigDefault.button_size),
update_entities: z.string().array().optional(), })
elements: pictureElementsSchema, .default(menuConfigDefault);
export type MenuConfig = z.infer<typeof menuConfigSchema>;
/**
* Event viewer configuration section (clip, snapshot, clip-specific, snapshot-specific).
*/
const viewerConfigDefault = {
autoplay_clip: false,
lazy_load: true,
controls: {
next_previous: {
size: '48px',
style: 'thumbnails' as const,
},
},
};
const nextPreviousControlConfigSchema = z
.object({
style: z
.enum(NEXT_PREVIOUS_CONTROL_STYLES)
.default(viewerConfigDefault.controls.next_previous.style),
size: z.string().default(viewerConfigDefault.controls.next_previous.size),
})
.default(viewerConfigDefault.controls.next_previous);
export type NextPreviousControlConfig = z.infer<typeof nextPreviousControlConfigSchema>;
const viewerConfigSchema = z
.object({
autoplay_clip: z.boolean().default(viewerConfigDefault.autoplay_clip),
lazy_load: z.boolean().default(viewerConfigDefault.lazy_load),
controls: z controls: z
.object({ .object({
nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'), next_previous: nextPreviousControlConfigSchema,
nextprev_size: z.string().default('48px'),
}) })
.optional(), .default(viewerConfigDefault.controls),
dimensions: z })
.default(viewerConfigDefault);
export type ViewerConfig = z.infer<typeof viewerConfigSchema>;
/**
* Dimensions configuration section.
*/
const dimensionsConfigDefault = {
aspect_ratio_mode: 'dynamic' as const,
aspect_ratio: [16, 9],
};
const dimensionsConfigSchema = z
.object({ .object({
aspect_ratio_mode: z aspect_ratio_mode: z
.enum(['dynamic', 'static', 'unconstrained']) .enum(['dynamic', 'static', 'unconstrained'])
.default('dynamic'), .default(dimensionsConfigDefault.aspect_ratio_mode),
aspect_ratio: z aspect_ratio: z
.number() .number()
.array() .array()
@@ -342,17 +436,43 @@ export const frigateCardConfigSchema = z.object({
.regex(/^\s*\d+\s*[:\/]\s*\d+\s*$/) .regex(/^\s*\d+\s*[:\/]\s*\d+\s*$/)
.transform((input) => input.split(/[:\/]/).map((d) => Number(d))), .transform((input) => input.split(/[:\/]/).map((d) => Number(d))),
) )
.default([16, 9]), .default(dimensionsConfigDefault.aspect_ratio),
}) })
.optional(), .default(dimensionsConfigDefault);
/**
* Main card config.
*/
export const frigateCardConfigSchema = z.object({
camera_entity: z.string().optional(),
// Main configuration sections.
frigate: frigateConfigDefaultSchema,
view: viewConfigSchema,
menu: menuConfigSchema,
live: liveConfigSchema,
event_viewer: viewerConfigSchema,
image: imageConfigSchema,
elements: pictureElementsSchema,
dimensions: dimensionsConfigSchema,
// Entities that should trigger a card update.
update_entities: z.string().array().optional(),
// Stock lovelace card config. // Stock lovelace card config.
type: z.string(), type: z.string(),
show_warning: z.boolean().optional(),
show_error: z.boolean().optional(),
test_gui: z.boolean().optional(), test_gui: z.boolean().optional(),
}); });
export type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>; export type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>;
export type RawFrigateCardConfig = Record<string, unknown>;
export const frigateCardConfigDefaults = {
frigate: frigateConfigDefault,
view: viewConfigDefault,
menu: menuConfigDefault,
live: liveConfigDefault,
event_viewer: viewerConfigDefault,
};
// Schema for card (non-user configured) menu icons. // Schema for card (non-user configured) menu icons.
const internalMenuIconSchema = z.object({ const internalMenuIconSchema = z.object({
@@ -404,8 +524,8 @@ export interface Message {
} }
export interface MenuInteraction { export interface MenuInteraction {
interaction: "hold" | "tap" | "double_tap", interaction: 'hold' | 'tap' | 'double_tap';
button: MenuButton, button: MenuButton;
} }
/** /**