Initial skeleton of multiple camera support.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+196
-74
@@ -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 {
|
||||
frigate: {
|
||||
camera: {
|
||||
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) {
|
||||
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,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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
if (this.config.camera_entity) {
|
||||
// Option 2: Find entity unique_id in registry.
|
||||
// Find entity unique_id in registry.
|
||||
const request = {
|
||||
type: 'config/entity_registry/get',
|
||||
entity_id: this.config.camera_entity,
|
||||
entity_id: entity,
|
||||
};
|
||||
try {
|
||||
const entityResult = await homeAssistantWSRequest<Entity>(
|
||||
@@ -408,10 +489,9 @@ export class FrigateCard extends LitElement {
|
||||
// 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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
+38
-35
@@ -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,36 +455,32 @@ 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();
|
||||
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
|
||||
} else {
|
||||
dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (
|
||||
this._jsmpegURL === undefined ||
|
||||
!this._jsmpegVideoPlayer ||
|
||||
!this._jsmpegCanvasElement
|
||||
) {
|
||||
return html`${until(this._refreshPlayer(), renderProgressIndicator())}`;
|
||||
}
|
||||
if (!this._jsmpegURL) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
return html`${until(_render(), renderProgressIndicator())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
@use './button.scss';
|
||||
|
||||
:host {
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
mwc-list-item {
|
||||
z-index: 20;
|
||||
}
|
||||
+52
-13
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user