Add picture elements support.

This commit is contained in:
Dermot Duffy
2021-10-07 21:16:16 -07:00
parent 639e2cddfd
commit 49455ab741
14 changed files with 438 additions and 120 deletions
+24 -8
View File
@@ -209,21 +209,25 @@ entities:
### Picture Elements / Menu customizations ### Picture Elements / Menu customizations
This card supports a subset of the [Picture Elements This card supports the [Picture Elements configuration
configuration](https://www.home-assistant.io/lovelace/picture-elements/) to syntax](https://www.home-assistant.io/lovelace/picture-elements/) to seamlessly
allow the user to add custom elements to the card, which may be configured to allow the user to add custom elements to the card, which may be configured to
perform different actions on `tap`, `double_tap` and `hold`. perform a variety of actions on `tap`, `double_tap` and `hold`.
In the card YAML configuration, elements may be manually added under an `elements` key. In the card YAML configuration, elements may be manually added under an `elements` key.
#### Supported Elements #### Special Elements
This card supports all [Picture Elements](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) using the same syntax. The card also supports two special elements to add plain icons and state-based icons to the Frigate card menu.
| Element name | Description | | Element name | Description |
| ------------- | --------------------------------------------- | | ------------- | --------------------------------------------- |
| `menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is identical to that of the [Picture elements icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element).| | `menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture elements icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element).|
| `menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is identical to that of the [Picture elements state icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon).| | `menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture elements state icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon).|
#### Example See the [action documentation](https://www.home-assistant.io/lovelace/actions/#hold-action) for more information on the action options available.
#### Elements Example
Add an icon that represents the state of the `light.office_main_lights` entity, that shows more information on single click (the default action) and toggles the light on double click. Add an icon that represents the state of the `light.office_main_lights` entity, that shows more information on single click (the default action) and toggles the light on double click.
@@ -245,7 +249,19 @@ Add an icon that navigates the brower to the releases page for this card:
url_path: https://github.com/dermotduffy/frigate-hass-card/releases url_path: https://github.com/dermotduffy/frigate-hass-card/releases
``` ```
See the [action documentation](https://www.home-assistant.io/lovelace/actions/#hold-action) for more information on the action options available. Add a state badge showing the temperature but hide the label text:
```yaml
- type: state-badge
entity: sensor.kitchen_temperature
style:
right: '-20px'
top: 100px
color: rgba(0,0,0,0)
opacity: 0.5
```
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/picture_elements_temperature.png" alt="Picture elements temperature example" width="400px">
<a name="views"></a> <a name="views"></a>
Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

+68 -14
View File
@@ -16,11 +16,10 @@ import {
LovelaceCardEditor, LovelaceCardEditor,
getLovelace, getLovelace,
handleAction, handleAction,
ActionHandlerEvent,
} from 'custom-card-helpers'; } from 'custom-card-helpers';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { entitySchema, frigateCardConfigSchema } from './types'; import { entitySchema, frigateCardConfigSchema, Message } from './types';
import type { import type {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
Entity, Entity,
@@ -33,16 +32,16 @@ import type {
import { CARD_VERSION } from './const'; import { CARD_VERSION } from './const';
import { FrigateCardMenu, MENU_HEIGHT } from './components/menu'; import { FrigateCardMenu, MENU_HEIGHT } from './components/menu';
import { View } from './view'; import { View } from './view';
import { actionHandler } from './action-handler-directive';
import { import {
getParseErrorKeys, getParseErrorKeys,
homeAssistantWSRequest, homeAssistantWSRequest,
shouldUpdateBasedOnHass, shouldUpdateBasedOnHass,
} from './common'; } from './common';
import { localize } from './localize/localize'; import { localize } from './localize/localize';
import { renderErrorMessage, renderProgressIndicator } from './components/message'; import { renderMessage, renderProgressIndicator } from './components/message';
import './editor'; import './editor';
import './components/elements';
import './components/gallery'; import './components/gallery';
import './components/live'; import './components/live';
import './components/menu'; import './components/menu';
@@ -52,6 +51,7 @@ import './patches/ha-camera-stream';
import './patches/ha-hls-player'; import './patches/ha-hls-player';
import cardStyle from './scss/card.scss'; import cardStyle from './scss/card.scss';
import { FrigateCardElements } from './components/elements';
const MEDIA_HEIGHT_CUTOFF = 50; const MEDIA_HEIGHT_CUTOFF = 50;
const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF; const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF;
@@ -105,6 +105,9 @@ export class FrigateCard extends LitElement {
@query('frigate-card-menu') @query('frigate-card-menu')
_menu!: FrigateCardMenu; _menu!: FrigateCardMenu;
@query('frigate-card-elements')
_elements!: FrigateCardElements;
// Whether or not media is actively playing (live or clip). // Whether or not media is actively playing (live or clip).
protected _mediaPlaying = false; protected _mediaPlaying = false;
@@ -119,15 +122,23 @@ export class FrigateCard extends LitElement {
// derived). // derived).
protected _frigateCameraName: string | null = null; protected _frigateCameraName: string | null = null;
// Error/info message to render.
protected _message: Message | null = null;
set hass(hass: HomeAssistant & ExtendedHomeAssistant) { set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
this._hass = hass; this._hass = hass;
// Manually set hass in the menu. This is to allow the menu to update, // Manually set hass in the menu & elements. This is to allow these to
// without necessarily re-rendering the entire card (re-rendering interrupts // update, without necessarily re-rendering the entire card (re-rendering
// clip playing). // interrupts clip playing).
if (this._menu && this._hass) { if (this._hass) {
if (this._menu) {
this._menu.hass = this._hass; this._menu.hass = this._hass;
} }
if (this._elements) {
this._elements.hass = this._hass;
}
}
} }
// Get the configuration element. // Get the configuration element.
@@ -197,7 +208,7 @@ export class FrigateCard extends LitElement {
const elements = this.config.elements || []; const elements = this.config.elements || [];
for (let i = 0; this._hass && i < elements.length; i++) { for (let i = 0; this._hass && i < elements.length; i++) {
const element = elements[i]; const element = elements[i];
if (['menu-icon', 'menu-state-icon'].includes(element.type)) { if (element.type == 'menu-icon' || element.type == 'menu-state-icon') {
buttons.push(element); buttons.push(element);
} }
} }
@@ -404,6 +415,27 @@ export class FrigateCard extends LitElement {
this._mediaPlaying = false; this._mediaPlaying = false;
} }
protected _setMessageAndUpdate(message: Message): void {
// Only register the first message.
if (!this._message) {
this._message = message;
this.requestUpdate();
}
}
protected _messageHandler(e: CustomEvent<Message>): void {
return this._setMessageAndUpdate(e.detail);
}
protected _renderAndResetMessage(): TemplateResult | void {
if (this._message) {
const message = this._message;
this._message = null;
return renderMessage(message);
}
return html``;
}
protected _mediaLoadHandler(e: CustomEvent<MediaLoadInfo>): void { protected _mediaLoadHandler(e: CustomEvent<MediaLoadInfo>): void {
const mediaInfo = e.detail; const mediaInfo = e.detail;
// In Safari, with WebRTC, 0x0 is occasionally returned during loading, // In Safari, with WebRTC, 0x0 is occasionally returned during loading,
@@ -527,29 +559,41 @@ export class FrigateCard extends LitElement {
${this.config.menu_mode == 'above' ? this._renderMenu() : ''} ${this.config.menu_mode == 'above' ? this._renderMenu() : ''}
<div class="container outer" style="${styleMap(outerStyle)}"> <div class="container outer" style="${styleMap(outerStyle)}">
<div class="${classMap(contentClasses)}" style="${styleMap(innerStyle)}"> <div class="${classMap(contentClasses)}" style="${styleMap(innerStyle)}">
${until(this._render(), renderProgressIndicator())} ${this._message
? this._renderAndResetMessage()
: until(this._render(), renderProgressIndicator())}
</div> </div>
</div> </div>
${this.config.menu_mode != 'above' ? this._renderMenu() : ''} ${this.config.menu_mode != 'above' ? this._renderMenu() : ''}
</ha-card>`; </ha-card>`;
} }
protected async _render(): Promise<TemplateResult> { protected async _render(): Promise<TemplateResult | void> {
if (!this._frigateCameraName) { if (!this._frigateCameraName) {
this._frigateCameraName = await this._getFrigateCameraName(); this._frigateCameraName = await this._getFrigateCameraName();
} }
const mediaQueryParameters = this._getBrowseMediaQueryParameters(); const mediaQueryParameters = this._getBrowseMediaQueryParameters();
if (!this._frigateCameraName || !mediaQueryParameters) { if (!this._frigateCameraName || !mediaQueryParameters) {
return renderErrorMessage(localize('error.no_frigate_camera_name')); return this._setMessageAndUpdate({
message: localize('error.no_frigate_camera_name'),
type: 'error',
});
} }
const pictureElementsClasses = {
'picture-elements': true,
gallery: this._view.isGalleryView(),
};
return html` return html`
<div class="${classMap(pictureElementsClasses)}">
${this._view.is('clips') || this._view.is('snapshots') ${this._view.is('clips') || this._view.is('snapshots')
? html` <frigate-card-gallery ? html` <frigate-card-gallery
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
.browseMediaQueryParameters=${mediaQueryParameters} .browseMediaQueryParameters=${mediaQueryParameters}
@frigate-card:change-view=${this._changeViewHandler} @frigate-card:change-view=${this._changeViewHandler}
@frigate-card:message=${this._messageHandler}
> >
</frigate-card-gallery>` </frigate-card-gallery>`
: ``} : ``}
@@ -564,20 +608,30 @@ export class FrigateCard extends LitElement {
@frigate-card:media-load=${this._mediaLoadHandler} @frigate-card:media-load=${this._mediaLoadHandler}
@frigate-card:pause=${this._pauseHandler} @frigate-card:pause=${this._pauseHandler}
@frigate-card:play=${this._playHandler} @frigate-card:play=${this._playHandler}
@frigate-card:message=${this._messageHandler}
> >
</frigate-card-viewer>` </frigate-card-viewer>`
: ``} : ``}
${this._view.is('live') ${this._view.is('live')
? html` <frigate-card-live ? html`
<frigate-card-live
.hass=${this._hass} .hass=${this._hass}
.config=${this.config} .config=${this.config}
.frigateCameraName=${this._frigateCameraName} .frigateCameraName=${this._frigateCameraName}
@frigate-card:media-load=${this._mediaLoadHandler} @frigate-card:media-load=${this._mediaLoadHandler}
@frigate-card:pause=${this._pauseHandler} @frigate-card:pause=${this._pauseHandler}
@frigate-card:play=${this._playHandler} @frigate-card:play=${this._playHandler}
@frigate-card:message=${this._messageHandler}
> >
</frigate-card-live>` </frigate-card-live>
`
: ``} : ``}
<frigate-card-elements
.hass=${this._hass}
.pictureElements=${this.config.elements}
>
</frigate-card-elements>
</div>
`; `;
} }
+23
View File
@@ -7,6 +7,7 @@ import type {
BrowseMediaSource, BrowseMediaSource,
ExtendedHomeAssistant, ExtendedHomeAssistant,
MediaLoadInfo, MediaLoadInfo,
Message,
} from './types'; } from './types';
import { browseMediaSourceSchema } from './types'; import { browseMediaSourceSchema } from './types';
@@ -144,6 +145,28 @@ export function dispatchMediaLoadEvent(
} }
} }
export function dispatchMessageEvent(
element: HTMLElement,
message: string,
icon?: string,
): void {
dispatchEvent<Message>(element, 'message', {
message: message,
type: 'info',
icon: icon,
});
}
export function dispatchErrorMessageEvent(
element: HTMLElement,
message: string,
): void {
dispatchEvent<Message>(element, 'message', {
message: message,
type: 'error',
});
}
// Determine whether the card should be updated based on Home Assistant changes. // Determine whether the card should be updated based on Home Assistant changes.
export function shouldUpdateBasedOnHass( export function shouldUpdateBasedOnHass(
newHass: HomeAssistant | null, newHass: HomeAssistant | null,
+103
View File
@@ -0,0 +1,103 @@
import { LitElement, TemplateResult, html, CSSResultGroup, unsafeCSS } from 'lit';
import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property } from 'lit/decorators';
import { ExtendedHomeAssistant, PictureElement, PictureElements } from '../types';
import elementsStyle from '../scss/elements.scss';
@customElement('frigate-card-elements')
export class FrigateCardElements extends LitElement {
@property({ attribute: false })
protected _pictureElements: PictureElements;
protected _hass!: HomeAssistant & ExtendedHomeAssistant;
protected _elements: HTMLElement[] = [];
set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
for (let i = 0; hass && i < this._elements.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this._elements[i] as any).hass = hass;
}
this._hass = hass;
}
set pictureElements(pictureElements: PictureElements) {
if (this._elements.length > 0) {
this._elements.forEach((el: HTMLElement) => {
if (el.parentElement) {
el.parentElement.removeChild(el);
}
});
this._elements = [];
}
if (!pictureElements) {
return;
}
for (let i = 0; i < pictureElements.length && pictureElements[i]; i++) {
const element = this._createPictureElement(pictureElements[i]);
if (element) {
this._elements.push(element);
}
}
}
@property({ attribute: false })
protected _createPictureElement(pictureElement: PictureElement): HTMLElement | null {
let customElementName: string | null = null;
switch (pictureElement.type) {
case 'state-badge':
case 'state-icon':
case 'state-label':
case 'service-button':
case 'icon':
case 'image':
case 'conditional':
customElementName = `hui-${pictureElement.type}-element`;
break;
}
if (!customElementName) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const elementConstructor = customElements.get(customElementName) as any;
if (!elementConstructor) {
return null;
}
const element = new elementConstructor();
element.hass = this._hass;
try {
element.setConfig(pictureElement);
} catch (e) {
console.error(e, (e as Error).stack);
return null;
}
element.classList.add('element');
const targetStyle = pictureElement.style || {};
Object.keys(targetStyle).forEach((prop) => {
element.style.setProperty(prop, targetStyle[prop]);
});
return element;
}
protected render(): TemplateResult {
return html`${this._elements.map((element) => element)}`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(elementsStyle);
}
}
export function renderFrigateCardElements(
hass: HomeAssistant & ExtendedHomeAssistant,
pictureElements: PictureElements,
): TemplateResult {
return html` <frigate-card-elements .hass=${hass} .pictureElements=${pictureElements}>
</frigate-card-elements>`;
}
+12 -5
View File
@@ -11,9 +11,15 @@ import type {
} from '../types'; } from '../types';
import { View } from '../view'; import { View } from '../view';
import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common'; import {
browseMedia,
browseMediaQuery,
dispatchErrorMessageEvent,
dispatchMessageEvent,
getFirstTrueMediaChildIndex,
} from '../common';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { renderMessage, renderErrorMessage, renderProgressIndicator } from './message'; import { renderProgressIndicator } from './message';
import galleryStyle from '../scss/gallery.scss'; import galleryStyle from '../scss/gallery.scss';
import { styleMap } from 'lit/directives/style-map.js'; import { styleMap } from 'lit/directives/style-map.js';
@@ -65,7 +71,7 @@ export class FrigateCardGallery extends LitElement {
return html`${until(this._render(), renderProgressIndicator())}`; return html`${until(this._render(), renderProgressIndicator())}`;
} }
protected async _render(): Promise<TemplateResult> { protected async _render(): Promise<TemplateResult | void> {
let parent: BrowseMediaSource | null; let parent: BrowseMediaSource | null;
try { try {
if (this.view.target) { if (this.view.target) {
@@ -74,11 +80,12 @@ export class FrigateCardGallery extends LitElement {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
} }
} catch (e: any) { } catch (e: any) {
return renderErrorMessage(e.message); return dispatchErrorMessageEvent(this, e.message);
} }
if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) { if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) {
return renderMessage( return dispatchMessageEvent(
this,
this._getMediaType() == 'clips' this._getMediaType() == 'clips'
? localize('common.no_clips') ? localize('common.no_clips')
: localize('common.no_snapshots'), : localize('common.no_snapshots'),
+14 -9
View File
@@ -8,16 +8,14 @@ import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { import {
dispatchErrorMessageEvent,
dispatchMediaLoadEvent, dispatchMediaLoadEvent,
dispatchMessageEvent,
dispatchPauseEvent, dispatchPauseEvent,
dispatchPlayEvent, dispatchPlayEvent,
homeAssistantWSRequest, homeAssistantWSRequest,
} from '../common'; } from '../common';
import { import { renderProgressIndicator } from '../components/message';
renderMessage,
renderErrorMessage,
renderProgressIndicator,
} from '../components/message';
import JSMpeg from '@cycjimmy/jsmpeg-player'; import JSMpeg from '@cycjimmy/jsmpeg-player';
@@ -74,7 +72,11 @@ export class FrigateCardViewerFrigate extends LitElement {
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!(this.cameraEntity in this.hass.states)) { if (!(this.cameraEntity in this.hass.states)) {
return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off'); return dispatchMessageEvent(
this,
localize('error.no_live_camera'),
'mdi:camera-off',
);
} }
return html` <frigate-card-ha-camera-stream return html` <frigate-card-ha-camera-stream
.hass=${this.hass} .hass=${this.hass}
@@ -120,7 +122,7 @@ export class FrigateCardViewerWebRTC extends LitElement {
try { try {
this._createWebRTC(); this._createWebRTC();
} catch (e) { } catch (e) {
return renderErrorMessage((e as Error).message); return dispatchErrorMessageEvent(this, (e as Error).message);
} }
} }
return html`${this._webRTCElement}`; return html`${this._webRTCElement}`;
@@ -213,7 +215,7 @@ export class FrigateCardViewerJSMPEG extends LitElement {
return html`${until(this._render(), renderProgressIndicator())}`; return html`${until(this._render(), renderProgressIndicator())}`;
} }
protected async _render(): Promise<TemplateResult> { protected async _render(): Promise<TemplateResult | void> {
if (!this._jsmpegCanvasElement) { if (!this._jsmpegCanvasElement) {
this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement = document.createElement('canvas');
this._jsmpegCanvasElement.className = 'media'; this._jsmpegCanvasElement.className = 'media';
@@ -223,7 +225,10 @@ export class FrigateCardViewerJSMPEG extends LitElement {
const jsmpeg_url = await this._getURL(); const jsmpeg_url = await this._getURL();
if (!jsmpeg_url) { if (!jsmpeg_url) {
return renderErrorMessage('Could not retrieve or sign JSMPEG websocket path'); return dispatchErrorMessageEvent(
this,
'Could not retrieve or sign JSMPEG websocket path',
);
} }
let videoDecoded = false; let videoDecoded = false;
+2 -1
View File
@@ -74,6 +74,7 @@ export class FrigateCardMenu extends LitElement {
let emphasize = false; let emphasize = false;
let title = button.title; let title = button.title;
let icon = button.icon; let icon = button.icon;
const style = ('style' in button ? button.style : {}) || {};
if (button.type === 'menu-state-icon') { if (button.type === 'menu-state-icon') {
state = this.hass.states[button.entity]; state = this.hass.states[button.entity];
@@ -100,7 +101,7 @@ export class FrigateCardMenu extends LitElement {
return html` <ha-icon-button return html` <ha-icon-button
class="${classMap(classes)}" class="${classMap(classes)}"
style="${styleMap(button.style || {})}" style="${styleMap(style)}"
icon=${icon || 'mdi:gesture-tap-button'} icon=${icon || 'mdi:gesture-tap-button'}
title=${title || ''} title=${title || ''}
@action=${(ev) => this._callAction(ev, button)} @action=${(ev) => this._callAction(ev, button)}
+17 -12
View File
@@ -3,6 +3,8 @@ import { customElement, property } from 'lit/decorators';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { Message } from '../types';
import messageStyle from '../scss/message.scss'; import messageStyle from '../scss/message.scss';
const URL_TROUBLESHOOTING = const URL_TROUBLESHOOTING =
@@ -14,13 +16,14 @@ export class FrigateCardMessage extends LitElement {
protected message = ''; protected message = '';
@property({ attribute: false }) @property({ attribute: false })
protected icon = 'mdi:information-outline'; protected icon?;
// Render the menu. // Render the menu.
protected render(): TemplateResult { protected render(): TemplateResult {
const icon = this.icon ? this.icon : 'mdi:information-outline';
return html` <div class="message"> return html` <div class="message">
<span> <span>
<ha-icon icon="${this.icon}"> </ha-icon> <ha-icon icon="${icon}"> </ha-icon>
${this.message ? html`&nbsp;${this.message}` : ''} ${this.message ? html`&nbsp;${this.message}` : ''}
</span> </span>
</div>`; </div>`;
@@ -59,16 +62,18 @@ export class FrigateCardProgressIndicator extends LitElement {
} }
} }
export function renderErrorMessage(error: string): TemplateResult { export function renderMessage(message: Message): TemplateResult {
return html` if (message.type == 'error') {
<frigate-card-error-message .error=${error}></frigate-card-error-message> return html` <frigate-card-error-message
`; .error=${message.message}
} ></frigate-card-error-message>`;
} else if (message.type == 'info') {
export function renderMessage(message: string, icon: string): TemplateResult { return html` <frigate-card-message
return html` .message=${message.message}
<frigate-card-message .message=${message} .icon=${icon}></frigate-card-message> .icon=${message.icon}
`; ></frigate-card-message>`;
}
return html``;
} }
export function renderProgressIndicator(): TemplateResult { export function renderProgressIndicator(): TemplateResult {
+7 -6
View File
@@ -18,7 +18,9 @@ import type {
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { import {
browseMediaQuery, browseMediaQuery,
dispatchErrorMessageEvent,
dispatchMediaLoadEvent, dispatchMediaLoadEvent,
dispatchMessageEvent,
dispatchPauseEvent, dispatchPauseEvent,
dispatchPlayEvent, dispatchPlayEvent,
getFirstTrueMediaChildIndex, getFirstTrueMediaChildIndex,
@@ -27,8 +29,6 @@ import {
import { View } from '../view'; import { View } from '../view';
import { import {
renderMessage,
renderErrorMessage,
renderProgressIndicator, renderProgressIndicator,
} from '../components/message'; } from '../components/message';
@@ -158,7 +158,7 @@ export class FrigateCardViewer extends LitElement {
return html`${until(this._render(), renderProgressIndicator())}`; return html`${until(this._render(), renderProgressIndicator())}`;
} }
protected async _render(): Promise<TemplateResult> { protected async _render(): Promise<TemplateResult | void> {
let autoplay = true; let autoplay = true;
let parent: BrowseMediaSource | null = null; let parent: BrowseMediaSource | null = null;
@@ -173,11 +173,12 @@ export class FrigateCardViewer extends LitElement {
try { try {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
} catch (e) { } catch (e) {
return renderErrorMessage((e as Error).message); return dispatchErrorMessageEvent(this, (e as Error).message);
} }
childIndex = getFirstTrueMediaChildIndex(parent); childIndex = getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) { if (!parent || !parent.children || childIndex == null) {
return renderMessage( return dispatchMessageEvent(
this,
this.view.is('clip') this.view.is('clip')
? localize('common.no_clip') ? localize('common.no_clip')
: localize('common.no_snapshot'), : localize('common.no_snapshot'),
@@ -196,7 +197,7 @@ export class FrigateCardViewer extends LitElement {
const resolvedMedia = await this._resolveMedia(mediaToRender); const resolvedMedia = await this._resolveMedia(mediaToRender);
if (!mediaToRender || !resolvedMedia) { if (!mediaToRender || !resolvedMedia) {
// Home Assistant could not resolve media item. // Home Assistant could not resolve media item.
return renderErrorMessage(localize('error.could_not_resolve')); return dispatchErrorMessageEvent(this, localize('error.could_not_resolve'));
} }
const neighbors = this._getMediaNeighbors(parent, childIndex); const neighbors = this._getMediaNeighbors(parent, childIndex);
+15 -3
View File
@@ -1,15 +1,16 @@
.container { .container {
position: relative; position: relative;
overflow: auto; overflow: auto;
height: 100%;
width: 100%; width: 100%;
height: 100%;
margin: auto; margin: auto;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.frigate-card-contents { .frigate-card-contents {
width: 100%; width: inherit;
height: inherit;
margin: auto; margin: auto;
overflow: auto; overflow: auto;
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */ -ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
@@ -43,6 +44,17 @@
.outer:hover + .hover-menu, .hover-menu:hover { .outer:hover + .hover-menu, .hover-menu:hover {
opacity: 1.0; opacity: 1.0;
} }
/* A relative div to place absolute picture elements onto */
.picture-elements {
position: relative;
width: inherit;
}
/* Enforce picture elements to only be the size of the card/fullscreen (and not
larger) when in gallery mode so that the picture elements do not scroll. */
.picture-elements.gallery {
height: 100%;
}
ha-card { ha-card {
display: flex; display: flex;
@@ -56,7 +68,7 @@ ha-card {
background-color: var(--secondary-background-color, black); background-color: var(--secondary-background-color, black);
} }
frigate-card-gallery, frigate-card-viewer, frigate-card-live { frigate-card-gallery, frigate-card-viewer, frigate-card-live, frigate-card-message, frigate-card-error-message {
width: 100%; width: 100%;
display: block; display: block;
} }
+4
View File
@@ -0,0 +1,4 @@
.element {
position: absolute;
transform: translate(-50%, -50%);
}
+4
View File
@@ -1,6 +1,10 @@
@use "@material/image-list/mdc-image-list"; @use "@material/image-list/mdc-image-list";
@use "@material/image-list"; @use "@material/image-list";
:host {
overflow: auto;
}
.frigate-card-gallery { .frigate-card-gallery {
// Note: In fullscreen, number of columns is overwritten in Javascript based // Note: In fullscreen, number of columns is overwritten in Javascript based
// on dimensions. // on dimensions.
+109 -26
View File
@@ -60,9 +60,9 @@ export type LiveProvider = typeof LIVE_PROVIDERS[number];
// Declare schemas to existing types: // Declare schemas to existing types:
// - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 // - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schemaForType = const schemaForType =
<T>() => <T>() =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<S extends z.ZodType<T, any, any>>(arg: S) => { <S extends z.ZodType<T, any, any>>(arg: S) => {
return arg; return arg;
}; };
@@ -104,50 +104,113 @@ const elementsActionSchema = z.union([
]); ]);
export type ElementsActionType = z.infer<typeof elementsActionSchema>; export type ElementsActionType = z.infer<typeof elementsActionSchema>;
const elementsActionsSchema = z.object({ const elementsBaseSchema = z.object({
style: z.object({}).passthrough().optional(),
title: z.string().nullable().optional(),
tap_action: elementsActionSchema.optional(), tap_action: elementsActionSchema.optional(),
hold_action: elementsActionSchema.optional(), hold_action: elementsActionSchema.optional(),
double_tap_action: elementsActionSchema.optional(), double_tap_action: elementsActionSchema.optional(),
}); });
/** /**
* Menu Types * Picture Element Types
*/ */
const menuItemBaseSchema = z.object({ // https://www.home-assistant.io/lovelace/picture-elements/#state-badge
title: z.string().optional(), const stateBadgeIconSchema = elementsBaseSchema.merge(
style: z.object({}).passthrough().optional(),
});
const menuIconSchema = menuItemBaseSchema
.merge(
z.object({ z.object({
type: z.literal('menu-icon'), type: z.literal('state-badge'),
icon: z.string(), entity: z.string(),
}), }));
)
.merge(elementsActionsSchema);
const menuStateIconSchema = menuItemBaseSchema // https://www.home-assistant.io/lovelace/picture-elements/#state-icon
.merge( const stateIconSchema = elementsBaseSchema.merge(
z.object({ z.object({
type: z.literal('menu-state-icon'), type: z.literal('state-icon'),
entity: z.string(), entity: z.string(),
icon: z.string().optional(), icon: z.string().optional(),
state_color: z.boolean().default(true), state_color: z.boolean().default(true),
}), }));
// https://www.home-assistant.io/lovelace/picture-elements/#state-label
const stateLabelSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('state-label'),
entity: z.string(),
attribute: z.string().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
}));
// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button
const serviceCallButtonSchema =
elementsBaseSchema.merge(z
.object({
type: z.literal('service-button'),
// Title is required for service button.
title: z.string(),
service: z.string(),
service_data: z.object({}).passthrough().optional(),
})
) )
.merge(elementsActionsSchema);
// https://www.home-assistant.io/lovelace/picture-elements/#icon
const iconSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('icon'),
icon: z.string(),
entity: z.string().optional(),
}));
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
const imageSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('image'),
entity: z.string().optional(),
image: z.string().optional(),
camera_image: z.string().optional(),
camera_view: z.string().optional(),
state_image: z.object({}).passthrough().optional(),
filter: z.string().optional(),
state_filter: z.object({}).passthrough().optional(),
aspect_ratio: z.string().optional(),
}));
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
const conditionalSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('conditional'),
conditions: z.object({
entity: z.string(),
state: z.string().optional(),
state_not: z.string().optional(),
}).array(),
elements: z.lazy(() => pictureElementsSchema),
}));
/**
* Menu Element Types
*/
const menuIconSchema = iconSchema.merge(
z.object({
type: z.literal('menu-icon'),
}));
const menuStateIconSchema = stateIconSchema.merge(
z.object({
type: z.literal('menu-state-icon'),
}));
// Schema for card (non-user configured) menu icons. // Schema for card (non-user configured) menu icons.
const internalMenuIconSchema = menuItemBaseSchema.merge( const internalMenuIconSchema = z
z.object({ .object({
type: z.literal('internal-menu-icon'), type: z.literal('internal-menu-icon'),
title: z.string(),
icon: z.string().optional(), icon: z.string().optional(),
emphasize: z.boolean().default(false).optional(), emphasize: z.boolean().default(false).optional(),
card_action: z.string(), card_action: z.string(),
}), });
);
const menuButtonSchema = z.union([ const menuButtonSchema = z.union([
menuIconSchema, menuIconSchema,
@@ -158,7 +221,21 @@ export type MenuButton = z.infer<typeof menuButtonSchema>;
// 'internalMenuIconSchema' is excluded to disallow the user from manually // 'internalMenuIconSchema' is excluded to disallow the user from manually
// changing the internal menu buttons. // changing the internal menu buttons.
const elementsSchema = z.union([menuStateIconSchema, menuIconSchema]); const pictureElementSchema = z.union([
menuStateIconSchema,
menuIconSchema,
stateBadgeIconSchema,
stateIconSchema,
stateLabelSchema,
serviceCallButtonSchema,
iconSchema,
imageSchema,
conditionalSchema,
]);
export type PictureElement = z.infer<typeof pictureElementSchema>;
const pictureElementsSchema = pictureElementSchema.array().optional();
export type PictureElements = z.infer<typeof pictureElementsSchema>;
export const frigateCardConfigSchema = z.object({ export const frigateCardConfigSchema = z.object({
camera_entity: z.string(), camera_entity: z.string(),
@@ -200,7 +277,7 @@ export const frigateCardConfigSchema = z.object({
}) })
.optional(), .optional(),
update_entities: z.string().array().optional(), update_entities: z.string().array().optional(),
elements: elementsSchema.array().optional(), elements: pictureElementsSchema,
controls: z controls: z
.object({ .object({
nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'), nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'),
@@ -260,6 +337,12 @@ export interface MediaLoadInfo {
height: number; height: number;
} }
export interface Message {
message: string;
type: 'error' | 'info';
icon?: string;
}
/** /**
* Home Assistant API types. * Home Assistant API types.
*/ */