Initial skeleton of multiple camera support.

This commit is contained in:
Dermot Duffy
2022-01-14 21:31:16 -08:00
parent 3b31699265
commit ca581c5078
15 changed files with 366 additions and 160 deletions
+3 -4
View File
@@ -139,6 +139,7 @@ menu:
| Option | Default | Description | | Option | Default | Description |
| - | - | - | | - | - | - |
| `frigate` | `true` | Whether to show the `Frigate` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.mode` is `hidden-*` . | | `frigate` | `true` | Whether to show the `Frigate` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.mode` is `hidden-*` . |
| `cameras` | `true` | Whether to show the camera selection submenu. Will only appear if multiple cameras are configured. |
| `live` | `true` | Whether to show the `live` view menu button: brings the user to the `live` view. See [views](#views) below.| | `live` | `true` | Whether to show the `live` view menu button: brings the user to the `live` view. See [views](#views) below.|
| `clips` | `true` | Whether to show the `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. See [views](#views) below.| | `clips` | `true` | Whether to show the `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. See [views](#views) below.|
| `snapshots` | `true` | Whether to show the `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. See [views](#views) below.| | `snapshots` | `true` | Whether to show the `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. See [views](#views) below.|
@@ -889,7 +890,7 @@ The following table describes the behavior these 3 flags have.
### Card Update Truth Table ### Card Update Truth Table
| `view.timeout` | `view.update_force` | `view.update_entities` & `camera_entity` | Behavior | | `view.timeout` | `view.update_force` | `view.update_entities` | Behavior |
| :-: | :-: | :-: | - | | :-: | :-: | :-: | - |
| Unset or `0` | *(Any value)* | Unset | Card will not automatically re-render. | | Unset or `0` | *(Any value)* | Unset | Card will not automatically re-render. |
| Unset or `0` | `false` | *(Any entity)* | Card will reload **current** view when entity state changes, unless media is playing. | | Unset or `0` | `false` | *(Any entity)* | Card will reload **current** view when entity state changes, unless media is playing. |
@@ -910,9 +911,7 @@ view:
``` ```
* Using `clip` or `snapshot` as the default view (for the most recent clip or * Using `clip` or `snapshot` as the default view (for the most recent clip or
snapshot respectively) and having the card automatically refresh (to fetch a snapshot respectively) and having the card automatically refresh (to fetch a
newer clip/snapshot) when an entity state changes. A Frigate `camera_entity` newer clip/snapshot) when an entity state changes. Use the Frigate
is generally not sufficient for this since the Home Assistant state for
Frigate camera entities does not change often. Instead, use the Frigate
binary_sensor for that camera (or any other entity at your discretion) to binary_sensor for that camera (or any other entity at your discretion) to
trigger the update: trigger the update:
```yaml ```yaml
+1
View File
@@ -17,6 +17,7 @@
"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",
"@material/mwc-menu": "^0.25.3",
"@material/rtl": "^13.0.0", "@material/rtl": "^13.0.0",
"custom-card-helpers": "^1.8.0", "custom-card-helpers": "^1.8.0",
"dayjs": "^1.10.7", "dayjs": "^1.10.7",
+196 -74
View File
@@ -1,3 +1,5 @@
// TODO change url to frigate_url?
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { import {
CSSResultGroup, CSSResultGroup,
@@ -28,6 +30,7 @@ import {
entitySchema, entitySchema,
frigateCardConfigSchema, frigateCardConfigSchema,
Actions, Actions,
CameraConfig,
} from './types.js'; } from './types.js';
import type { import type {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
@@ -131,7 +134,7 @@ export class FrigateCard extends LitElement {
protected _interactionTimerID: number | null = null; protected _interactionTimerID: number | null = null;
@property({ attribute: false }) @property({ attribute: false })
protected _view: View = new View(); protected _view?: View;
@state() @state()
protected _conditionState?: ConditionState; protected _conditionState?: ConditionState;
@@ -155,13 +158,8 @@ export class FrigateCard extends LitElement {
// Array of dynamic menu buttons to be added to menu. // Array of dynamic menu buttons to be added to menu.
protected _dynamicMenuButtons: MenuButton[] = []; protected _dynamicMenuButtons: MenuButton[] = [];
// The frigate camera name to use (may be manually specified or automatically @state()
// derived). protected _cameras?: Map<string, CameraConfig>;
// Values:
// - string: Camera name on the Frigate backend.
// - null: Attempted to find name, but failed.
// - undefined: Have not yet attempted to find name.
protected _frigateCameraName?: string | null;
// Error/info message to render. // Error/info message to render.
protected _message: Message | null = null; protected _message: Message | null = null;
@@ -208,8 +206,15 @@ export class FrigateCard extends LitElement {
): FrigateCardConfig { ): FrigateCardConfig {
const cameraEntity = entities.find((element) => element.startsWith('camera.')); const cameraEntity = entities.find((element) => element.startsWith('camera.'));
return { return {
frigate: {
camera: {
camera_entity: cameraEntity, camera_entity: cameraEntity,
} as FrigateCardConfig; },
},
// Need to use 'as unknown' to convince Typescript that this really isn't a
// mistake, despite the miniscule size of the configuration vs the full type
// description.
} as unknown as FrigateCardConfig;
} }
/** /**
@@ -273,13 +278,31 @@ export class FrigateCard extends LitElement {
}), }),
); );
} }
if (this.config.menu.buttons.cameras && this._cameras && this._cameras.size > 1) {
const menuItems = Array.from(this._cameras, ([camera, config]) => ({
icon: config.icon || 'mdi:cctv',
entity: config.camera_entity,
state_color: true,
title: config.title,
tap_action: createFrigateCardCustomAction('camera_select', camera),
}));
buttons.push({
type: 'custom:frigate-card-menu-submenu',
title: localize('config.menu.buttons.cameras'),
icon: 'mdi:camera-switch',
items: menuItems,
});
}
if (this.config.menu.buttons.live) { if (this.config.menu.buttons.live) {
buttons.push( buttons.push(
this._getFrigateCardMenuButton({ this._getFrigateCardMenuButton({
tap_action: 'live', tap_action: 'live',
title: localize('config.view.views.live'), title: localize('config.view.views.live'),
icon: 'mdi:cctv', icon: 'mdi:cctv',
emphasize: this._view.is('live'), emphasize: this._view?.is('live'),
}), }),
); );
} }
@@ -291,7 +314,7 @@ export class FrigateCard extends LitElement {
hold_action: 'clip', hold_action: 'clip',
title: localize('config.view.views.clips'), title: localize('config.view.views.clips'),
icon: 'mdi:filmstrip', icon: 'mdi:filmstrip',
emphasize: this._view.is('clips'), emphasize: this._view?.is('clips'),
}), }),
); );
} }
@@ -302,7 +325,7 @@ export class FrigateCard extends LitElement {
hold_action: 'snapshot', hold_action: 'snapshot',
title: localize('config.view.views.snapshots'), title: localize('config.view.views.snapshots'),
icon: 'mdi:camera', icon: 'mdi:camera',
emphasize: this._view.is('snapshots'), emphasize: this._view?.is('snapshots'),
}), }),
); );
} }
@@ -312,11 +335,11 @@ export class FrigateCard extends LitElement {
tap_action: 'image', tap_action: 'image',
title: localize('config.view.views.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.config.menu.buttons.download && this._view.isViewerView()) { if (this.config.menu.buttons.download && this._view?.isViewerView()) {
buttons.push( buttons.push(
this._getFrigateCardMenuButton({ this._getFrigateCardMenuButton({
tap_action: 'download', tap_action: 'download',
@@ -325,7 +348,9 @@ export class FrigateCard extends LitElement {
}), }),
); );
} }
if (this.config.menu.buttons.frigate_ui && this.config.frigate.url) {
const cameraConfig = this._getSelectedCameraConfig();
if (this.config.menu.buttons.frigate_ui && cameraConfig && cameraConfig.url) {
buttons.push( buttons.push(
this._getFrigateCardMenuButton({ this._getFrigateCardMenuButton({
tap_action: 'frigate_ui', tap_action: 'frigate_ui',
@@ -369,28 +394,84 @@ export class FrigateCard extends LitElement {
} }
/** /**
* Get the Frigate camera name through a variety of means. * Fully load the configured cameras.
*/
protected async _loadCameras(): Promise<void> {
const cameras: Map<string, CameraConfig> = new Map();
const addCameraConfig = async (config: CameraConfig) => {
if (!config.camera_name && config.camera_entity) {
const resolvedName = await this._getFrigateCameraNameFromEntity(
config.camera_entity,
);
if (resolvedName) {
config.camera_name = resolvedName;
}
}
if (config.camera_name) {
const id = config.id || config.camera_name;
if (cameras.has(id)) {
this._setMessageAndUpdate(
{
message: localize('error.duplicate_frigate_camera_name'),
type: 'error',
},
true,
);
} else {
cameras.set(config.id || config.camera_name, config);
}
}
};
if (this.config.camera) {
if (Array.isArray(this.config.camera)) {
await Promise.all(this.config.camera.map(addCameraConfig.bind(this)));
} else {
await addCameraConfig(this.config.camera);
}
}
if (!cameras.size) {
return this._setMessageAndUpdate(
{
message: localize('error.no_cameras'),
type: 'error',
},
true,
);
}
this._cameras = cameras;
}
/**
* Get the camera configuration for the selected camera.
* @returns The CameraConfig object or null if not found.
*/
protected _getSelectedCameraConfig(): CameraConfig | null {
if (!this._cameras || !this._cameras.size || !this._view?.camera) {
return null;
}
return this._cameras.get(this._view.camera) || null;
}
/**
* Get the Frigate camera name from an entity name.
* @returns The Frigate camera name or null if unavailable. * @returns The Frigate camera name or null if unavailable.
*/ */
protected async _getFrigateCameraName(): Promise<string | null> { protected async _getFrigateCameraNameFromEntity(
// No camera name specified, apply two heuristics in this order: entity: string,
// - Get the entity information and pull out the camera name from the unique_id. ): Promise<string | null> {
// - Apply basic entity name guesswork. if (!this._hass) {
if (!this._hass || !this.config) {
return null; return null;
} }
// Option 1: Name specified in config -> done! // Find entity unique_id in registry.
if (this.config.frigate.camera_name) {
return this.config.frigate.camera_name;
}
if (this.config.camera_entity) {
// Option 2: Find entity unique_id in registry.
const request = { const request = {
type: 'config/entity_registry/get', type: 'config/entity_registry/get',
entity_id: this.config.camera_entity, entity_id: entity,
}; };
try { try {
const entityResult = await homeAssistantWSRequest<Entity>( const entityResult = await homeAssistantWSRequest<Entity>(
@@ -408,10 +489,9 @@ export class FrigateCard extends LitElement {
// Pass. // Pass.
} }
// Option 3: Guess from the entity_id. // Fallback: Guess from the entity_id.
if (this.config.camera_entity.includes('.')) { if (entity.includes('.')) {
return this.config.camera_entity.split('.', 2)[1]; return entity.split('.', 2)[1];
}
} }
return null; return null;
@@ -510,13 +590,11 @@ export class FrigateCard extends LitElement {
getLovelace().setEditMode(true); getLovelace().setEditMode(true);
} }
this._frigateCameraName = undefined;
this.config = config; this.config = config;
this._cameras = undefined;
this._view = undefined;
this._entitiesToMonitor = this.config.view.update_entities || []; this._entitiesToMonitor = this.config.view.update_entities || [];
if (this.config.camera_entity) {
this._entitiesToMonitor.push(this.config.camera_entity);
}
if (this.config.view.update_force) { if (this.config.view.update_force) {
// If update force is enabled, start a timer right away. // If update force is enabled, start a timer right away.
this._resetInteractionTimer(); this._resetInteractionTimer();
@@ -528,7 +606,17 @@ 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 }); let camera = this._view?.camera;
if (!camera && this._cameras?.size) {
camera = this._cameras.keys().next().value;
}
if (camera) {
this._view = new View({
view: this.config.view.default,
camera: camera,
});
}
} else { } else {
this._view = view; this._view = view;
} }
@@ -565,7 +653,10 @@ export class FrigateCard extends LitElement {
// are browsing the mini-gallery). Do not allow re-rendering from a Home // are browsing the mini-gallery). Do not allow re-rendering from a Home
// Assistant update if there's been recent interaction (e.g. clicks on the // Assistant update if there's been recent interaction (e.g. clicks on the
// card) or if there is media active playing. // card) or if there is media active playing.
if (!this.config.view.update_force && (this._interactionTimerID || this._mediaPlaying)) { if (
!this.config.view.update_force &&
(this._interactionTimerID || this._mediaPlaying)
) {
return false; return false;
} }
return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor); return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor);
@@ -577,7 +668,7 @@ export class FrigateCard extends LitElement {
* Download media being displayed in the viewer. * Download media being displayed in the viewer.
*/ */
protected async _downloadViewerMedia(): Promise<void> { protected async _downloadViewerMedia(): Promise<void> {
if (!this._hass || !this._view.isViewerView()) { if (!this._hass || !this._view?.isViewerView()) {
// Should not occur. // Should not occur.
return; return;
} }
@@ -598,8 +689,13 @@ export class FrigateCard extends LitElement {
return; return;
} }
const cameraConfig = this._getSelectedCameraConfig();
if (!cameraConfig) {
return;
}
const path = const path =
`/api/frigate/${this.config.frigate.client_id}` + `/api/frigate/${cameraConfig.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`;
@@ -618,7 +714,10 @@ export class FrigateCard extends LitElement {
return; return;
} }
if (navigator.userAgent.startsWith("Home Assistant/") || navigator.userAgent.startsWith("HomeAssistant/")) { if (
navigator.userAgent.startsWith('Home Assistant/') ||
navigator.userAgent.startsWith('HomeAssistant/')
) {
// Home Assistant companion apps cannot download files without opening a // Home Assistant companion apps cannot download files without opening a
// new browser window. // new browser window.
// //
@@ -662,7 +761,14 @@ export class FrigateCard extends LitElement {
case 'live': case 'live':
case 'snapshot': case 'snapshot':
case 'snapshots': case 'snapshots':
this._changeView(new View({ view: action })); if (this._view) {
this._changeView(
new View({
view: action,
camera: this._view.camera,
}),
);
}
break; break;
case 'download': case 'download':
this._downloadViewerMedia(); this._downloadViewerMedia();
@@ -678,6 +784,23 @@ export class FrigateCard extends LitElement {
screenfull.toggle(this); screenfull.toggle(this);
} }
break; break;
case 'camera_select':
const camera = frigateCardAction.camera;
if (this._cameras?.has(camera) && this._view) {
this._changeView(
new View({
view: this._view.view,
camera: camera,
}),
);
}
break;
// case 'next_camera':
// this._changeCamera({ next: true });
// break;
// case 'previous_camera':
// this._changeCamera({ previous: true });
// break;
default: default:
console.warn(`Frigate card received unknown card action: ${action}`); console.warn(`Frigate card received unknown card action: ${action}`);
} }
@@ -688,15 +811,14 @@ 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) { const cameraConfig = this._getSelectedCameraConfig();
if (!cameraConfig || !cameraConfig.url || !this._view) {
return null; return null;
} }
if (!this._frigateCameraName) { if (this._view.isViewerView() || this._view.isGalleryView()) {
return this.config.frigate.url; return `${cameraConfig.url}/events?camera=${cameraConfig.camera_name}`;
} else if (this._view.is('live')) {
return `${this.config.frigate.url}/cameras/${this._frigateCameraName}`;
} }
return `${this.config.frigate.url}/events?camera=${this._frigateCameraName}`; return `${cameraConfig.url}/cameras/${cameraConfig.camera_name}`;
} }
/** /**
@@ -769,8 +891,12 @@ export class FrigateCard extends LitElement {
protected _getBrowseMediaQueryParameters( protected _getBrowseMediaQueryParameters(
mediaType?: 'clips' | 'snapshots', mediaType?: 'clips' | 'snapshots',
): BrowseMediaQueryParameters | undefined { ): BrowseMediaQueryParameters | undefined {
const cameraConfig = this._getSelectedCameraConfig();
if ( if (
!this._frigateCameraName || !cameraConfig ||
!cameraConfig.camera_name ||
!this._view ||
!( !(
this._view.isClipRelatedView() || this._view.isClipRelatedView() ||
this._view.isSnapshotRelatedView() || this._view.isSnapshotRelatedView() ||
@@ -781,10 +907,10 @@ export class FrigateCard extends LitElement {
} }
return { return {
mediaType: mediaType || (this._view.isClipRelatedView() ? 'clips' : 'snapshots'), mediaType: mediaType || (this._view.isClipRelatedView() ? 'clips' : 'snapshots'),
clientId: this.config.frigate.client_id, clientId: cameraConfig.client_id,
cameraName: this._frigateCameraName, cameraName: cameraConfig.camera_name,
label: this.config.frigate.label, label: cameraConfig.label,
zone: this.config.frigate.zone, zone: cameraConfig.zone,
}; };
} }
@@ -838,7 +964,7 @@ export class FrigateCard extends LitElement {
} }
let requestRefresh = false; let requestRefresh = false;
if ( if (
this._view.isGalleryView() && this._view?.isGalleryView() &&
(mediaShowInfo.width != this._mediaShowInfo?.width || (mediaShowInfo.width != this._mediaShowInfo?.width ||
mediaShowInfo.height != this._mediaShowInfo?.height) mediaShowInfo.height != this._mediaShowInfo?.height)
) { ) {
@@ -897,7 +1023,7 @@ export class FrigateCard extends LitElement {
return !( return !(
(screenfull.isEnabled && screenfull.isFullscreen) || (screenfull.isEnabled && screenfull.isFullscreen) ||
aspectRatioMode == 'unconstrained' || aspectRatioMode == 'unconstrained' ||
(aspectRatioMode == 'dynamic' && this._view.isMediaView()) (aspectRatioMode == 'dynamic' && this._view?.isMediaView())
); );
} }
@@ -931,13 +1057,13 @@ export class FrigateCard extends LitElement {
protected _getMergedActions(): Actions { protected _getMergedActions(): Actions {
let specificActions: Actions | undefined = undefined; let specificActions: Actions | undefined = undefined;
if (this._view.is('live')) { if (this._view?.is('live')) {
specificActions = this.config.live.actions; specificActions = this.config.live.actions;
} else if (this._view.isGalleryView()) { } else if (this._view?.isGalleryView()) {
specificActions = this.config.event_gallery?.actions; specificActions = this.config.event_gallery?.actions;
} else if (this._view.isViewerView()) { } else if (this._view?.isViewerView()) {
specificActions = this.config.event_viewer.actions; specificActions = this.config.event_viewer.actions;
} else if (this._view.is('image')) { } else if (this._view?.is('image')) {
specificActions = this.config.image?.actions; specificActions = this.config.image?.actions;
} }
return { ...this.config.view.actions, ...specificActions }; return { ...this.config.view.actions, ...specificActions };
@@ -973,10 +1099,11 @@ 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)}"> <div class="${classMap(contentClasses)}">
${this._frigateCameraName == undefined ${this._cameras === undefined
? until( ? until(
(async () => { (async () => {
this._frigateCameraName = await this._getFrigateCameraName(); await this._loadCameras();
this._changeView();
return this._render(); return this._render();
})(), })(),
renderProgressIndicator(), renderProgressIndicator(),
@@ -992,18 +1119,11 @@ export class FrigateCard extends LitElement {
* Sub-render method for the card. * Sub-render method for the card.
*/ */
protected _render(): TemplateResult | void { protected _render(): TemplateResult | void {
if (!this._hass) { const cameraConfig = this._getSelectedCameraConfig();
if (!this._hass || !this._view || !cameraConfig) {
return html``; return html``;
} }
if (!this._frigateCameraName) {
this._setMessageAndUpdate(
{
message: localize('error.no_frigate_camera_name'),
type: 'error',
},
true,
);
}
const pictureElementsClasses = { const pictureElementsClasses = {
'picture-elements': true, 'picture-elements': true,
@@ -1068,10 +1188,12 @@ export class FrigateCard extends LitElement {
? html` ? html`
<frigate-card-live <frigate-card-live
.hass=${this._hass} .hass=${this._hass}
.view=${this._view}
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters( .browseMediaQueryParameters=${this._getBrowseMediaQueryParameters(
this.config.live.controls.thumbnails.media, this.config.live.controls.thumbnails.media,
)} )}
.config=${this.config} .liveConfig=${this.config.live}
.cameraConfig=${cameraConfig}
.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:change-view=${this._changeViewHandler} @frigate-card:change-view=${this._changeViewHandler}
+15 -2
View File
@@ -7,6 +7,7 @@ import { localize } from './localize/localize.js';
import { import {
ActionType, ActionType,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateCardAction,
FrigateCardCustomAction, FrigateCardCustomAction,
frigateCardCustomActionSchema, frigateCardCustomActionSchema,
MediaShowInfo, MediaShowInfo,
@@ -283,11 +284,23 @@ export function convertActionToFrigateCardCustomAction(
* @param action The Frigate card action string (e.g. 'fullscreen') * @param action The Frigate card action string (e.g. 'fullscreen')
* @returns A FrigateCardCustomAction for that action string. * @returns A FrigateCardCustomAction for that action string.
*/ */
export function createFrigateCardCustomAction(action: string): FrigateCardCustomAction { export function createFrigateCardCustomAction(
action: FrigateCardAction,
camera?: string): FrigateCardCustomAction | undefined {
if (action == 'camera_select') {
if (!camera) {
return undefined;
}
return { return {
action: 'fire-dom-event', action: 'fire-dom-event',
frigate_card_action: action, frigate_card_action: action,
}; camera: camera,
}
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
}
} }
/** /**
+2
View File
@@ -189,6 +189,7 @@ export class FrigateCardGalleryCore extends LitElement {
if (this.view) { if (this.view) {
new View({ new View({
view: this.view.view, view: this.view.view,
camera: this.view.camera,
target: child, target: child,
previous: this.view ?? undefined, previous: this.view ?? undefined,
}).dispatchChangeEvent(this); }).dispatchChangeEvent(this);
@@ -215,6 +216,7 @@ export class FrigateCardGalleryCore extends LitElement {
view: this.view.is('clips') view: this.view.is('clips')
? 'clip-specific' ? 'clip-specific'
: 'snapshot-specific', : 'snapshot-specific',
camera: this.view.camera,
target: this.view.target ?? undefined, target: this.view.target ?? undefined,
childIndex: index, childIndex: index,
previous: this.view ?? undefined, previous: this.view ?? undefined,
+38 -35
View File
@@ -3,8 +3,9 @@ import type {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
BrowseMediaSource, BrowseMediaSource,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateCardConfig, CameraConfig,
JSMPEGConfig, JSMPEGConfig,
LiveConfig,
MediaShowInfo, MediaShowInfo,
WebRTCConfig, WebRTCConfig,
} from '../types.js'; } from '../types.js';
@@ -46,7 +47,13 @@ export class FrigateCardLive extends LitElement {
protected hass?: HomeAssistant & ExtendedHomeAssistant; protected hass?: HomeAssistant & ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
protected config?: FrigateCardConfig; protected view?: View;
@property({ attribute: false })
protected cameraConfig?: CameraConfig;
@property({ attribute: false })
protected liveConfig?: LiveConfig;
@property({ attribute: false }) @property({ attribute: false })
protected browseMediaQueryParameters?: BrowseMediaQueryParameters; protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
@@ -85,7 +92,7 @@ export class FrigateCardLive extends LitElement {
* @returns A rendered template or void. * @returns A rendered template or void.
*/ */
protected renderThumbnails(): TemplateResult | void { protected renderThumbnails(): TemplateResult | void {
if (!this.config) { if (!this.liveConfig || !this.view) {
return; return;
} }
@@ -106,13 +113,14 @@ export class FrigateCardLive extends LitElement {
if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) { if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) {
return html` <frigate-card-thumbnail-carousel return html` <frigate-card-thumbnail-carousel
.target=${parent} .target=${parent}
.config=${this.config?.live.controls.thumbnails} .config=${this.liveConfig?.controls.thumbnails}
.highlightSelected=${false} .highlightSelected=${false}
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => { @frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
const mediaType = this.browseMediaQueryParameters?.mediaType; const mediaType = this.browseMediaQueryParameters?.mediaType;
if (mediaType && ['snapshots', 'clips'].includes(mediaType)) { if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) {
new View({ new View({
view: mediaType === 'clips' ? 'clip-specific' : 'snapshot-specific', view: mediaType === 'clips' ? 'clip-specific' : 'snapshot-specific',
camera: this.view.camera,
target: ev.detail.target, target: ev.detail.target,
childIndex: ev.detail.childIndex, childIndex: ev.detail.childIndex,
}).dispatchChangeEvent(this); }).dispatchChangeEvent(this);
@@ -131,37 +139,37 @@ export class FrigateCardLive extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.config) { if (!this.hass || !this.liveConfig || !this.cameraConfig) {
return; return;
} }
return html` return html`
${this.config.live.controls.thumbnails.mode === 'above' ${this.liveConfig.controls.thumbnails.mode === 'above'
? this.renderThumbnails() ? this.renderThumbnails()
: ''} : ''}
${this.config.live.provider == 'frigate' ${this.liveConfig.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.cameraConfig.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.liveConfig.provider == 'webrtc'
? html`<frigate-card-live-webrtc ? html`<frigate-card-live-webrtc
.hass=${this.hass} .hass=${this.hass}
.webRTCConfig=${this.config.live.webrtc || {}} .webRTCConfig=${this.liveConfig.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.browseMediaQueryParameters?.cameraName} .cameraName=${this.cameraConfig.camera_name}
.clientId=${this.config.frigate.client_id} .clientId=${this.cameraConfig.client_id}
.jsmpegConfig=${this.config.live.jsmpeg} .jsmpegConfig=${this.liveConfig.jsmpeg}
@frigate-card:media-show=${this._mediaShowHandler} @frigate-card:media-show=${this._mediaShowHandler}
> >
</frigate-card-live-jsmpeg>`} </frigate-card-live-jsmpeg>`}
${this.config.live.controls.thumbnails.mode === 'below' ${this.liveConfig.controls.thumbnails.mode === 'below'
? this.renderThumbnails() ? this.renderThumbnails()
: ''} : ''}
`; `;
@@ -320,10 +328,10 @@ export class FrigateCardLiveJSMPEG extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected jsmpegConfig?: JSMPEGConfig; protected jsmpegConfig?: JSMPEGConfig;
@property({ attribute: false })
protected hass?: HomeAssistant & ExtendedHomeAssistant; protected hass?: HomeAssistant & ExtendedHomeAssistant;
protected _jsmpegCanvasElement?: HTMLCanvasElement; protected _jsmpegCanvasElement?: HTMLCanvasElement;
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
protected _jsmpegURL?: string | null;
protected _refreshPlayerTimerID?: number; protected _refreshPlayerTimerID?: number;
/** /**
@@ -356,7 +364,7 @@ export class FrigateCardLiveJSMPEG extends LitElement {
* Create a JSMPEG player. * Create a JSMPEG player.
* @returns A JSMPEG player. * @returns A JSMPEG player.
*/ */
protected _createJSMPEGPlayer(): JSMpeg.VideoElement { protected _createJSMPEGPlayer(url: string): JSMpeg.VideoElement {
let videoDecoded = false; let videoDecoded = false;
const jsmpegOptions = { const jsmpegOptions = {
@@ -380,7 +388,7 @@ export class FrigateCardLiveJSMPEG extends LitElement {
return new JSMpeg.VideoElement( return new JSMpeg.VideoElement(
this, this,
this._jsmpegURL, url,
{ {
canvas: this._jsmpegCanvasElement, canvas: this._jsmpegCanvasElement,
hooks: { hooks: {
@@ -416,7 +424,6 @@ export class FrigateCardLiveJSMPEG extends LitElement {
this._jsmpegCanvasElement.remove(); this._jsmpegCanvasElement.remove();
this._jsmpegCanvasElement = undefined; this._jsmpegCanvasElement = undefined;
} }
this._jsmpegURL = undefined;
} }
/** /**
@@ -448,36 +455,32 @@ export class FrigateCardLiveJSMPEG extends LitElement {
this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement = document.createElement('canvas');
this._jsmpegCanvasElement.className = 'media'; this._jsmpegCanvasElement.className = 'media';
this._jsmpegURL = await this._getURL(); const url = await this._getURL();
if (this._jsmpegURL) { if (url) {
this._jsmpegVideoPlayer = this._createJSMPEGPlayer(); this._jsmpegVideoPlayer = this._createJSMPEGPlayer(url);
this._refreshPlayerTimerID = window.setTimeout(() => { this._refreshPlayerTimerID = window.setTimeout(() => {
this._refreshPlayer();
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
}
this.requestUpdate(); this.requestUpdate();
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
} else {
dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
}
} }
/** /**
* Master render method. * Master render method.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if ( const _render = async (): Promise<TemplateResult | void> => {
this._jsmpegURL === undefined || await this._refreshPlayer();
!this._jsmpegVideoPlayer ||
!this._jsmpegCanvasElement
) {
return html`${until(this._refreshPlayer(), renderProgressIndicator())}`;
}
if (!this._jsmpegURL) {
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
}
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player'));
} }
return html`${this._jsmpegCanvasElement}`; return html`${this._jsmpegCanvasElement}`;
} }
return html`${until(_render(), renderProgressIndicator())}`;
}
/** /**
* Get styles. * Get styles.
+9
View File
@@ -29,6 +29,7 @@ import {
import menuStyle from '../scss/menu.scss'; import menuStyle from '../scss/menu.scss';
import { ConditionState, evaluateCondition } from '../card-condition.js'; import { ConditionState, evaluateCondition } from '../card-condition.js';
import { Corner } from '@material/mwc-menu';
export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
@@ -115,7 +116,15 @@ export class FrigateCardMenu extends LitElement {
*/ */
protected _renderButton(button: MenuButton): TemplateResult | void { protected _renderButton(button: MenuButton): TemplateResult | void {
if (button.type == 'custom:frigate-card-menu-submenu') { if (button.type == 'custom:frigate-card-menu-submenu') {
let corner: Corner | undefined;
if (this._menuConfig?.mode.endsWith("-left")) {
// Minor nicety: Start the menu to the right of the menu itself is on
// the left, otherwise use the default.
corner = "BOTTOM_RIGHT";
}
return html` <frigate-card-submenu return html` <frigate-card-submenu
.corner=${corner}
.hass=${this.hass} .hass=${this.hass}
.submenu=${button} .submenu=${button}
@action=${this._actionHandler.bind(this)} @action=${this._actionHandler.bind(this)}
+7 -1
View File
@@ -8,6 +8,7 @@ import { actionHandler } from '../action-handler-directive.js';
import { refreshDynamicStateParameters } from '../common.js'; import { refreshDynamicStateParameters } from '../common.js';
import submenuStyle from '../scss/submenu.scss'; import submenuStyle from '../scss/submenu.scss';
import type { Corner } from "@material/mwc-menu";
@customElement('frigate-card-submenu') @customElement('frigate-card-submenu')
export class FrigateCardSubmenu extends LitElement { export class FrigateCardSubmenu extends LitElement {
@@ -17,6 +18,9 @@ export class FrigateCardSubmenu extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public submenu?: MenuSubmenu; public submenu?: MenuSubmenu;
@property({ attribute: false })
public corner?: Corner;
protected _renderItem(item: MenuSubmenuItem): TemplateResult | void { protected _renderItem(item: MenuSubmenuItem): TemplateResult | void {
if (!this.hass) { if (!this.hass) {
return; return;
@@ -56,7 +60,9 @@ export class FrigateCardSubmenu extends LitElement {
} }
return html` return html`
<ha-button-menu corner="BOTTOM_LEFT"> <ha-button-menu
corner=${this.corner || "BOTTOM_LEFT"}
>
<ha-icon-button <ha-icon-button
style="${styleMap(this.submenu.style || {})}" style="${styleMap(this.submenu.style || {})}"
class="button" class="button"
+1
View File
@@ -411,6 +411,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
if (clipStartTime && clipStartTime === snapshotStartTime) { if (clipStartTime && clipStartTime === snapshotStartTime) {
return new View({ return new View({
view: 'clip-specific', view: 'clip-specific',
camera: this.view.camera,
target: clips, target: clips,
childIndex: i, childIndex: i,
previous: this.view, previous: this.view,
+4 -2
View File
@@ -87,7 +87,8 @@
"frigate": "Frigate menu / Default view", "frigate": "Frigate menu / Default view",
"frigate_ui": "Frigate user Interface", "frigate_ui": "Frigate user Interface",
"fullscreen": "Fullscreen", "fullscreen": "Fullscreen",
"download": "Download event media" "download": "Download event media",
"cameras": "Camera selection"
}, },
"mode": "Menu mode", "mode": "Menu mode",
"modes": { "modes": {
@@ -152,7 +153,8 @@
"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", "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_cameras": "No cameras found, you must configure at least one camera configured with a camera_entity or camera_name",
"duplicate_frigate_camera_name": "Duplicate Frigate camera name, use the 'id' parameter to uniquely identify them",
"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",
-2
View File
@@ -1,7 +1,5 @@
ha-icon-button.button { ha-icon-button.button {
z-index: 10;
color: var(--secondary-color, white); color: var(--secondary-color, white);
opacity: 0.8;
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%; border-radius: 50%;
padding: 0px; padding: 0px;
+6 -1
View File
@@ -4,10 +4,15 @@
--frigate-card-menu-button-size: 40px; --frigate-card-menu-button-size: 40px;
--mdc-icon-button-size: var(--frigate-card-menu-button-size); --mdc-icon-button-size: var(--frigate-card-menu-button-size);
--mdc-icon-size: calc(var(--mdc-icon-button-size) / 2); --mdc-icon-size: calc(var(--mdc-icon-button-size) / 2);
z-index: 10;
opacity: 0.9;
// Necessary to mitigate an apparent Chrome opacity flickering bug caused by
// button ripples.
will-change: opacity;
} }
.frigate-card-menu { .frigate-card-menu {
z-index: 1;
/* Menu div itself does not handle click events. Without this, in overlay /* Menu div itself does not handle click events. Without this, in overlay
mode, the menu div prevents clicking on gallery items 'behind' the overlay. mode, the menu div prevents clicking on gallery items 'behind' the overlay.
*/ */
+4 -1
View File
@@ -1,6 +1,9 @@
@use './button.scss'; @use './button.scss';
:host { :host {
z-index: 20;
pointer-events: auto; pointer-events: auto;
} }
mwc-list-item {
z-index: 20;
}
+52 -13
View File
@@ -106,16 +106,46 @@ const customActionSchema = schemaForType<CustomActionConfig>()(
action: z.literal('fire-dom-event'), action: z.literal('fire-dom-event'),
}), }),
); );
export const frigateCardCustomActionSchema = customActionSchema.merge( const frigateCardCustomActionBaseSchema = customActionSchema.merge(
z.object({ z.object({
// Syntactic sugar to avoid 'fire-dom-event' as part of an external API. // Syntactic sugar to avoid 'fire-dom-event' as part of an external API.
action: z action: z
.literal('custom:frigate-card-action') .literal('custom:frigate-card-action')
.transform((): 'fire-dom-event' => 'fire-dom-event') .transform((): 'fire-dom-event' => 'fire-dom-event')
.or(z.literal('fire-dom-event')), .or(z.literal('fire-dom-event')),
frigate_card_action: z.string(),
}), }),
); );
const FRIGATE_CARD_GENERAL_ACTIONS = [
'frigate',
'clip',
'clips',
'image',
'live',
'snapshot',
'snapshots',
'download',
'frigate_ui',
'fullscreen',
] as const;
const FRIGATE_CARD_ACTIONS = [...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select'] as const;
export type FrigateCardAction = typeof FRIGATE_CARD_ACTIONS[number];
const frigateCardGeneralActionSchema = frigateCardCustomActionBaseSchema.merge(
z.object({
frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS),
}),
);
const frigateCardCameraSelectActionSchema = frigateCardCustomActionBaseSchema.merge(
z.object({
frigate_card_action: z.literal('camera_select'),
camera: z.string(),
}),
);
export const frigateCardCustomActionSchema = z.union([
frigateCardGeneralActionSchema,
frigateCardCameraSelectActionSchema,
]);
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>; export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
const actionSchema = z.union([ const actionSchema = z.union([
@@ -322,19 +352,28 @@ export type PictureElements = z.infer<typeof pictureElementsSchema>;
/** /**
* Frigate configuration section. * Frigate configuration section.
*/ */
const frigateConfigDefault = { export const cameraConfigDefault = {
client_id: 'frigate' as const, client_id: 'frigate' as const,
}; };
const frigateConfigDefaultSchema = z const cameraConfigDefaultSchema = z
.object({ .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).
url: z.string().optional(), url: z.string().optional(),
client_id: z.string().optional().default(frigateConfigDefault.client_id), client_id: z.string().optional().default(cameraConfigDefault.client_id),
camera_name: z.string().optional(), camera_name: z.string().optional(),
label: z.string().optional(), label: z.string().optional(),
zone: z.string().optional(), zone: z.string().optional(),
camera_entity: z.string().optional(),
// Used for presentation in the UI (autodetected from the entity if
// specified).
icon: z.string().optional(),
title: z.string().optional(),
id: z.string().optional(),
}) })
.default(frigateConfigDefault); .default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigDefaultSchema>;
/** /**
* View configuration section. * View configuration section.
@@ -457,6 +496,7 @@ const liveConfigSchema = z
}) })
.merge(actionsSchema) .merge(actionsSchema)
.default(liveConfigDefault); .default(liveConfigDefault);
export type LiveConfig = z.infer<typeof liveConfigSchema>;
/** /**
* Menu configuration section. * Menu configuration section.
@@ -465,6 +505,7 @@ const menuConfigDefault = {
mode: 'hidden-top' as const, mode: 'hidden-top' as const,
buttons: { buttons: {
frigate: true, frigate: true,
cameras: true,
live: true, live: true,
clips: true, clips: true,
snapshots: true, snapshots: true,
@@ -481,6 +522,7 @@ const menuConfigSchema = z
buttons: z buttons: z
.object({ .object({
frigate: z.boolean().default(menuConfigDefault.buttons.frigate), frigate: z.boolean().default(menuConfigDefault.buttons.frigate),
cameras: z.boolean().default(menuConfigDefault.buttons.cameras),
live: z.boolean().default(menuConfigDefault.buttons.live), live: z.boolean().default(menuConfigDefault.buttons.live),
clips: z.boolean().default(menuConfigDefault.buttons.clips), clips: z.boolean().default(menuConfigDefault.buttons.clips),
snapshots: z.boolean().default(menuConfigDefault.buttons.snapshots), snapshots: z.boolean().default(menuConfigDefault.buttons.snapshots),
@@ -577,10 +619,8 @@ const dimensionsConfigSchema = z
* Main card config. * Main card config.
*/ */
export const frigateCardConfigSchema = z.object({ export const frigateCardConfigSchema = z.object({
camera_entity: z.string().optional(),
// Main configuration sections. // Main configuration sections.
frigate: frigateConfigDefaultSchema, camera: cameraConfigDefaultSchema.or(cameraConfigDefaultSchema.array().nonempty()),
view: viewConfigSchema, view: viewConfigSchema,
menu: menuConfigSchema, menu: menuConfigSchema,
live: liveConfigSchema, live: liveConfigSchema,
@@ -598,7 +638,7 @@ export type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>;
export type RawFrigateCardConfig = Record<string, unknown>; export type RawFrigateCardConfig = Record<string, unknown>;
export const frigateCardConfigDefaults = { export const frigateCardConfigDefaults = {
frigate: frigateConfigDefault, cameras: cameraConfigDefault,
view: viewConfigDefault, view: viewConfigDefault,
menu: menuConfigDefault, menu: menuConfigDefault,
live: liveConfigDefault, live: liveConfigDefault,
@@ -628,9 +668,8 @@ export interface BrowseMediaQueryParameters {
export interface GetFrigateCardMenuButtonParameters { export interface GetFrigateCardMenuButtonParameters {
icon: string; icon: string;
title: string; title: string;
tap_action: string; tap_action: FrigateCardAction;
hold_action?: FrigateCardAction;
hold_action?: string;
emphasize?: boolean; emphasize?: boolean;
} }
+6 -3
View File
@@ -2,7 +2,8 @@ import type { BrowseMediaSource, FrigateCardView } from './types.js';
import { dispatchFrigateCardEvent } from './common.js'; import { dispatchFrigateCardEvent } from './common.js';
export interface ViewParameters { export interface ViewParameters {
view?: FrigateCardView; view: FrigateCardView;
camera: string;
target?: BrowseMediaSource; target?: BrowseMediaSource;
childIndex?: number; childIndex?: number;
previous?: View; previous?: View;
@@ -10,12 +11,14 @@ export interface ViewParameters {
export class View { export class View {
view: FrigateCardView; view: FrigateCardView;
camera: string;
target?: BrowseMediaSource; target?: BrowseMediaSource;
childIndex?: number; childIndex?: number;
previous?: View; previous?: View;
constructor(params?: ViewParameters) { constructor(params: ViewParameters) {
this.view = params?.view || 'live'; this.view = params?.view;
this.camera = params?.camera;
this.target = params?.target; this.target = params?.target;
this.childIndex = params?.childIndex; this.childIndex = params?.childIndex;
this.previous = params?.previous; this.previous = params?.previous;