diff --git a/package.json b/package.json index 0313b1fa..50ff7ff0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@babel/plugin-proposal-class-properties": "^7.14.5", "@babel/plugin-proposal-decorators": "^7.15.4", "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-multi-entry": "^4.1.0", "@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/parser": "^4.30.0", "eslint": "^7.32.0", diff --git a/rollup.config.js b/rollup.config.js index dc2c7e3a..3b875029 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -6,6 +6,7 @@ import { terser } from 'rollup-plugin-terser'; import serve from 'rollup-plugin-serve'; import json from '@rollup/plugin-json'; import styles from 'rollup-plugin-styles'; +import multi from '@rollup/plugin-multi-entry'; const dev = process.env.ROLLUP_WATCH; @@ -20,6 +21,7 @@ const serveopts = { }; const plugins = [ + multi(), styles({ modules: false, // Behavior of inject mode, without actually injecting style @@ -42,9 +44,9 @@ const plugins = [ export default [ { - input: 'src/frigate-hass-card.ts', + input: ['src/card.ts'], output: { - dir: 'dist', + file: 'dist/frigate-hass-card.js', format: 'es', }, plugins: [...plugins], diff --git a/src/card.ts b/src/card.ts new file mode 100644 index 00000000..a51e2ed5 --- /dev/null +++ b/src/card.ts @@ -0,0 +1,431 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + html, + unsafeCSS, +} from 'lit'; +import { customElement, property, query, state } from 'lit/decorators'; +import { classMap } from 'lit/directives/class-map.js'; +import { + HomeAssistant, + LovelaceCardEditor, + fireEvent, + getLovelace, + stateIcon, +} from 'custom-card-helpers'; + +import { + MenuButton, + frigateCardConfigSchema, +} from './types'; +import type { + BrowseMediaQueryParameters, + ExtendedHomeAssistant, + FrigateCardConfig, +} from './types'; + +import { CARD_VERSION } from './const'; +import { FrigateCardMenu } from './components/menu'; +import { View } from './view'; +import { getParseErrorKeys } from './common'; +import { localize } from './localize/localize'; + +import './editor'; +import './components/gallery'; +import './components/live'; +import './components/menu'; +import './components/message'; +import './components/viewer'; + +import cardStyle from './scss/card.scss'; + +/* eslint no-console: 0 */ +console.info( + `%c FRIGATE-HASS-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `, + 'color: pink; font-weight: bold; background: black', + 'color: white; font-weight: bold; background: dimgray', +); + +// This puts your card into the UI card picker dialog +(window as any).customCards = (window as any).customCards || []; +(window as any).customCards.push({ + type: 'frigate-card', + name: localize('common.frigate_card'), + description: localize('common.frigate_card_description'), +}); + +// Determine whether the card should be updated based on Home Assistant changes. +function shouldUpdateBasedOnHass( + newHass: HomeAssistant | null, + oldHass: HomeAssistant | undefined, + entities: string[] | null, +): boolean { + if (!newHass || !entities) { + return false; + } + if (!entities.length) { + return false; + } + + if (oldHass) { + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + if (!entity) { + continue; + } + if (oldHass.states[entity] !== newHass.states[entity]) { + return true; + } + } + return false; + } + return false; +} + +// Main FrigateCard class. +@customElement('frigate-card') +export class FrigateCard extends LitElement { + @property({ attribute: false }) + protected _hass: (HomeAssistant & ExtendedHomeAssistant) | null = null; + + @state() + public config!: FrigateCardConfig; + + protected _interactionTimerID: number | null = null; + + @property({ attribute: false }) + protected _view: View = new View(); + + @query('frigate-card-menu') + _menu!: FrigateCardMenu; + + // Whether or not there is an active clip being played. + protected _clipPlaying = false; + + // A small cache to avoid needing to create a new list of entities every time + // a hass update arrives. + protected _entitiesToMonitor: string[] | null = null; + + set hass(hass: HomeAssistant & ExtendedHomeAssistant) { + this._hass = hass; + this._updateMenu(); + } + + // Get the configuration element. + public static async getConfigElement(): Promise { + return document.createElement('frigate-card-editor'); + } + + // Get a stub basic config. + public static getStubConfig(): Record { + return {}; + } + + protected _updateMenu(): void { + // Manually set hass in the menu. This is to allow the menu to update, + // without necessarily re-rendering the entire card (re-rendering interrupts + // clip playing). + if (!this._menu || !this._hass) { + return; + } + this._menu.buttons = this._getMenuButtons(); + } + + protected _getMenuButtons(): Map { + const buttons: Map = new Map(); + + if (this.config.menu_buttons?.frigate ?? true) { + buttons.set('frigate', { description: localize('menu.frigate') }); + } + if (this.config.menu_buttons?.live ?? true) { + buttons.set('live', { + icon: 'mdi:cctv', + description: localize('menu.live'), + emphasize: this._view.is('live'), + }); + } + if (this.config.menu_buttons?.clips ?? true) { + buttons.set('clips', { + icon: 'mdi:filmstrip', + description: localize('menu.clips'), + emphasize: this._view.is('clips'), + }); + } + if (this.config.menu_buttons?.snapshots ?? true) { + buttons.set('snapshots', { + icon: 'mdi:camera', + description: localize('menu.snapshots'), + emphasize: this._view.is('snapshots'), + }); + } + if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) { + buttons.set('frigate_ui', { + icon: 'mdi:web', + description: localize('menu.frigate_ui'), + }); + } + const entities = this.config.entities || []; + for (let i = 0; this._hass && i < entities.length; i++) { + if (!entities[i].show) { + continue; + } + const entity = entities[i].entity; + const state = this._hass.states[entity]; + buttons.set(entity, { + description: state.attributes.friendly_name || entity, + emphasize: ['on', 'active', 'home'].includes(state.state), + icon: entities[i].icon || stateIcon(state), + }); + } + return buttons; + } + + // Set the object configuration. + public setConfig(inputConfig: FrigateCardConfig): void { + if (!inputConfig) { + throw new Error(localize('error.invalid_configuration:')); + } + + const parseResult = frigateCardConfigSchema.safeParse(inputConfig); + if (!parseResult.success) { + const keys = getParseErrorKeys(parseResult.error); + throw new Error(localize('error.invalid_configuration') + ': ' + keys.join(', ')); + } + const config = parseResult.data; + + if (config.test_gui) { + getLovelace().setEditMode(true); + } + + if (!config.frigate_camera_name) { + // No camera name specified, so just assume it's the same as the entity name. + if (config.camera_entity.includes('.')) { + config.frigate_camera_name = config.camera_entity.split('.', 2)[1]; + } else { + throw new Error(localize('error.invalid_configuration') + ': camera_entity'); + } + } + + this.config = config; + this._entitiesToMonitor = [ + ...(this.config.entities || []).map((entity) => entity.entity), + this.config.camera_entity, + ]; + this._changeView(); + } + + protected _changeView(view?: View | undefined): void { + if (view === undefined) { + this._view = new View({ view: this.config.view_default }); + } else { + this._view = view; + } + } + + protected _changeViewHandler(e: CustomEvent): void { + this._changeView(e.detail); + } + + // Determine whether the card should be updated. + protected shouldUpdate(changedProps: PropertyValues): boolean { + if (!this.config) { + return false; + } + if (changedProps.has('config')) { + return true; + } + + const oldHass = changedProps.get('_hass') as HomeAssistant | undefined; + if (oldHass) { + // Home Assistant pumps a lot of updates through. Re-rendering the card is + // necessary at times (e.g. to update the 'clip' view as new clips + // arrive), but also is a jarring experience for the user (e.g. if they + // 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 a clip active playing. + if (this._interactionTimerID || this._clipPlaying) { + return false; + } + return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor); + } + return true; + } + + protected _menuActionHandler(name: string): void { + switch (name) { + case 'frigate': + this._changeView(); + break; + case 'live': + case 'clips': + case 'snapshots': + this._changeView(new View({ view: name })); + break; + case 'frigate_ui': + const frigate_url = this._getFrigateURLFromContext(); + if (frigate_url) { + window.open(frigate_url); + } + break; + default: + // If it's unknown, it's assumed to be an entity_id. + fireEvent(this, 'hass-more-info', { entityId: name }); + } + } + + // Get the Frigate UI url. + protected _getFrigateURLFromContext(): string | null { + if (!this.config.frigate_url) { + return null; + } + if (this._view.is('live')) { + return `${this.config.frigate_url}/cameras/${this.config.frigate_camera_name}`; + } + return `${this.config.frigate_url}/events?camera=${this.config.frigate_camera_name}`; + } + + public updated(): void { + this.updateComplete.then(() => { + // DOM elements are not always present until after updateComplete promise + // is resolved. Note that children of children (i.e. the underlying video + // element) is not always present even when the promise returns, so + // capture the event at the upper shadow root instead. + const hls_player = this.renderRoot + ?.querySelector('ha-card') + ?.querySelector('ha-hls-player'); + + if (hls_player) { + hls_player.shadowRoot?.addEventListener( + 'play', + () => { + this._clipPlaying = true; + }, + true, + ); + hls_player.shadowRoot?.addEventListener( + 'pause', + () => { + this._clipPlaying = true; + }, + true, + ); + } + }); + } + + // Record interactions with the card. + protected _interactionHandler(): void { + if (!this.config.view_timeout) { + return; + } + if (this._interactionTimerID) { + window.clearTimeout(this._interactionTimerID); + } + this._interactionTimerID = window.setTimeout(() => { + this._interactionTimerID = null; + this._changeView(); + }, this.config.view_timeout * 1000); + } + + protected _renderMenu(): TemplateResult | void { + const classes = { + 'hover-menu': this.config.menu_mode.startsWith('hover-'), + }; + return html` + + `; + } + + protected _getBrowseMediaQueryParameters(): BrowseMediaQueryParameters { + return { + mediaType: this._view.view == 'clips' ? 'clips' : 'snapshots', + clientId: this.config.frigate_client_id, + // frigate_camera_name cannot be null, it will be set to a default value + // in setConfig if not specified in the configuration. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + cameraName: this.config.frigate_camera_name!, + label: this.config.label, + zone: this.config.zone, + }; + } + + // Render the call (master render method). + protected render(): TemplateResult | void { + if (this.config.show_warning) { + return this._showWarning(localize('common.show_warning')); + } + if (this.config.show_error) { + return this._showError(localize('common.show_error')); + } + return html` + ${this.config.menu_mode == 'above' ? this._renderMenu() : ''} +
+
+ ${this._view.is('clips') || this._view.is('snapshots') + ? html` + ` + : ``} + ${this._view.is('clip') || this._view.is('snapshot') + ? html` + ` + : ``} + ${this._view.is('live') + ? html` + ` + : ``} +
+
+ ${this.config.menu_mode != 'above' ? this._renderMenu() : ''} +
`; + } + + // Show a warning card. + private _showWarning(warning: string): TemplateResult { + return html` ${warning} `; + } + + // Show an error card. + private _showError(error: string): TemplateResult { + const errorCard = document.createElement('hui-error-card'); + errorCard.setConfig({ + type: 'error', + error, + origConfig: this.config, + }); + + return html` ${errorCard} `; + } + + // Return compiled CSS styles (thus safe to use with unsafeCSS). + static get styles(): CSSResultGroup { + return unsafeCSS(cardStyle); + } + + // Get the Lovelace card size. + public getCardSize(): number { + return 6; + } +} diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 00000000..79898353 --- /dev/null +++ b/src/common.ts @@ -0,0 +1,115 @@ +import { ZodSchema, z } from 'zod'; +import { MessageBase } from 'home-assistant-js-websocket'; +import { HomeAssistant } from 'custom-card-helpers'; +import { localize } from './localize/localize'; +import type { + BrowseMediaQueryParameters, + BrowseMediaSource, + ExtendedHomeAssistant, +} from './types'; +import { browseMediaSourceSchema } from './types'; + +export function getParseErrorKeys(error: z.ZodError): string[] { + const errors = error.format(); + return Object.keys(errors).filter((v) => !v.startsWith('_')); +} + +export async function homeAssistantWSRequest( + hass: HomeAssistant & ExtendedHomeAssistant, + schema: ZodSchema, + request: MessageBase, +): Promise { + const response = await hass.callWS(request); + + if (!response) { + const error_message = `${localize('error.empty_response')}: ${JSON.stringify( + request, + )}`; + console.warn(error_message); + throw new Error(error_message); + } + const parseResult = schema.safeParse(response); + if (!parseResult.success) { + const keys = getParseErrorKeys(parseResult.error); + const error_message = + `${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` + + localize('error.invalid_keys') + + `: '${keys}'`; + console.warn(error_message); + throw new Error(error_message); + } + return parseResult.data; +} + +// From a BrowseMediaSource item extract the first true media item (i.e. a +// clip/snapshot, not a folder). +export function getFirstTrueMediaChildIndex( + media: BrowseMediaSource | null, +): number | null { + if (!media || !media.children) { + return null; + } + for (let i = 0; i < media.children.length; i++) { + if (!media.children[i].can_expand) { + return i; + } + } + return null; +} + +// Browse Frigate media with a media content id. +export async function browseMedia( + hass: (HomeAssistant & ExtendedHomeAssistant) | null, + media_content_id: string, +): Promise { + if (!hass) { + return null; + } + const request = { + type: 'media_source/browse_media', + media_content_id: media_content_id, + }; + return homeAssistantWSRequest(hass, browseMediaSourceSchema, request); +} + +// Browse Frigate media with query parameters. +export async function browseMediaQuery( + hass: HomeAssistant & ExtendedHomeAssistant, + params: BrowseMediaQueryParameters, +): Promise { + return browseMedia( + hass, + // Defined in: + // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py + [ + 'media-source://frigate', + params.clientId, + 'event-search', + params.mediaType, + '', // Name/Title to render (not necessary here) + params.after ? String(params.after) : '', + params.before ? String(params.before) : '', + params.cameraName, + params.label, + params.zone, + ].join('/'), + ); +} + +export function dispatchPlayEvent(node: HTMLElement): void { + node.dispatchEvent( + new CustomEvent('frigate-card:play', { + bubbles: true, + composed: true, + }), + ); +} + +export function dispatchPauseEvent(node: HTMLElement): void { + node.dispatchEvent( + new CustomEvent('frigate-card:pause', { + bubbles: true, + composed: true, + }), + ); +} diff --git a/src/components/gallery.ts b/src/components/gallery.ts new file mode 100644 index 00000000..ec276b1d --- /dev/null +++ b/src/components/gallery.ts @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { until } from 'lit/directives/until.js'; +import { HomeAssistant } from 'custom-card-helpers'; + +import type { + BrowseMediaSource, + BrowseMediaQueryParameters, + ExtendedHomeAssistant, +} from '../types'; + +import { View } from '../view'; +import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common'; +import { localize } from '../localize/localize'; +import { renderMessage, renderErrorMessage, renderProgressIndicator } from './message'; + +import galleryStyle from '../scss/gallery.scss'; + +@customElement('frigate-card-gallery') +export class FrigateCardGallery extends LitElement { + @property({ attribute: false }) + protected hass!: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected view!: View; + + @property({ attribute: false }) + protected browseMediaQueryParameters!: BrowseMediaQueryParameters; + + protected _getMediaType(): 'clips' | 'snapshots' { + return this.view?.view == 'clips' ? 'clips' : 'snapshots'; + } + + protected render(): TemplateResult | void { + return html`${until(this._render(), renderProgressIndicator())}`; + } + + protected async _render(): Promise { + let parent: BrowseMediaSource | null; + try { + if (this.view.target) { + parent = await browseMedia(this.hass, this.view.target.media_content_id); + } else { + parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); + } + } catch (e: any) { + return renderErrorMessage(e.message); + } + + if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) { + return renderMessage( + this._getMediaType() == 'clips' + ? localize('common.no_clips') + : localize('common.no_snapshots'), + this._getMediaType() == 'clips' ? 'mdi:filmstrip-off' : 'mdi:camera-off', + ); + } + + return html` `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(galleryStyle); + } +} diff --git a/src/components/live.ts b/src/components/live.ts new file mode 100644 index 00000000..caf6604f --- /dev/null +++ b/src/components/live.ts @@ -0,0 +1,216 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { until } from 'lit/directives/until.js'; +import { HomeAssistant } from 'custom-card-helpers'; + +import { signedPathSchema } from '../types'; +import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types'; + +import { localize } from '../localize/localize'; +import { homeAssistantWSRequest } from '../common'; +import { + renderMessage, + renderErrorMessage, + renderProgressIndicator, +} from '../components/message'; + +import JSMpeg from '@cycjimmy/jsmpeg-player'; + +import liveStyle from '../scss/live.scss'; + +@customElement('frigate-card-live') +export class FrigateCardViewer extends LitElement { + @property({ attribute: false }) + protected hass!: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected config!: FrigateCardConfig; + + protected render(): TemplateResult | void { + return html`${until(this._render(), renderProgressIndicator())}`; + } + + protected async _render(): Promise { + return html` ${this.config.live_provider == 'frigate' + ? html` + ` + : this.config.live_provider == 'webrtc' + ? html` + ` + : html` + `}`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(liveStyle); + } +} + +@customElement('frigate-card-live-frigate') +export class FrigateCardViewerFrigate extends LitElement { + @property({ attribute: false }) + protected hass!: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected cameraEntity!: string; + + protected render(): TemplateResult | void { + if (!(this.cameraEntity in this.hass.states)) { + return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off'); + } + return html` + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(liveStyle); + } +} + +// Create a wrapper for the WebRTC element +// - https://github.com/AlexxIT/WebRTC +@customElement('frigate-card-live-webrtc') +export class FrigateCardViewerWebRTC extends LitElement { + @property({ attribute: false }) + protected hass!: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected webRTCConfig!: Record; + + protected _webRTCElement: HTMLElement | null = null; + + protected _createWebRTC(): TemplateResult | void { + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const webrtcElement = customElements.get('webrtc-camera') as any; + if (webrtcElement) { + const webrtc = new webrtcElement(); + webrtc.setConfig(this.webRTCConfig); + webrtc.hass = this.hass; + this._webRTCElement = webrtc; + } else { + throw new Error(localize('error.missing_webrtc')); + } + } + + protected render(): TemplateResult | void { + if (!this._webRTCElement) { + try { + this._createWebRTC(); + } catch (e) { + return renderErrorMessage((e as Error).message); + } + } + return html`${this._webRTCElement}`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(liveStyle); + } +} + +@customElement('frigate-card-live-jsmpeg') +export class FrigateCardViewerJSMPEG extends LitElement { + @property({ attribute: false }) + protected hass!: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected cameraName!: string; + + @property({ attribute: false }) + protected clientId!: string; + + protected _jsmpegCanvasElement: HTMLElement | null = null; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected _jsmpegVideoPlayer: any | null = null; + + protected async _getURL(): Promise { + if (!this.hass) { + return null; + } + + const request = { + type: 'auth/sign_path', + path: `/api/frigate/${this.clientId}` + `/jsmpeg/${this.cameraName}`, + }; + // Sign the path so it includes an authSig parameter. + let response; + try { + response = await homeAssistantWSRequest(this.hass, signedPathSchema, request); + } catch (err) { + console.warn(err); + return null; + } + const url = this.hass.hassUrl(response.path); + return url.replace(/^http/i, 'ws'); + } + + disconnectedCallback(): void { + if (this._jsmpegVideoPlayer) { + this._jsmpegVideoPlayer.destroy(); + this._jsmpegVideoPlayer = null; + } + this._jsmpegCanvasElement = null; + super.disconnectedCallback(); + } + + protected render(): TemplateResult | void { + return html`${until(this._render(), renderProgressIndicator())}`; + } + + protected async _render(): Promise { + if (!this._jsmpegCanvasElement) { + this._jsmpegCanvasElement = document.createElement('canvas'); + this._jsmpegCanvasElement.className = 'media'; + } + + if (!this._jsmpegVideoPlayer) { + const jsmpeg_url = await this._getURL(); + + if (!jsmpeg_url) { + return renderErrorMessage('Could not retrieve or sign JSMPEG websocket path'); + } + + // Return the html canvas node only after the JSMPEG video has loaded and + // is playing, to reduce the amount of time the user is staring at a blank + // white canvas (instead they get the progress spinner until this promise + // resolves). + return new Promise((resolve) => { + this._jsmpegVideoPlayer = new JSMpeg.VideoElement( + this, + jsmpeg_url, + { + canvas: this._jsmpegCanvasElement, + hooks: { + play: () => { + resolve(html`${this._jsmpegCanvasElement}`); + }, + }, + }, + { protocols: [], videoBufferSize: 1024 * 1024 * 4 }, + ); + }); + } + return html`${this._jsmpegCanvasElement}`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(liveStyle); + } +} diff --git a/src/components/menu.ts b/src/components/menu.ts new file mode 100644 index 00000000..deb15256 --- /dev/null +++ b/src/components/menu.ts @@ -0,0 +1,121 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { classMap } from 'lit/directives/class-map.js'; + +import { MenuButton } from '../types'; +import type { FrigateMenuMode } from '../types'; + +import menuStyle from '../scss/menu.scss'; + +type FrigateCardMenuCallback = (name: string) => void; + +// A menu for the Frigate card. +@customElement('frigate-card-menu') +export class FrigateCardMenu extends LitElement { + @property({ attribute: false }) + protected menuMode: FrigateMenuMode = 'hidden-top'; + + @property({ attribute: false }) + protected expand = false; + + @property({ attribute: false }) + protected actionCallback: FrigateCardMenuCallback | null = null; + + @property({ attribute: false }) + public buttons: Map = new Map(); + + // Call the callback. + protected _callAction(name: string): void { + if (this.menuMode.startsWith('hidden-')) { + if (name == 'frigate') { + this.expand = !this.expand; + return; + } + // Collapse menu after the user clicks on something. + this.expand = false; + } + + if (this.actionCallback) { + this.actionCallback(name); + } + } + + // Render a menu button. + protected _renderButton(name: string, button: MenuButton): TemplateResult { + const classes = { + button: true, + emphasize: button.emphasize ?? false, + }; + + return html` this._callAction(name)} + >`; + } + + // Render the Frigate menu button. + protected _renderFrigateButton(name: string, button: MenuButton): TemplateResult { + const icon = + this.menuMode.startsWith('hidden-') && !this.expand + ? 'mdi:alpha-f-box-outline' + : 'mdi:alpha-f-box'; + + return this._renderButton(name, Object.assign({}, button, { icon: icon })); + } + + // Render the menu. + protected render(): TemplateResult { + // If the menu is off, or if it's in hidden mode but there's no button to + // unhide it, just show nothing. + if ( + this.menuMode == 'none' || + (this.menuMode.startsWith('hidden-') && !this.buttons.get('frigate')) + ) { + return html``; + } + + const classes = { + 'frigate-card-menu': true, + 'overlay-hidden': + this.menuMode.startsWith('hidden-') || + this.menuMode.startsWith('overlay-') || + this.menuMode.startsWith('hover-'), + 'expanded-horizontal': + (this.menuMode.startsWith('overlay-') || + this.menuMode.startsWith('hover-') || + this.expand) && + (this.menuMode.endsWith('-top') || this.menuMode.endsWith('-bottom')), + 'expanded-vertical': + (this.menuMode.startsWith('overlay-') || + this.menuMode.startsWith('hover-') || + this.expand) && + (this.menuMode.endsWith('-left') || this.menuMode.endsWith('-right')), + full: ['above', 'below'].includes(this.menuMode), + left: this.menuMode.endsWith('-left'), + right: this.menuMode.endsWith('-right'), + top: this.menuMode.endsWith('-top'), + bottom: this.menuMode.endsWith('-bottom'), + }; + + return html` +
+ ${Array.from(this.buttons.keys()).map((name) => { + const button = this.buttons.get(name); + if (button) { + return name === 'frigate' + ? this._renderFrigateButton(name, button) + : this._renderButton(name, button); + } + return html``; + })} +
+ `; + } + + // Return compiled CSS styles (thus safe to use with unsafeCSS). + static get styles(): CSSResultGroup { + return unsafeCSS(menuStyle); + } +} diff --git a/src/components/message.ts b/src/components/message.ts new file mode 100644 index 00000000..939bb0ae --- /dev/null +++ b/src/components/message.ts @@ -0,0 +1,76 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; + +import { localize } from '../localize/localize'; + +import messageStyle from '../scss/message.scss'; + +const URL_TROUBLESHOOTING = + 'https://github.com/dermotduffy/frigate-hass-card#troubleshooting'; + +@customElement('frigate-card-message') +export class FrigateCardMessage extends LitElement { + @property({ attribute: false }) + protected message = ''; + + @property({ attribute: false }) + protected icon = 'mdi:information-outline'; + + // Render the menu. + protected render(): TemplateResult { + return html`
+ + + ${this.message ? html` ${this.message}` : ''} + +
`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(messageStyle); + } +} + +@customElement('frigate-card-error-message') +export class FrigateCardErrorMessage extends LitElement { + @property({ attribute: false }) + protected error = ''; + + protected render(): TemplateResult { + return html` ${localize('error.troubleshooting')} .`} + .icon=${'mdi:alert-circle'} + > + `; + } +} + +@customElement('frigate-card-progress-indicator') +export class FrigateCardProgressIndicator extends LitElement { + protected render(): TemplateResult { + return html`
+ +
`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(messageStyle); + } +} + +export function renderErrorMessage(error: string): TemplateResult { + return html` + + `; +} + +export function renderMessage(message: string, icon: string): TemplateResult { + return html` + + `; +} + +export function renderProgressIndicator(): TemplateResult { + return html` `; +} diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts new file mode 100644 index 00000000..b9f68e91 --- /dev/null +++ b/src/components/next-prev-control.ts @@ -0,0 +1,77 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { classMap } from 'lit/directives/class-map'; + +import { BrowseMediaSource, NextPreviousControlStyle } from '../types'; + +import { View } from '../view'; + +import controlStyle from '../scss/next-previous-control.scss'; + +@customElement('frigate-card-next-previous-control') +export class FrigateCardMessage extends LitElement { + @property({ attribute: false }) + protected control!: "next" | "previous"; + + @property({ attribute: false }) + protected controlStyle!: NextPreviousControlStyle; + + @property({ attribute: false }) + protected parent!: BrowseMediaSource; + + @property({ attribute: false }) + protected childIndex!: number; + + @property({ attribute: false }) + protected view!: View; + + protected _changeView(): void { + new View({ + view: this.view.view, + target: this.parent, + childIndex: this.childIndex, + }).dispatchChangeEvent(this); + } + + protected render() : TemplateResult { + if (this.controlStyle == 'none' || !this.parent.children) { + return html``; + } + const target = this.parent.children[this.childIndex]; + if (!target) { + return html``; + } + + const classes = { + controls: true, + previous: this.control == "previous", + next: this.control == "next", + thumbnails: this.controlStyle == "thumbnails", + chevrons: this.controlStyle == "chevrons", + button: this.controlStyle == "chevrons", + }; + + if (this.controlStyle == "chevrons") { + return html` `; + } + + if (!target.thumbnail) { + return html``; + } + return html``; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(controlStyle); + } +} \ No newline at end of file diff --git a/src/components/viewer.ts b/src/components/viewer.ts new file mode 100644 index 00000000..08cd821d --- /dev/null +++ b/src/components/viewer.ts @@ -0,0 +1,266 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { until } from 'lit/directives/until.js'; +import { HomeAssistant } from 'custom-card-helpers'; + +import dayjs from 'dayjs'; +import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat'; + +import { resolvedMediaSchema } from '../types'; +import type { + BrowseMediaNeighbors, + BrowseMediaQueryParameters, + BrowseMediaSource, + ExtendedHomeAssistant, + NextPreviousControlStyle, + ResolvedMedia, +} from '../types'; +import { localize } from '../localize/localize'; +import { + browseMediaQuery, + dispatchPauseEvent, + dispatchPlayEvent, + getFirstTrueMediaChildIndex, + homeAssistantWSRequest, +} from '../common'; + +import { View } from '../view'; +import { + renderMessage, + renderErrorMessage, + renderProgressIndicator, +} from '../components/message'; + +import './next-prev-control'; + +import viewerStyle from '../scss/viewer.scss'; + +// Load dayjs plugin(s). +dayjs.extend(dayjs_custom_parse_format); + +@customElement('frigate-card-viewer') +export class FrigateCardViewer extends LitElement { + @property({ attribute: false }) + protected hass!: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected view!: View; + + @property({ attribute: false }) + protected browseMediaQueryParameters!: BrowseMediaQueryParameters; + + @property({ attribute: false }) + protected nextPreviousControlStyle!: NextPreviousControlStyle; + + @property({ attribute: false }) + protected autoplayClip!: boolean; + + protected async _resolveMedia( + mediaSource: BrowseMediaSource | null, + ): Promise { + if (!mediaSource) { + return null; + } + const request = { + type: 'media_source/resolve_media', + media_content_id: mediaSource.media_content_id, + }; + return homeAssistantWSRequest(this.hass, resolvedMediaSchema, request); + } + + protected _extractEventStartTimeFromBrowseMedia( + browseMedia: BrowseMediaSource, + ): number | null { + // Example: 2021-08-27 20:57:22 [10s, Person 76%] + const result = browseMedia.title.match(/^(?.+) \[/); + if (result && result.groups) { + const iso_datetime_str = result.groups['iso_datetime']; + if (iso_datetime_str) { + const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true); + if (iso_datetime.isValid()) { + return iso_datetime.unix(); + } + } + } + return null; + } + + // Get the previous and next real media items, given the index + protected _getMediaNeighbors( + parent: BrowseMediaSource, + index: number | null, + ): BrowseMediaNeighbors | null { + if (index == null || !parent.children) { + return null; + } + + // Work backwards from the index to get the previous real media. + let prevIndex: number | null = null; + for (let i = index - 1; i >= 0; i--) { + const media = parent.children[i]; + if (media && !media.can_expand) { + prevIndex = i; + break; + } + } + + // Work forwards from the index to get the next real media. + let nextIndex: number | null = null; + for (let i = index + 1; i < parent.children.length; i++) { + const media = parent.children[i]; + if (media && !media.can_expand) { + nextIndex = i; + break; + } + } + + return { + previousIndex: prevIndex, + previous: prevIndex != null ? parent.children[prevIndex] : null, + nextIndex: nextIndex, + next: nextIndex != null ? parent.children[nextIndex] : null, + }; + } + + // Get a clip at the same time as a snapshot. + protected async _findRelatedClips( + snapshot: BrowseMediaSource | null, + ): Promise { + if (!snapshot) { + return null; + } + + const startTime = this._extractEventStartTimeFromBrowseMedia(snapshot); + if (startTime) { + try { + // Fetch clips within the same second (same camera/zone/label, etc). + const clipsAtSameTime = await browseMediaQuery(this.hass, { + ...this.browseMediaQueryParameters, + before: startTime + 1, + after: startTime, + }); + if (clipsAtSameTime) { + const index = getFirstTrueMediaChildIndex(clipsAtSameTime); + if (index != null && clipsAtSameTime.children?.length) { + return clipsAtSameTime.children[index]; + } + } + } catch (e: any) { + // Pass. This is best effort. + } + } + return null; + } + + protected render(): TemplateResult | void { + return html`${until(this._render(), renderProgressIndicator())}`; + } + + protected async _render(): Promise { + let autoplay = true; + + let parent: BrowseMediaSource | null = null; + let childIndex: number | null = null; + let mediaToRender: BrowseMediaSource | null = null; + + if (this.view.target) { + parent = this.view.target; + childIndex = this.view.childIndex ?? null; + mediaToRender = this.view.media ?? null; + } else { + try { + parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); + } catch (e) { + return renderErrorMessage((e as Error).message); + } + childIndex = getFirstTrueMediaChildIndex(parent); + if (!parent || !parent.children || childIndex == null) { + return renderMessage( + this.view.is('clip') + ? localize('common.no_clip') + : localize('common.no_snapshot'), + this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off', + ); + } + mediaToRender = parent.children[childIndex]; + + // In this block, no clip has been manually selected, so this is loading + // the most recent clip on card load. In this mode, autoplay of the clip + // may be disabled by configuration. If does not make sense to disable + // autoplay when the user has explicitly picked an event to play in the + // gallery. + autoplay = this.autoplayClip; + } + const resolvedMedia = await this._resolveMedia(mediaToRender); + if (!mediaToRender || !resolvedMedia) { + // Home Assistant could not resolve media item. + return renderErrorMessage(localize('error.could_not_resolve')); + } + + const neighbors = this._getMediaNeighbors(parent, childIndex); + + return html` + ${neighbors?.previousIndex != null + ? html`` + : ``} + ${this.view.is('clip') + ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' + ? html` + ` + : html`` + : html` { + // Get clips potentially related to this snapshot. + this._findRelatedClips(mediaToRender).then((relatedClip) => { + if (relatedClip) { + new View({ + view: 'clip', + target: relatedClip, + }).dispatchChangeEvent(this); + } + }); + }} + />`} + ${neighbors?.nextIndex != null + ? html`` + : ``} + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(viewerStyle); + } +} diff --git a/src/frigate-hass-card.ts b/src/frigate-hass-card.ts deleted file mode 100644 index 8a30d99a..00000000 --- a/src/frigate-hass-card.ts +++ /dev/null @@ -1,1134 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - CSSResultGroup, - LitElement, - PropertyValues, - TemplateResult, - html, - unsafeCSS, -} from 'lit'; -import { customElement, property, query, state } from 'lit/decorators'; -import { classMap } from 'lit/directives/class-map.js'; -import { until } from 'lit/directives/until.js'; - -import { - HomeAssistant, - LovelaceCardEditor, - fireEvent, - getLovelace, - stateIcon, -} from 'custom-card-helpers'; - -import './editor'; - -import frigate_card_style from './scss/card.scss'; -import frigate_card_menu_style from './scss/menu.scss'; - -import { - MenuButton, - browseMediaSourceSchema, - frigateCardConfigSchema, - resolvedMediaSchema, - signedPathSchema, -} from './types'; -import type { - BrowseMediaNeighbors, - BrowseMediaSource, - ExtendedHomeAssistant, - FrigateCardConfig, - FrigateCardView, - FrigateMenuMode, - ResolvedMedia, -} from './types'; -import { CARD_VERSION } from './const'; -import { localize } from './localize/localize'; -import dayjs from 'dayjs'; -import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat'; - -import { ZodSchema, z } from 'zod'; -import { MessageBase } from 'home-assistant-js-websocket'; - -import JSMpeg from '@cycjimmy/jsmpeg-player'; - -const URL_TROUBLESHOOTING = - 'https://github.com/dermotduffy/frigate-hass-card#troubleshooting'; - -// Load dayjs plugin(s). -dayjs.extend(dayjs_custom_parse_format); - -/* eslint no-console: 0 */ -console.info( - `%c FRIGATE-HASS-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `, - 'color: pink; font-weight: bold; background: black', - 'color: white; font-weight: bold; background: dimgray', -); - -// This puts your card into the UI card picker dialog -(window as any).customCards = (window as any).customCards || []; -(window as any).customCards.push({ - type: 'frigate-card', - name: localize('common.frigate_card'), - description: localize('common.frigate_card_description'), -}); - -type FrigateCardMenuCallback = (name: string) => any; - -// Determine whether the card should be updated based on Home Assistant changes. -function shouldUpdateBasedOnHass( - newHass: HomeAssistant | null, - oldHass: HomeAssistant | undefined, - entities: string[] | null, -): boolean { - if (!newHass || !entities) { - return false; - } - if (!entities.length) { - return false; - } - - if (oldHass) { - for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - if (!entity) { - continue; - } - if (oldHass.states[entity] !== newHass.states[entity]) { - return true; - } - } - return false; - } - return false; -} - -// A menu for the Frigate card. -@customElement('frigate-card-menu') -export class FrigateCardMenu extends LitElement { - static FRIGATE_CARD_MENU_ID: string = 'frigate-card-menu-id' as const; - - @property({ attribute: false }) - protected menuMode: FrigateMenuMode = 'hidden-top'; - - @property({ attribute: false }) - protected expand = false; - - @property({ attribute: false }) - protected actionCallback: FrigateCardMenuCallback | null = null; - - @property({ attribute: false }) - public buttons: Map = new Map(); - - // Call the callback. - protected _callAction(name: string): void { - if (this.menuMode.startsWith('hidden-')) { - if (name == 'frigate') { - this.expand = !this.expand; - return; - } - // Collapse menu after the user clicks on something. - this.expand = false; - } - - if (this.actionCallback) { - this.actionCallback(name); - } - } - - // Render a menu button. - protected _renderButton(name: string, button: MenuButton): TemplateResult { - const classes = { - button: true, - emphasize: button.emphasize ?? false, - }; - - return html` this._callAction(name)} - >`; - } - - // Render the Frigate menu button. - protected _renderFrigateButton(name: string, button: MenuButton): TemplateResult { - const icon = - this.menuMode.startsWith('hidden-') && !this.expand - ? 'mdi:alpha-f-box-outline' - : 'mdi:alpha-f-box'; - - return this._renderButton(name, Object.assign({}, button, { icon: icon })); - } - - // Render the menu. - protected render(): TemplateResult { - // If the menu is off, or if it's in hidden mode but there's no button to - // unhide it, just show nothing. - if ( - this.menuMode == 'none' || - (this.menuMode.startsWith('hidden-') && !this.buttons.get('frigate')) - ) { - return html``; - } - - const classes = { - 'frigate-card-menu': true, - 'overlay-hidden': - this.menuMode.startsWith('hidden-') || - this.menuMode.startsWith('overlay-') || - this.menuMode.startsWith('hover-'), - 'expanded-horizontal': - (this.menuMode.startsWith('overlay-') || - this.menuMode.startsWith('hover-') || - this.expand) && - (this.menuMode.endsWith('-top') || this.menuMode.endsWith('-bottom')), - 'expanded-vertical': - (this.menuMode.startsWith('overlay-') || - this.menuMode.startsWith('hover-') || - this.expand) && - (this.menuMode.endsWith('-left') || this.menuMode.endsWith('-right')), - full: ['above', 'below'].includes(this.menuMode), - left: this.menuMode.endsWith('-left'), - right: this.menuMode.endsWith('-right'), - top: this.menuMode.endsWith('-top'), - bottom: this.menuMode.endsWith('-bottom'), - }; - - return html` -
- ${Array.from(this.buttons.keys()).map((name) => { - const button = this.buttons.get(name); - if (button) { - return name === 'frigate' - ? this._renderFrigateButton(name, button) - : this._renderButton(name, button); - } - return html``; - })} -
- `; - } - - // Return compiled CSS styles (thus safe to use with unsafeCSS). - static get styles(): CSSResultGroup { - return unsafeCSS(frigate_card_menu_style); - } -} - -interface ViewParameters { - view?: FrigateCardView; - target?: BrowseMediaSource; - childIndex?: number; - previous?: View; -} - -class View { - view: FrigateCardView; - target?: BrowseMediaSource; - childIndex?: number; - previous?: View; - - constructor(params?: ViewParameters) { - this.view = params?.view || 'live'; - this.target = params?.target; - this.childIndex = params?.childIndex; - this.previous = params?.previous; - } - - public is(name: string): boolean { - return this.view == name; - } - - get media(): BrowseMediaSource | undefined { - if (this.target) { - if (this.target.children && this.childIndex !== undefined) { - return this.target.children[this.childIndex]; - } - return this.target; - } - return undefined; - } -} - -// Main FrigateCard class. -@customElement('frigate-card') -export class FrigateCard extends LitElement { - // Get the configuration element. - public static async getConfigElement(): Promise { - return document.createElement('frigate-card-editor'); - } - - // Get a stub basic config. - public static getStubConfig(): Record { - return {}; - } - set hass(hass: HomeAssistant & ExtendedHomeAssistant) { - if (this._webrtcElement) { - this._webrtcElement.hass = hass; - } - this._hass = hass; - this._updateMenu(); - } - - @property({ attribute: false }) - protected _hass: (HomeAssistant & ExtendedHomeAssistant) | null = null; - - @state() - public config!: FrigateCardConfig; - - protected _interactionTimerID: number | null = null; - protected _jsmpegCanvasElement: any | null = null; - protected _jsmpegPlayer: any | null = null; - protected _webrtcElement: any | null = null; - - @property({ attribute: false }) - protected _view: View = new View(); - - // Whether or not there is an active clip being played. - protected _clipPlaying = false; - - @query(`#${FrigateCardMenu.FRIGATE_CARD_MENU_ID}`) - _menu!: FrigateCardMenu | null; - - // A small cache to avoid needing to create a new list of entities every time - // a hass update arrives. - protected _entitiesToMonitor: string[] | null = null; - - protected _updateMenu(): void { - // Manually set hass in the menu. This is to allow the menu to update, - // without necessarily re-rendering the entire card (re-rendering interrupts - // clip playing). - if (!this._menu || !this._hass) { - return; - } - - this._menu.buttons = this._getMenuButtons(); - } - - protected _getMenuButtons(): Map { - const buttons: Map = new Map(); - - if (this.config.menu_buttons?.frigate ?? true) { - buttons.set('frigate', { description: localize('menu.frigate') }); - } - if (this.config.menu_buttons?.live ?? true) { - buttons.set('live', { - icon: 'mdi:cctv', - description: localize('menu.live'), - emphasize: this._view.is('live'), - }); - } - if (this.config.menu_buttons?.clips ?? true) { - buttons.set('clips', { - icon: 'mdi:filmstrip', - description: localize('menu.clips'), - emphasize: this._view.is('clips'), - }); - } - if (this.config.menu_buttons?.snapshots ?? true) { - buttons.set('snapshots', { - icon: 'mdi:camera', - description: localize('menu.snapshots'), - emphasize: this._view.is('snapshots'), - }); - } - if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) { - buttons.set('frigate_ui', { - icon: 'mdi:web', - description: localize('menu.frigate_ui'), - }); - } - const entities = this.config.entities || []; - for (let i = 0; this._hass && i < entities.length; i++) { - if (!entities[i].show) { - continue; - } - const entity = entities[i].entity; - const state = this._hass.states[entity]; - buttons.set(entity, { - description: state.attributes.friendly_name || entity, - emphasize: ['on', 'active', 'home'].includes(state.state), - icon: entities[i].icon || stateIcon(state), - }); - } - return buttons; - } - - protected _getParseErrorKeys(error: z.ZodError): string[] { - const errors = error.format(); - return Object.keys(errors).filter((v) => !v.startsWith('_')); - } - - // Set the object configuration. - public setConfig(inputConfig: FrigateCardConfig): void { - if (!inputConfig) { - throw new Error(localize('error.invalid_configuration:')); - } - - const parseResult = frigateCardConfigSchema.safeParse(inputConfig); - if (!parseResult.success) { - const keys = this._getParseErrorKeys(parseResult.error); - throw new Error(localize('error.invalid_configuration') + ': ' + keys.join(', ')); - } - const config = parseResult.data; - - if (config.test_gui) { - getLovelace().setEditMode(true); - } - - if (!config.frigate_camera_name) { - // No camera name specified, so just assume it's the same as the entity name. - if (config.camera_entity.includes('.')) { - config.frigate_camera_name = config.camera_entity.split('.', 2)[1]; - } else { - throw new Error(localize('error.invalid_configuration') + ': camera_entity'); - } - } - - if (config.live_provider == 'webrtc') { - // Create a WebRTC element (https://github.com/AlexxIT/WebRTC) - const webrtcElement = customElements.get('webrtc-camera') as any; - if (webrtcElement) { - const webrtc = new webrtcElement(); - webrtc.setConfig(config.webrtc || {}); - webrtc.hass = this._hass; - this._webrtcElement = webrtc; - } else { - throw new Error(localize('error.missing_webrtc')); - } - } - - this.config = config; - this._entitiesToMonitor = [ - ...(this.config.entities || []).map((entity) => entity.entity), - this.config.camera_entity, - ]; - this._changeView(); - } - - // Update the card view. - protected _changeView(view?: View | undefined): void { - if (view === undefined) { - this._view = new View({ view: this.config.view_default }); - } else { - this._view = view; - } - this._resetJSMPEGIfNecessary(); - } - - // Determine whether the card should be updated. - protected shouldUpdate(changedProps: PropertyValues): boolean { - if (!this.config) { - return false; - } - if (changedProps.has('config')) { - return true; - } - - const oldHass = changedProps.get('_hass') as HomeAssistant | undefined; - if (oldHass) { - // Home Assistant pumps a lot of updates through. Re-rendering the card is - // necessary at times (e.g. to update the 'clip' view as new clips - // arrive), but also is a jarring experience for the user (e.g. if they - // 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 a clip active playing. - if (this._interactionTimerID || this._clipPlaying) { - return false; - } - return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor); - } - return true; - } - - // Make a websocket request to Home Assistant. - protected async _makeWSRequest( - schema: ZodSchema, - request: MessageBase, - ): Promise { - if (!this._hass) { - return null; - } - - const response = await this._hass.callWS(request); - - if (!response) { - const error_message = `${localize('error.empty_response')}: ${JSON.stringify( - request, - )}`; - console.warn(error_message); - throw new Error(error_message); - } - const parseResult = schema.safeParse(response); - if (!parseResult.success) { - const keys = this._getParseErrorKeys(parseResult.error); - const error_message = - `${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` + - localize('error.invalid_keys') + - `: '${keys}'`; - console.warn(error_message); - throw new Error(error_message); - } - return parseResult.data; - } - - // Browse Frigate media with a media content id. - protected async _browseMedia( - media_content_id: string, - ): Promise { - const request = { - type: 'media_source/browse_media', - media_content_id: media_content_id, - }; - return this._makeWSRequest(browseMediaSourceSchema, request); - } - - // Browse Frigate media with query parameters. - protected async _browseMediaQuery( - want_clips?: boolean, - before?: number, - after?: number, - ): Promise { - return this._browseMedia( - // Defined in: - // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py - [ - 'media-source://frigate', - this.config.frigate_client_id, - 'event-search', - want_clips ? 'clips' : 'snapshots', - '', // Name/Title to render (not necessary here) - after ? String(after) : '', - before ? String(before) : '', - this.config.frigate_camera_name, - this.config.label, - this.config.zone, - ].join('/'), - ); - } - - // Resolve Frigate media identifier to a real URL. - protected async _resolveMedia( - mediaSource: BrowseMediaSource | null, - ): Promise { - if (!mediaSource) { - return null; - } - const request = { - type: 'media_source/resolve_media', - media_content_id: mediaSource.media_content_id, - }; - return this._makeWSRequest(resolvedMediaSchema, request); - } - - // Render an attention grabbing icon. - protected _renderAttentionIcon( - icon: string, - message: string | TemplateResult | null = null, - ): TemplateResult { - return html`
- - - ${message ? html` ${message}` : ''} - -
`; - } - - // Render an embedded error situation. - protected _renderError(error: string): TemplateResult { - return this._renderAttentionIcon( - 'mdi:alert-circle', - html`${error ? `${error}.` : `${localize('error.unknown_error')}.`} - ${localize('error.troubleshooting')}.`, - ); - } - - // Render Frigate events into a card gallery. - protected async _renderEvents(): Promise { - let parent; - try { - if (this._view.target) { - parent = await this._browseMedia(this._view.target.media_content_id); - } else { - parent = await this._browseMediaQuery(this._view.is('clips')); - } - } catch (e: any) { - return this._renderError(e.message); - } - - if (this._getFirstTrueMediaChildIndex(parent) == null) { - return this._renderAttentionIcon( - this._view.is('clips') ? 'mdi:filmstrip-off' : 'mdi:camera-off', - this._view.is('clips') - ? localize('common.no_clips') - : localize('common.no_snapshots'), - ); - } - - return html`
    - ${this._view.previous - ? html`
  • -
    -
    - { - this._changeView(this._view.previous); - }} - outlined="" - class="frigate-card-image-list-folder" - > - - -
    -
    -
  • ` - : ''} - ${parent.children.map( - (child, index) => - html`
  • -
    - ${child.can_expand - ? html`
    - { - this._changeView( - new View({ - view: this._view.view, - target: child, - previous: this._view, - }), - ); - }} - outlined="" - class="frigate-card-image-list-folder" - > -
    ${child.title}
    -
    -
    ` - : html` { - this._changeView( - new View({ - view: this._view.is('clips') ? 'clip' : 'snapshot', - target: parent, - childIndex: index, - previous: this._view, - }), - ); - }} - />`} -
    -
  • `, - )} -
