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 |
| - | - | - |
| `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.|
| `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.|
@@ -889,7 +890,7 @@ The following table describes the behavior these 3 flags have.
### 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` | `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
snapshot respectively) and having the card automatically refresh (to fetch a
newer clip/snapshot) when an entity state changes. A Frigate `camera_entity`
is generally not sufficient for this since the Home Assistant state for
Frigate camera entities does not change often. Instead, use the Frigate
newer clip/snapshot) when an entity state changes. Use the Frigate
binary_sensor for that camera (or any other entity at your discretion) to
trigger the update:
```yaml
+1
View File
@@ -17,6 +17,7 @@
"dependencies": {
"@cycjimmy/jsmpeg-player": "^5.0.1",
"@material/image-list": "^12.0.0",
"@material/mwc-menu": "^0.25.3",
"@material/rtl": "^13.0.0",
"custom-card-helpers": "^1.8.0",
"dayjs": "^1.10.7",
+214 -92
View File
@@ -1,3 +1,5 @@
// TODO change url to frigate_url?
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
CSSResultGroup,
@@ -28,6 +30,7 @@ import {
entitySchema,
frigateCardConfigSchema,
Actions,
CameraConfig,
} from './types.js';
import type {
BrowseMediaQueryParameters,
@@ -131,7 +134,7 @@ export class FrigateCard extends LitElement {
protected _interactionTimerID: number | null = null;
@property({ attribute: false })
protected _view: View = new View();
protected _view?: View;
@state()
protected _conditionState?: ConditionState;
@@ -155,13 +158,8 @@ export class FrigateCard extends LitElement {
// Array of dynamic menu buttons to be added to menu.
protected _dynamicMenuButtons: MenuButton[] = [];
// The frigate camera name to use (may be manually specified or automatically
// derived).
// 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;
@state()
protected _cameras?: Map<string, CameraConfig>;
// Error/info message to render.
protected _message: Message | null = null;
@@ -208,8 +206,15 @@ export class FrigateCard extends LitElement {
): FrigateCardConfig {
const cameraEntity = entities.find((element) => element.startsWith('camera.'));
return {
camera_entity: cameraEntity,
} as FrigateCardConfig;
frigate: {
camera: {
camera_entity: cameraEntity,
},
},
// 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) {
buttons.push(
this._getFrigateCardMenuButton({
tap_action: 'live',
title: localize('config.view.views.live'),
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',
title: localize('config.view.views.clips'),
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',
title: localize('config.view.views.snapshots'),
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',
title: localize('config.view.views.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(
this._getFrigateCardMenuButton({
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(
this._getFrigateCardMenuButton({
tap_action: 'frigate_ui',
@@ -369,49 +394,104 @@ 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.
*/
protected async _getFrigateCameraName(): Promise<string | null> {
// No camera name specified, apply two heuristics in this order:
// - Get the entity information and pull out the camera name from the unique_id.
// - Apply basic entity name guesswork.
if (!this._hass || !this.config) {
protected async _getFrigateCameraNameFromEntity(
entity: string,
): Promise<string | null> {
if (!this._hass) {
return null;
}
// Option 1: Name specified in config -> done!
if (this.config.frigate.camera_name) {
return this.config.frigate.camera_name;
// Find entity unique_id in registry.
const request = {
type: 'config/entity_registry/get',
entity_id: entity,
};
try {
const entityResult = await homeAssistantWSRequest<Entity>(
this._hass,
entitySchema,
request,
);
if (entityResult && entityResult.platform == 'frigate') {
const match = entityResult.unique_id.match(/:camera:(?<camera>[^:]+)$/);
if (match && match.groups) {
return match.groups['camera'];
}
}
} catch (e: any) {
// Pass.
}
if (this.config.camera_entity) {
// Option 2: Find entity unique_id in registry.
const request = {
type: 'config/entity_registry/get',
entity_id: this.config.camera_entity,
};
try {
const entityResult = await homeAssistantWSRequest<Entity>(
this._hass,
entitySchema,
request,
);
if (entityResult && entityResult.platform == 'frigate') {
const match = entityResult.unique_id.match(/:camera:(?<camera>[^:]+)$/);
if (match && match.groups) {
return match.groups['camera'];
}
}
} catch (e: any) {
// Pass.
}
// Option 3: Guess from the entity_id.
if (this.config.camera_entity.includes('.')) {
return this.config.camera_entity.split('.', 2)[1];
}
// Fallback: Guess from the entity_id.
if (entity.includes('.')) {
return entity.split('.', 2)[1];
}
return null;
@@ -510,13 +590,11 @@ export class FrigateCard extends LitElement {
getLovelace().setEditMode(true);
}
this._frigateCameraName = undefined;
this.config = config;
this._cameras = undefined;
this._view = undefined;
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 update force is enabled, start a timer right away.
this._resetInteractionTimer();
@@ -528,7 +606,17 @@ export class FrigateCard extends LitElement {
this._message = null;
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 {
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
// Assistant update if there's been recent interaction (e.g. clicks on the
// 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 shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor);
@@ -577,7 +668,7 @@ export class FrigateCard extends LitElement {
* Download media being displayed in the viewer.
*/
protected async _downloadViewerMedia(): Promise<void> {
if (!this._hass || !this._view.isViewerView()) {
if (!this._hass || !this._view?.isViewerView()) {
// Should not occur.
return;
}
@@ -598,8 +689,13 @@ export class FrigateCard extends LitElement {
return;
}
const cameraConfig = this._getSelectedCameraConfig();
if (!cameraConfig) {
return;
}
const path =
`/api/frigate/${this.config.frigate.client_id}` +
`/api/frigate/${cameraConfig.client_id}` +
`/notifications/${event_id}/` +
`${this._view.isClipRelatedView() ? 'clip.mp4' : 'snapshot.jpg'}` +
`?download=true`;
@@ -618,7 +714,10 @@ export class FrigateCard extends LitElement {
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
// new browser window.
//
@@ -662,7 +761,14 @@ export class FrigateCard extends LitElement {
case 'live':
case 'snapshot':
case 'snapshots':
this._changeView(new View({ view: action }));
if (this._view) {
this._changeView(
new View({
view: action,
camera: this._view.camera,
}),
);
}
break;
case 'download':
this._downloadViewerMedia();
@@ -678,6 +784,23 @@ export class FrigateCard extends LitElement {
screenfull.toggle(this);
}
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:
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.
*/
protected _getFrigateURLFromContext(): string | null {
if (!this.config.frigate.url) {
const cameraConfig = this._getSelectedCameraConfig();
if (!cameraConfig || !cameraConfig.url || !this._view) {
return null;
}
if (!this._frigateCameraName) {
return this.config.frigate.url;
} else if (this._view.is('live')) {
return `${this.config.frigate.url}/cameras/${this._frigateCameraName}`;
if (this._view.isViewerView() || this._view.isGalleryView()) {
return `${cameraConfig.url}/events?camera=${cameraConfig.camera_name}`;
}
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(
mediaType?: 'clips' | 'snapshots',
): BrowseMediaQueryParameters | undefined {
const cameraConfig = this._getSelectedCameraConfig();
if (
!this._frigateCameraName ||
!cameraConfig ||
!cameraConfig.camera_name ||
!this._view ||
!(
this._view.isClipRelatedView() ||
this._view.isSnapshotRelatedView() ||
@@ -781,10 +907,10 @@ export class FrigateCard extends LitElement {
}
return {
mediaType: mediaType || (this._view.isClipRelatedView() ? 'clips' : 'snapshots'),
clientId: this.config.frigate.client_id,
cameraName: this._frigateCameraName,
label: this.config.frigate.label,
zone: this.config.frigate.zone,
clientId: cameraConfig.client_id,
cameraName: cameraConfig.camera_name,
label: cameraConfig.label,
zone: cameraConfig.zone,
};
}
@@ -838,7 +964,7 @@ export class FrigateCard extends LitElement {
}
let requestRefresh = false;
if (
this._view.isGalleryView() &&
this._view?.isGalleryView() &&
(mediaShowInfo.width != this._mediaShowInfo?.width ||
mediaShowInfo.height != this._mediaShowInfo?.height)
) {
@@ -897,7 +1023,7 @@ export class FrigateCard extends LitElement {
return !(
(screenfull.isEnabled && screenfull.isFullscreen) ||
aspectRatioMode == 'unconstrained' ||
(aspectRatioMode == 'dynamic' && this._view.isMediaView())
(aspectRatioMode == 'dynamic' && this._view?.isMediaView())
);
}
@@ -931,13 +1057,13 @@ export class FrigateCard extends LitElement {
protected _getMergedActions(): Actions {
let specificActions: Actions | undefined = undefined;
if (this._view.is('live')) {
if (this._view?.is('live')) {
specificActions = this.config.live.actions;
} else if (this._view.isGalleryView()) {
} else if (this._view?.isGalleryView()) {
specificActions = this.config.event_gallery?.actions;
} else if (this._view.isViewerView()) {
} else if (this._view?.isViewerView()) {
specificActions = this.config.event_viewer.actions;
} else if (this._view.is('image')) {
} else if (this._view?.is('image')) {
specificActions = this.config.image?.actions;
}
return { ...this.config.view.actions, ...specificActions };
@@ -973,10 +1099,11 @@ export class FrigateCard extends LitElement {
${this.config.menu.mode == 'above' ? this._renderMenu() : ''}
<div class="container outer" style="${styleMap(outerStyle)}">
<div class="${classMap(contentClasses)}">
${this._frigateCameraName == undefined
${this._cameras === undefined
? until(
(async () => {
this._frigateCameraName = await this._getFrigateCameraName();
await this._loadCameras();
this._changeView();
return this._render();
})(),
renderProgressIndicator(),
@@ -992,18 +1119,11 @@ export class FrigateCard extends LitElement {
* Sub-render method for the card.
*/
protected _render(): TemplateResult | void {
if (!this._hass) {
const cameraConfig = this._getSelectedCameraConfig();
if (!this._hass || !this._view || !cameraConfig) {
return html``;
}
if (!this._frigateCameraName) {
this._setMessageAndUpdate(
{
message: localize('error.no_frigate_camera_name'),
type: 'error',
},
true,
);
}
const pictureElementsClasses = {
'picture-elements': true,
@@ -1068,10 +1188,12 @@ export class FrigateCard extends LitElement {
? html`
<frigate-card-live
.hass=${this._hass}
.view=${this._view}
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters(
this.config.live.controls.thumbnails.media,
)}
.config=${this.config}
.liveConfig=${this.config.live}
.cameraConfig=${cameraConfig}
.preload=${this.config.live.preload && !this._view.is('live')}
class="${classMap(liveClasses)}"
@frigate-card:change-view=${this._changeViewHandler}
+15 -2
View File
@@ -7,6 +7,7 @@ import { localize } from './localize/localize.js';
import {
ActionType,
ExtendedHomeAssistant,
FrigateCardAction,
FrigateCardCustomAction,
frigateCardCustomActionSchema,
MediaShowInfo,
@@ -283,11 +284,23 @@ export function convertActionToFrigateCardCustomAction(
* @param action The Frigate card action string (e.g. 'fullscreen')
* @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 {
action: 'fire-dom-event',
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) {
new View({
view: this.view.view,
camera: this.view.camera,
target: child,
previous: this.view ?? undefined,
}).dispatchChangeEvent(this);
@@ -215,6 +216,7 @@ export class FrigateCardGalleryCore extends LitElement {
view: this.view.is('clips')
? 'clip-specific'
: 'snapshot-specific',
camera: this.view.camera,
target: this.view.target ?? undefined,
childIndex: index,
previous: this.view ?? undefined,
+40 -37
View File
@@ -3,8 +3,9 @@ import type {
BrowseMediaQueryParameters,
BrowseMediaSource,
ExtendedHomeAssistant,
FrigateCardConfig,
CameraConfig,
JSMPEGConfig,
LiveConfig,
MediaShowInfo,
WebRTCConfig,
} from '../types.js';
@@ -46,7 +47,13 @@ export class FrigateCardLive extends LitElement {
protected hass?: HomeAssistant & ExtendedHomeAssistant;
@property({ attribute: false })
protected config?: FrigateCardConfig;
protected view?: View;
@property({ attribute: false })
protected cameraConfig?: CameraConfig;
@property({ attribute: false })
protected liveConfig?: LiveConfig;
@property({ attribute: false })
protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
@@ -85,7 +92,7 @@ export class FrigateCardLive extends LitElement {
* @returns A rendered template or void.
*/
protected renderThumbnails(): TemplateResult | void {
if (!this.config) {
if (!this.liveConfig || !this.view) {
return;
}
@@ -106,13 +113,14 @@ export class FrigateCardLive extends LitElement {
if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) {
return html` <frigate-card-thumbnail-carousel
.target=${parent}
.config=${this.config?.live.controls.thumbnails}
.config=${this.liveConfig?.controls.thumbnails}
.highlightSelected=${false}
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
const mediaType = this.browseMediaQueryParameters?.mediaType;
if (mediaType && ['snapshots', 'clips'].includes(mediaType)) {
if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) {
new View({
view: mediaType === 'clips' ? 'clip-specific' : 'snapshot-specific',
camera: this.view.camera,
target: ev.detail.target,
childIndex: ev.detail.childIndex,
}).dispatchChangeEvent(this);
@@ -131,37 +139,37 @@ export class FrigateCardLive extends LitElement {
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this.hass || !this.config) {
if (!this.hass || !this.liveConfig || !this.cameraConfig) {
return;
}
return html`
${this.config.live.controls.thumbnails.mode === 'above'
${this.liveConfig.controls.thumbnails.mode === 'above'
? this.renderThumbnails()
: ''}
${this.config.live.provider == 'frigate'
${this.liveConfig.provider == 'frigate'
? html` <frigate-card-live-frigate
.hass=${this.hass}
.cameraEntity=${this.config.camera_entity}
.cameraEntity=${this.cameraConfig.camera_entity}
@frigate-card:media-show=${this._mediaShowHandler}
>
</frigate-card-live-frigate>`
: this.config.live.provider == 'webrtc'
: this.liveConfig.provider == 'webrtc'
? html`<frigate-card-live-webrtc
.hass=${this.hass}
.webRTCConfig=${this.config.live.webrtc || {}}
.webRTCConfig=${this.liveConfig.webrtc || {}}
@frigate-card:media-show=${this._mediaShowHandler}
>
</frigate-card-live-webrtc>`
: html` <frigate-card-live-jsmpeg
.hass=${this.hass}
.cameraName=${this.browseMediaQueryParameters?.cameraName}
.clientId=${this.config.frigate.client_id}
.jsmpegConfig=${this.config.live.jsmpeg}
.cameraName=${this.cameraConfig.camera_name}
.clientId=${this.cameraConfig.client_id}
.jsmpegConfig=${this.liveConfig.jsmpeg}
@frigate-card:media-show=${this._mediaShowHandler}
>
</frigate-card-live-jsmpeg>`}
${this.config.live.controls.thumbnails.mode === 'below'
${this.liveConfig.controls.thumbnails.mode === 'below'
? this.renderThumbnails()
: ''}
`;
@@ -320,10 +328,10 @@ export class FrigateCardLiveJSMPEG extends LitElement {
@property({ attribute: false })
protected jsmpegConfig?: JSMPEGConfig;
@property({ attribute: false })
protected hass?: HomeAssistant & ExtendedHomeAssistant;
protected _jsmpegCanvasElement?: HTMLCanvasElement;
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
protected _jsmpegURL?: string | null;
protected _refreshPlayerTimerID?: number;
/**
@@ -356,7 +364,7 @@ export class FrigateCardLiveJSMPEG extends LitElement {
* Create a JSMPEG player.
* @returns A JSMPEG player.
*/
protected _createJSMPEGPlayer(): JSMpeg.VideoElement {
protected _createJSMPEGPlayer(url: string): JSMpeg.VideoElement {
let videoDecoded = false;
const jsmpegOptions = {
@@ -380,7 +388,7 @@ export class FrigateCardLiveJSMPEG extends LitElement {
return new JSMpeg.VideoElement(
this,
this._jsmpegURL,
url,
{
canvas: this._jsmpegCanvasElement,
hooks: {
@@ -416,7 +424,6 @@ export class FrigateCardLiveJSMPEG extends LitElement {
this._jsmpegCanvasElement.remove();
this._jsmpegCanvasElement = undefined;
}
this._jsmpegURL = undefined;
}
/**
@@ -448,35 +455,31 @@ export class FrigateCardLiveJSMPEG extends LitElement {
this._jsmpegCanvasElement = document.createElement('canvas');
this._jsmpegCanvasElement.className = 'media';
this._jsmpegURL = await this._getURL();
if (this._jsmpegURL) {
this._jsmpegVideoPlayer = this._createJSMPEGPlayer();
const url = await this._getURL();
if (url) {
this._jsmpegVideoPlayer = this._createJSMPEGPlayer(url);
this._refreshPlayerTimerID = window.setTimeout(() => {
this._refreshPlayer();
this.requestUpdate();
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
} else {
dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
}
this.requestUpdate();
}
/**
* Master render method.
*/
protected render(): TemplateResult | void {
if (
this._jsmpegURL === undefined ||
!this._jsmpegVideoPlayer ||
!this._jsmpegCanvasElement
) {
return html`${until(this._refreshPlayer(), renderProgressIndicator())}`;
const _render = async (): Promise<TemplateResult | void> => {
await this._refreshPlayer();
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player'));
}
return html`${this._jsmpegCanvasElement}`;
}
if (!this._jsmpegURL) {
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
}
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player'));
}
return html`${this._jsmpegCanvasElement}`;
return html`${until(_render(), renderProgressIndicator())}`;
}
/**
+9
View File
@@ -29,6 +29,7 @@ import {
import menuStyle from '../scss/menu.scss';
import { ConditionState, evaluateCondition } from '../card-condition.js';
import { Corner } from '@material/mwc-menu';
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
@@ -115,7 +116,15 @@ export class FrigateCardMenu extends LitElement {
*/
protected _renderButton(button: MenuButton): TemplateResult | void {
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
.corner=${corner}
.hass=${this.hass}
.submenu=${button}
@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 submenuStyle from '../scss/submenu.scss';
import type { Corner } from "@material/mwc-menu";
@customElement('frigate-card-submenu')
export class FrigateCardSubmenu extends LitElement {
@@ -17,6 +18,9 @@ export class FrigateCardSubmenu extends LitElement {
@property({ attribute: false })
public submenu?: MenuSubmenu;
@property({ attribute: false })
public corner?: Corner;
protected _renderItem(item: MenuSubmenuItem): TemplateResult | void {
if (!this.hass) {
return;
@@ -56,7 +60,9 @@ export class FrigateCardSubmenu extends LitElement {
}
return html`
<ha-button-menu corner="BOTTOM_LEFT">
<ha-button-menu
corner=${this.corner || "BOTTOM_LEFT"}
>
<ha-icon-button
style="${styleMap(this.submenu.style || {})}"
class="button"
+1
View File
@@ -411,6 +411,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel {
if (clipStartTime && clipStartTime === snapshotStartTime) {
return new View({
view: 'clip-specific',
camera: this.view.camera,
target: clips,
childIndex: i,
previous: this.view,
+4 -2
View File
@@ -87,7 +87,8 @@
"frigate": "Frigate menu / Default view",
"frigate_ui": "Frigate user Interface",
"fullscreen": "Fullscreen",
"download": "Download event media"
"download": "Download event media",
"cameras": "Camera selection"
},
"mode": "Menu mode",
"modes": {
@@ -152,7 +153,8 @@
"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",
"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",
"invalid_elements_config": "Invalid picture elements configuration",
"jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path",
-2
View File
@@ -1,7 +1,5 @@
ha-icon-button.button {
z-index: 10;
color: var(--secondary-color, white);
opacity: 0.8;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
padding: 0px;
+7 -2
View File
@@ -4,10 +4,15 @@
--frigate-card-menu-button-size: 40px;
--mdc-icon-button-size: var(--frigate-card-menu-button-size);
--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 {
z-index: 1;
/* Menu div itself does not handle click events. Without this, in overlay
mode, the menu div prevents clicking on gallery items 'behind' the overlay.
*/
+4 -1
View File
@@ -1,6 +1,9 @@
@use './button.scss';
:host {
z-index: 20;
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'),
}),
);
export const frigateCardCustomActionSchema = customActionSchema.merge(
const frigateCardCustomActionBaseSchema = customActionSchema.merge(
z.object({
// Syntactic sugar to avoid 'fire-dom-event' as part of an external API.
action: z
.literal('custom:frigate-card-action')
.transform((): 'fire-dom-event' => '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>;
const actionSchema = z.union([
@@ -322,19 +352,28 @@ export type PictureElements = z.infer<typeof pictureElementsSchema>;
/**
* Frigate configuration section.
*/
const frigateConfigDefault = {
export const cameraConfigDefault = {
client_id: 'frigate' as const,
};
const frigateConfigDefaultSchema = z
const cameraConfigDefaultSchema = z
.object({
// No URL validation to allow relative URLs within HA (e.g. addons).
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(),
label: 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.
@@ -457,6 +496,7 @@ const liveConfigSchema = z
})
.merge(actionsSchema)
.default(liveConfigDefault);
export type LiveConfig = z.infer<typeof liveConfigSchema>;
/**
* Menu configuration section.
@@ -465,6 +505,7 @@ const menuConfigDefault = {
mode: 'hidden-top' as const,
buttons: {
frigate: true,
cameras: true,
live: true,
clips: true,
snapshots: true,
@@ -481,6 +522,7 @@ const menuConfigSchema = z
buttons: z
.object({
frigate: z.boolean().default(menuConfigDefault.buttons.frigate),
cameras: z.boolean().default(menuConfigDefault.buttons.cameras),
live: z.boolean().default(menuConfigDefault.buttons.live),
clips: z.boolean().default(menuConfigDefault.buttons.clips),
snapshots: z.boolean().default(menuConfigDefault.buttons.snapshots),
@@ -577,10 +619,8 @@ const dimensionsConfigSchema = z
* Main card config.
*/
export const frigateCardConfigSchema = z.object({
camera_entity: z.string().optional(),
// Main configuration sections.
frigate: frigateConfigDefaultSchema,
camera: cameraConfigDefaultSchema.or(cameraConfigDefaultSchema.array().nonempty()),
view: viewConfigSchema,
menu: menuConfigSchema,
live: liveConfigSchema,
@@ -598,7 +638,7 @@ export type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>;
export type RawFrigateCardConfig = Record<string, unknown>;
export const frigateCardConfigDefaults = {
frigate: frigateConfigDefault,
cameras: cameraConfigDefault,
view: viewConfigDefault,
menu: menuConfigDefault,
live: liveConfigDefault,
@@ -628,9 +668,8 @@ export interface BrowseMediaQueryParameters {
export interface GetFrigateCardMenuButtonParameters {
icon: string;
title: string;
tap_action: string;
hold_action?: string;
tap_action: FrigateCardAction;
hold_action?: FrigateCardAction;
emphasize?: boolean;
}
+6 -3
View File
@@ -2,7 +2,8 @@ import type { BrowseMediaSource, FrigateCardView } from './types.js';
import { dispatchFrigateCardEvent } from './common.js';
export interface ViewParameters {
view?: FrigateCardView;
view: FrigateCardView;
camera: string;
target?: BrowseMediaSource;
childIndex?: number;
previous?: View;
@@ -10,12 +11,14 @@ export interface ViewParameters {
export class View {
view: FrigateCardView;
camera: string;
target?: BrowseMediaSource;
childIndex?: number;
previous?: View;
constructor(params?: ViewParameters) {
this.view = params?.view || 'live';
constructor(params: ViewParameters) {
this.view = params?.view;
this.camera = params?.camera;
this.target = params?.target;
this.childIndex = params?.childIndex;
this.previous = params?.previous;