`; - } - - // Render a progress spinner while content loads. - protected _renderProgressIndicator(): TemplateResult { - return html`
- -
`; - } - - protected _menuActionHandler(name: string): void { - switch (name) { - case 'frigate': - this._changeView(); - break; - case 'live': - case 'clips': - case 'snapshots': - this._changeView(new View({ view: name })); - break; - case 'frigate_ui': - const frigate_url = this._getFrigateURLFromContext(); - if (frigate_url) { - window.open(frigate_url); - } - break; - default: - // If it's unknown, it's assumed to be an entity_id. - fireEvent(this, 'hass-more-info', { entityId: name }); - } - } - - protected _extractEventStartTimeFromBrowseMedia( - browseMedia: BrowseMediaSource, - ): number | null { - // Example: 2021-08-27 20:57:22 [10s, Person 76%] - const result = browseMedia.title.match(/^(?.+) \[/); - if (result && result.groups) { - const iso_datetime_str = result.groups['iso_datetime']; - if (iso_datetime_str) { - const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true); - if (iso_datetime.isValid()) { - return iso_datetime.unix(); - } - } - } - return null; - } - - // Get the Frigate UI url. - protected _getFrigateURLFromContext(): string | null { - if (!this.config.frigate_url) { - return null; - } - if (this._view.is('live')) { - return `${this.config.frigate_url}/cameras/${this.config.frigate_camera_name}`; - } - return `${this.config.frigate_url}/events?camera=${this.config.frigate_camera_name}`; - } - - // From a BrowseMediaSource item extract the first true media item (i.e. a - // clip/snapshot, not a folder). - protected _getFirstTrueMediaChildIndex( - media: BrowseMediaSource | null, - ): number | null { - if (!media || !media.children) { - return null; - } - for (let i = 0; i < media.children.length; i++) { - if (!media.children[i].can_expand) { - return i; - } - } - return null; - } - - // Get the previous and next real media items, given the index - protected _getMediaNeighbors( - parent: BrowseMediaSource, - index: number | null, - ): BrowseMediaNeighbors | null { - if (index == null || !parent.children) { - return null; - } - - // Work backwards from the index to get the previous real media. - let prevIndex: number | null = null; - for (let i = index - 1; i >= 0; i--) { - const media = parent.children[i]; - if (media && !media.can_expand) { - prevIndex = i; - break; - } - } - - // Work forwards from the index to get the next real media. - let nextIndex: number | null = null; - for (let i = index + 1; i < parent.children.length; i++) { - const media = parent.children[i]; - if (media && !media.can_expand) { - nextIndex = i; - break; - } - } - - return { - previousIndex: prevIndex, - previous: prevIndex != null ? parent.children[prevIndex] : null, - nextIndex: nextIndex, - next: nextIndex != null ? parent.children[nextIndex] : null, - }; - } - - // Render the next/previous controls. - protected _renderNextPreviousControls( - previous: boolean, - parent?: BrowseMediaSource, - targetChildIndex?: number, - neighbor?: BrowseMediaSource, - ): TemplateResult { - if (!neighbor || this.config.controls?.nextprev === 'none') { - return html``; - } - - const classes = { - 'frigate-media-controls': true, - previous: previous, - next: !previous, - thumbnails: - !this.config.controls?.nextprev || - this.config.controls?.nextprev === 'thumbnails', - chevrons: this.config.controls?.nextprev === 'chevrons', - button: this.config.controls?.nextprev === 'chevrons', - }; - - const clickChangeView = () => { - this._view = new View({ - view: this._view.view, - target: parent, - childIndex: targetChildIndex, - previous: this._view, - }); - }; - - if (this.config.controls?.nextprev == 'chevrons') { - return html` `; - } - - if (!neighbor.thumbnail) { - return html``; - } - return html``; - } - - // Render the view for media. - protected async _renderViewer(): Promise { - let autoplay = true; - - let parent: BrowseMediaSource | null = null; - let childIndex: number | null = null; - let mediaToRender: BrowseMediaSource | null = null; - - if (this._view.target) { - parent = this._view.target; - childIndex = this._view.childIndex ?? null; - mediaToRender = this._view.media ?? null; - } else { - try { - parent = await this._browseMediaQuery(this._view.is('clip')); - } catch (e: any) { - return this._renderError(e.message); - } - childIndex = this._getFirstTrueMediaChildIndex(parent); - if (!parent || !parent.children || childIndex == null) { - return this._renderAttentionIcon( - this._view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off', - this._view.is('clip') - ? localize('common.no_clip') - : localize('common.no_snapshot'), - ); - } - mediaToRender = parent.children[childIndex]; - - // In this block, no clip has been manually selected, so this is loading - // the most recent clip on card load. In this mode, autoplay of the clip - // may be disabled by configuration. If does not make sense to disable - // autoplay when the user has explicitly picked an event to play in the - // gallery. - autoplay = this.config.autoplay_clip; - } - const resolvedMedia = await this._resolveMedia(mediaToRender); - if (!mediaToRender || !resolvedMedia) { - // Home Assistant could not resolve media item. - return this._renderError(localize('error.could_not_resolve')); - } - - const neighbors = this._getMediaNeighbors(parent, childIndex); - - return html` - ${this._renderNextPreviousControls( - true, - parent, - neighbors?.previousIndex ?? undefined, - neighbors?.previous ?? undefined, - )} - ${this._view.is('clip') - ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' - ? html` - ` - : html`` - : html` { - // Get clips potentially related to this snapshot. - this._findRelatedClips(mediaToRender).then((relatedClip) => { - if (relatedClip) { - this._changeView( - new View({ - view: 'clip', - target: relatedClip, - previous: this._view, - }), - ); - } - }); - }} - />`} - ${this._renderNextPreviousControls( - false, - parent, - neighbors?.nextIndex ?? undefined, - neighbors?.next ?? undefined, - )} - `; - } - - public updated(): void { - this.updateComplete.then(() => { - // DOM elements are not always present until after updateComplete promise - // is resolved. Note that children of children (i.e. the underlying video - // element) is not always present even when the promise returns, so - // capture the event at the upper shadow root instead. - const hls_player = this.renderRoot - ?.querySelector('ha-card') - ?.querySelector('ha-hls-player'); - - if (hls_player) { - hls_player.shadowRoot?.addEventListener( - 'play', - () => { - this._clipPlaying = true; - }, - true, - ); - hls_player.shadowRoot?.addEventListener( - 'pause', - () => { - this._clipPlaying = true; - }, - true, - ); - } - }); - } - - // Get a clip at the same time as a snapshot. - protected async _findRelatedClips( - snapshot: BrowseMediaSource | null, - ): Promise { - if (!snapshot) { - return null; - } - - const startTime = this._extractEventStartTimeFromBrowseMedia(snapshot); - if (startTime) { - try { - // Fetch clips within the same second (same camera/zone/label, etc). - const clipsAtSameTime = await this._browseMediaQuery( - true, - startTime + 1, - startTime, - ); - if (clipsAtSameTime) { - const index = this._getFirstTrueMediaChildIndex(clipsAtSameTime); - if (index != null && clipsAtSameTime.children?.length) { - return clipsAtSameTime.children[index]; - } - } - } catch (e: any) { - // Pass. This is best effort. - } - } - return null; - } - - protected async _getJSMPEGURL(): Promise { - if (!this._hass) { - return null; - } - - const request = { - type: 'auth/sign_path', - path: - `/api/frigate/${this.config.frigate_client_id}` + - `/jsmpeg/${this.config.frigate_camera_name}`, - }; - // Sign the path so it includes an authSig parameter. - let response; - try { - response = await this._makeWSRequest(signedPathSchema, request); - } catch (err) { - console.warn(err); - return null; - } - const url = this._hass.hassUrl(response.path); - return url.replace(/^http/i, 'ws'); - } - - protected _resetJSMPEGIfNecessary(): void { - if (!this._view.is('live') || this.config.live_provider != 'frigate-jsmpeg') { - if (this._jsmpegPlayer) { - this._jsmpegPlayer.destroy(); - this._jsmpegPlayer = null; - } - this._jsmpegCanvasElement = null; - } - } - - // Cleanup and/or start the JSMPEG player. - protected async _renderJSMPEGPlayer(): Promise { - if (!this._jsmpegCanvasElement) { - this._jsmpegCanvasElement = document.createElement('canvas'); - this._jsmpegCanvasElement.className = 'media'; - } - - if (!this._jsmpegPlayer) { - const jsmpeg_url = await this._getJSMPEGURL(); - - if (!jsmpeg_url) { - return this._renderError('Could not retrieve or sign JSMPEG websocket path'); - } - - // Return the html canvas node only after the JSMPEG video has loaded and - // is playing, to reduce the amount of time the user is staring at a blank - // white canvas (instead they get the progress spinner until this promise - // resolves). - return new Promise((resolve) => { - this._jsmpegPlayer = new JSMpeg.VideoElement( - this, - jsmpeg_url, - { - canvas: this._jsmpegCanvasElement, - hooks: { - play: () => { - resolve(html`${this._jsmpegCanvasElement}`); - }, - }, - }, - { protocols: [], videoBufferSize: 1024 * 1024 * 4 }, - ); - }); - } - return html`${this._jsmpegCanvasElement}`; - } - - // Render the live viewer. - // Note: The live viewer is the main element used to size the overall card. It - // is always rendered (but sometimes hidden). - protected async _renderLiveViewer(): Promise { - if (!this._hass || !(this.config.camera_entity in this._hass.states)) { - return this._renderAttentionIcon( - 'mdi:camera-off', - localize('error.no_live_camera'), - ); - } - if (this._webrtcElement) { - return html`${this._webrtcElement}`; - } - if (this.config.live_provider == 'frigate-jsmpeg') { - return await this._renderJSMPEGPlayer(); - } - return html` - `; - } - - // Record interactions with the card. - protected _interactionHandler(): void { - if (!this.config.view_timeout) { - return; - } - if (this._interactionTimerID) { - window.clearTimeout(this._interactionTimerID); - } - this._interactionTimerID = window.setTimeout(() => { - this._interactionTimerID = null; - this._changeView(); - }, this.config.view_timeout * 1000); - } - - protected _renderMenu(): TemplateResult | void { - const classes = { - 'hover-menu': this.config.menu_mode.startsWith('hover-'), - }; - return html` - - `; - } - - // Render the call (master render method). - protected render(): TemplateResult | void { - if (this.config.show_warning) { - return this._showWarning(localize('common.show_warning')); - } - if (this.config.show_error) { - return this._showError(localize('common.show_error')); - } - return html` - ${this.config.menu_mode == 'above' ? this._renderMenu() : ''} -
-
- ${this._view.is('clips') || this._view.is('snapshots') - ? until(this._renderEvents(), this._renderProgressIndicator()) - : ``} - ${this._view.is('clip') || this._view.is('snapshot') - ? until(this._renderViewer(), this._renderProgressIndicator()) - : ``} - ${this._view.is('live') - ? until(this._renderLiveViewer(), this._renderProgressIndicator()) - : ``} -
-
- ${this.config.menu_mode != 'above' ? this._renderMenu() : ''} -
`; - } - - // Show a warning card. - private _showWarning(warning: string): TemplateResult { - return html` ${warning} `; - } - - // Show an error card. - private _showError(error: string): TemplateResult { - const errorCard = document.createElement('hui-error-card'); - errorCard.setConfig({ - type: 'error', - error, - origConfig: this.config, - }); - - return html` ${errorCard} `; - } - - // Return compiled CSS styles (thus safe to use with unsafeCSS). - static get styles(): CSSResultGroup { - return unsafeCSS(frigate_card_style); - } - - // Get the Lovelace card size. - public getCardSize(): number { - return 6; - } -} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 0f46a492..a9d75f48 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -84,6 +84,7 @@ "could_not_resolve": "Could not resolve media URL", "no_live_camera": "No live camera", "invalid_configuration": "Invalid configuration", - "missing_webrtc": "WebRTC component not found" + "missing_webrtc": "WebRTC component not found", + "internal": "Internal error" } } diff --git a/src/scss/common.scss b/src/scss/button.scss similarity index 99% rename from src/scss/common.scss rename to src/scss/button.scss index a8dac10b..9c4746a3 100644 --- a/src/scss/common.scss +++ b/src/scss/button.scss @@ -13,4 +13,4 @@ ha-icon-button.button { ha-icon-button.button.emphasize { color: var(--primary-color, white); -} \ No newline at end of file +} diff --git a/src/scss/card.scss b/src/scss/card.scss index 2055ea1b..fce1e04f 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -1,7 +1,3 @@ -@use "@material/image-list/mdc-image-list"; -@use "@material/image-list"; -@use './common.scss'; - .container_16_9 { /* 16:9 Aspect Ratio. When Safari supports 'aspect-ratio' this should not be necessary */ @@ -22,7 +18,12 @@ scrollbar-width: none; /* Hide scrollbar: Firefox */ } -/* Support the 'hover' menu mode. */ +/* Hide scrollbar for Chrome, Safari and Opera */ +.frigate-card-contents::-webkit-scrollbar { + display: none; +} + +/* The 'hover' menu mode is styling applied outside of the menu itself */ .hover-menu { z-index: 1; transition: all 0.5s ease; @@ -34,47 +35,6 @@ opacity: 1.0; } -.frigate-card-contents img.media,video.media,canvas.media { - width: 100%; -} - -/* Hide scrollbar for Chrome, Safari and Opera */ -.frigate-card-contents::-webkit-scrollbar { - display: none; -} - -.frigate-card-contents .attention { - height: 100%; - display: flex; - justify-content: center; - align-items: center; - box-sizing: border-box; - padding: 10%; -} - -.frigate-card-image-list { - @include image-list.standard-columns(5, 1px); - @include image-list.shape-radius(5px); -} - -ha-card .frigate-card-image-list-folder { - display: flex; - justify-content: center; - align-items: center; - box-sizing: border-box; - text-align: center; - border-style: 2px solid; - opacity: 0.7; - color: var(--secondary-text-color, white); - border-color: var(--secondary-text-color, black); - background-color: var(--primary-background-color, black); - padding: 10px; -} - -video, img { - display: block; -} - ha-card { display: flex; flex-direction: column; @@ -85,55 +45,8 @@ ha-card { position: relative; color: var(--secondary-text-color, white); background-color: var(--secondary-background-color, black); - - transform-style: preserve-3d; /* Safari brings video elements forward without this */ } ha-card a { color: var(--primary-text-color, white); -} - -/* Don't drop shadow or have radius for nested webrtc card */ -webrtc-camera ha-card { - box-shadow: none; - border-radius: 0px; - background-color: var(--secondary-background-color, black); -} - -.frigate-media-controls { - position: absolute; - z-index: 1; - overflow: hidden; -} -.frigate-media-controls.previous { - left: 45px; -} -.frigate-media-controls.next { - right: 45px; -} - -.frigate-media-controls.chevrons { - top: calc(50% - (40px / 2)); -} - -.frigate-media-controls.thumbnails { - border-radius: 50%; - height: 48px; - top: calc(50% - (48px / 2)); - box-shadow: 0px 0px 30px 1px black; - transition: all .2s ease; - opacity: 0.8; -} -.frigate-media-controls.thumbnails:hover { - opacity: 1 !important; - height: 72px; - top: calc(50% - (72px / 2)); -} - -.frigate-media-controls.previous.thumbnails:hover { - left: 33px; -} - -.frigate-media-controls.next.thumbnails:hover { - right: 33px; } \ No newline at end of file diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss new file mode 100644 index 00000000..f1731a48 --- /dev/null +++ b/src/scss/gallery.scss @@ -0,0 +1,22 @@ +@use "@material/image-list/mdc-image-list"; +@use "@material/image-list"; + +.frigate-card-gallery { + @include image-list.standard-columns(5, 1px); + @include image-list.shape-radius(5px); +} + +ha-card.frigate-card-gallery-folder { + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + text-align: center; + border-style: 2px solid; + opacity: 0.7; + color: var(--secondary-text-color, white); + border-color: var(--secondary-text-color, black); + background-color: var(--primary-background-color, black); + padding: 10px; + height: 100%; +} \ No newline at end of file diff --git a/src/scss/live.scss b/src/scss/live.scss new file mode 100644 index 00000000..790803c8 --- /dev/null +++ b/src/scss/live.scss @@ -0,0 +1,10 @@ +canvas { + width: 100%; +} + +/* Don't drop shadow or have radius for nested webrtc card */ +webrtc-camera ha-card { + box-shadow: none; + border-radius: 0px; + background-color: var(--secondary-background-color, black); +} \ No newline at end of file diff --git a/src/scss/menu.scss b/src/scss/menu.scss index ede9f006..1598ab61 100644 --- a/src/scss/menu.scss +++ b/src/scss/menu.scss @@ -1,4 +1,4 @@ -@use './common.scss'; +@use './button.scss'; .frigate-card-menu { z-index: 1; @@ -37,4 +37,4 @@ .frigate-card-menu.full { width: 100%; background: var(--secondary-background-color); -} \ No newline at end of file +} diff --git a/src/scss/message.scss b/src/scss/message.scss new file mode 100644 index 00000000..8752733f --- /dev/null +++ b/src/scss/message.scss @@ -0,0 +1,8 @@ +.message { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + padding: 10%; +} \ No newline at end of file diff --git a/src/scss/next-previous-control.scss b/src/scss/next-previous-control.scss new file mode 100644 index 00000000..cb21f094 --- /dev/null +++ b/src/scss/next-previous-control.scss @@ -0,0 +1,39 @@ +@use './button.scss'; + +.controls { + position: absolute; + z-index: 1; + overflow: hidden; +} +.controls.previous { + left: 45px; +} +.controls.next { + right: 45px; +} + +.controls.chevrons { + top: calc(50% - (40px / 2)); +} + +.controls.thumbnails { + border-radius: 50%; + height: 48px; + top: calc(50% - (48px / 2)); + box-shadow: 0px 0px 30px 1px black; + transition: all .2s ease; + opacity: 0.8; +} +.controls.thumbnails:hover { + opacity: 1 !important; + height: 72px; + top: calc(50% - (72px / 2)); +} + +.controls.previous.thumbnails:hover { + left: 33px; +} + +.controls.next.thumbnails:hover { + right: 33px; +} diff --git a/src/scss/viewer.scss b/src/scss/viewer.scss new file mode 100644 index 00000000..ab01dce9 --- /dev/null +++ b/src/scss/viewer.scss @@ -0,0 +1,7 @@ +ha-hls-player { + transform-style: preserve-3d; +} +img,video { + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 425c6043..eca53815 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,4 @@ -import { - LovelaceCard, - LovelaceCardEditor, -} from 'custom-card-helpers'; +import { LovelaceCard, LovelaceCardEditor } from 'custom-card-helpers'; import { z } from 'zod'; declare global { @@ -43,12 +40,17 @@ export const FRIGATE_MENU_MODES = [ ] as const; export type FrigateMenuMode = typeof FRIGATE_MENU_MODES[number]; +export const NEXT_PREVIOUS_CONTROL_STYLES = ['none', 'thumbnails', 'chevrons'] as const; +export type NextPreviousControlStyle = typeof NEXT_PREVIOUS_CONTROL_STYLES[number]; + +export const LIVE_PROVIDERS = ['frigate', 'frigate-jsmpeg', 'webrtc'] as const; +export type LiveProvider = typeof LIVE_PROVIDERS[number]; export const frigateCardConfigSchema = z.object({ camera_entity: z.string(), // No URL validation to allow relative URLs within HA (e.g. addons). frigate_url: z.string().optional(), - frigate_client_id: z.string().optional().default("frigate"), + frigate_client_id: z.string().optional().default('frigate'), frigate_camera_name: z.string().optional(), view_default: z.enum(FRIGATE_CARD_VIEWS).optional().default('live'), view_timeout: z @@ -59,31 +61,42 @@ export const frigateCardConfigSchema = z.object({ .regex(/^\d+$/) .transform((val) => Number(val)), ) - .optional().default(180), - live_provider: z.enum(['frigate', 'frigate-jsmpeg', 'webrtc']).default('frigate'), - webrtc: z.object({ - entity: z.string().optional(), - url: z.string().optional(), - }).passthrough().optional(), + .optional() + .default(180), + live_provider: z.enum(LIVE_PROVIDERS).default('frigate'), + webrtc: z + .object({ + entity: z.string().optional(), + url: z.string().optional(), + }) + .passthrough() + .optional(), label: z.string().optional(), zone: z.string().optional(), autoplay_clip: z.boolean().default(false), menu_mode: z.enum(FRIGATE_MENU_MODES).optional().default('hidden-top'), - menu_buttons: z.object({ - frigate: z.boolean().default(true), - live: z.boolean().default(true), - clips: z.boolean().default(true), - snapshots: z.boolean().default(true), - frigate_ui: z.boolean().default(true), - }).optional(), - entities: z.object({ - entity: z.string(), - show: z.boolean().default(true), - icon: z.string().optional(), - }).array().optional(), - controls: z.object({ - nextprev: z.enum(['thumbnails', 'chevrons', 'none']).default('thumbnails'), - }).optional(), + menu_buttons: z + .object({ + frigate: z.boolean().default(true), + live: z.boolean().default(true), + clips: z.boolean().default(true), + snapshots: z.boolean().default(true), + frigate_ui: z.boolean().default(true), + }) + .optional(), + entities: z + .object({ + entity: z.string(), + show: z.boolean().default(true), + icon: z.string().optional(), + }) + .array() + .optional(), + controls: z + .object({ + nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'), + }) + .optional(), // Stock lovelace card config. type: z.string(), @@ -103,6 +116,16 @@ export interface ExtendedHomeAssistant { hassUrl(path?): string; } +export interface BrowseMediaQueryParameters { + mediaType: 'clips' | 'snapshots'; + clientId: string; + cameraName: string; + label?: string; + zone?: string; + before?: number; + after?: number; +} + /** * Media Browser API types. */ @@ -119,7 +142,7 @@ export interface BrowseMediaSource { can_play: boolean; can_expand: boolean; children_media_class: string | null; - thumbnail: string | null + thumbnail: string | null; children?: BrowseMediaSource[] | null; } @@ -134,7 +157,7 @@ export const browseMediaSourceSchema: z.ZodSchema = z.lazy(() children_media_class: z.string().nullable(), thumbnail: z.string().nullable(), children: z.array(browseMediaSourceSchema).nullable().optional(), - }) + }), ); // Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py diff --git a/src/view.ts b/src/view.ts new file mode 100644 index 00000000..067e9a68 --- /dev/null +++ b/src/view.ts @@ -0,0 +1,46 @@ +import type { BrowseMediaSource, FrigateCardView } from './types'; + +export interface ViewParameters { + view?: FrigateCardView; + target?: BrowseMediaSource; + childIndex?: number; + previous?: View; +} + +export class View { + view: FrigateCardView; + target?: BrowseMediaSource; + childIndex?: number; + previous?: View; + + constructor(params?: ViewParameters) { + this.view = params?.view || 'live'; + this.target = params?.target; + this.childIndex = params?.childIndex; + this.previous = params?.previous; + } + + public is(name: string): boolean { + return this.view == name; + } + + get media(): BrowseMediaSource | undefined { + if (this.target) { + if (this.target.children && this.childIndex !== undefined) { + return this.target.children[this.childIndex]; + } + return this.target; + } + return undefined; + } + + public dispatchChangeEvent(node: HTMLElement): void { + node.dispatchEvent( + new CustomEvent('frigate-card:change-view', { + bubbles: true, + composed: true, + detail: this, + }), + ); + } +}