From 63a5a93eeb10b3f28b1b0d35d5559b1fcebd1660 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 18 Sep 2021 20:04:41 -0700 Subject: [PATCH 1/9] Componentize messages, error messages and progress indicator. --- package.json | 1 + rollup.config.js | 6 ++- src/{frigate-hass-card.ts => main.ts} | 64 ++++++----------------- src/message.ts | 75 +++++++++++++++++++++++++++ src/scss/message.scss | 8 +++ 5 files changed, 105 insertions(+), 49 deletions(-) rename src/{frigate-hass-card.ts => main.ts} (95%) create mode 100644 src/message.ts create mode 100644 src/scss/message.scss 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..ab52b946 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/main.ts'], output: { - dir: 'dist', + file: 'dist/frigate-hass-card.js', format: 'es', }, plugins: [...plugins], diff --git a/src/frigate-hass-card.ts b/src/main.ts similarity index 95% rename from src/frigate-hass-card.ts rename to src/main.ts index 8a30d99a..bfffab27 100644 --- a/src/frigate-hass-card.ts +++ b/src/main.ts @@ -11,6 +11,8 @@ import { customElement, property, query, state } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map.js'; import { until } from 'lit/directives/until.js'; +import { renderMessage, renderErrorMessage, renderProgressIndicator } from './message'; + import { HomeAssistant, LovelaceCardEditor, @@ -50,9 +52,6 @@ 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); @@ -520,28 +519,6 @@ export class FrigateCard extends LitElement { 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; @@ -552,15 +529,15 @@ export class FrigateCard extends LitElement { parent = await this._browseMediaQuery(this._view.is('clips')); } } catch (e: any) { - return this._renderError(e.message); + return renderErrorMessage(e.message); } if (this._getFirstTrueMediaChildIndex(parent) == null) { - return this._renderAttentionIcon( - this._view.is('clips') ? 'mdi:filmstrip-off' : 'mdi:camera-off', + return renderMessage( this._view.is('clips') - ? localize('common.no_clips') - : localize('common.no_snapshots'), + ? localize('common.no_clips') + : localize('common.no_snapshots'), + this._view.is('clips') ? 'mdi:filmstrip-off' : 'mdi:camera-off', ); } @@ -625,13 +602,6 @@ export class FrigateCard extends LitElement { `; } - // Render a progress spinner while content loads. - protected _renderProgressIndicator(): TemplateResult { - return html`
- -
`; - } - protected _menuActionHandler(name: string): void { switch (name) { case 'frigate': @@ -802,15 +772,15 @@ export class FrigateCard extends LitElement { try { parent = await this._browseMediaQuery(this._view.is('clip')); } catch (e: any) { - return this._renderError(e.message); + return renderErrorMessage(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', + 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]; @@ -825,7 +795,7 @@ export class FrigateCard extends LitElement { const resolvedMedia = await this._resolveMedia(mediaToRender); if (!mediaToRender || !resolvedMedia) { // Home Assistant could not resolve media item. - return this._renderError(localize('error.could_not_resolve')); + return renderErrorMessage(localize('error.could_not_resolve')); } const neighbors = this._getMediaNeighbors(parent, childIndex); @@ -998,7 +968,7 @@ export class FrigateCard extends LitElement { const jsmpeg_url = await this._getJSMPEGURL(); if (!jsmpeg_url) { - return this._renderError('Could not retrieve or sign JSMPEG websocket path'); + return renderErrorMessage('Could not retrieve or sign JSMPEG websocket path'); } // Return the html canvas node only after the JSMPEG video has loaded and @@ -1029,9 +999,9 @@ export class FrigateCard extends LitElement { // 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', + return renderMessage( localize('error.no_live_camera'), + 'mdi:camera-off', ); } if (this._webrtcElement) { @@ -1091,13 +1061,13 @@ export class FrigateCard extends LitElement {
${this._view.is('clips') || this._view.is('snapshots') - ? until(this._renderEvents(), this._renderProgressIndicator()) + ? until(this._renderEvents(), renderProgressIndicator()) : ``} ${this._view.is('clip') || this._view.is('snapshot') - ? until(this._renderViewer(), this._renderProgressIndicator()) + ? until(this._renderViewer(), renderProgressIndicator()) : ``} ${this._view.is('live') - ? until(this._renderLiveViewer(), this._renderProgressIndicator()) + ? until(this._renderLiveViewer(), renderProgressIndicator()) : ``}
diff --git a/src/message.ts b/src/message.ts new file mode 100644 index 00000000..34ddd999 --- /dev/null +++ b/src/message.ts @@ -0,0 +1,75 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { localize } from './localize/localize'; + +import frigate_card_message_style 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(frigate_card_message_style); + } +} + +@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(frigate_card_message_style); + } +} + +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/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 From 023c66b137ea4a192de0bcbafd81faae9c3d0131 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 18 Sep 2021 20:35:22 -0700 Subject: [PATCH 2/9] Convert menu to a separate component file. --- rollup.config.js | 2 +- src/{main.ts => card.ts} | 130 ++------------------------------ src/components/menu.ts | 121 +++++++++++++++++++++++++++++ src/{ => components}/message.ts | 9 +-- 4 files changed, 134 insertions(+), 128 deletions(-) rename src/{main.ts => card.ts} (88%) create mode 100644 src/components/menu.ts rename src/{ => components}/message.ts (90%) diff --git a/rollup.config.js b/rollup.config.js index ab52b946..3b875029 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -44,7 +44,7 @@ const plugins = [ export default [ { - input: ['src/main.ts'], + input: ['src/card.ts'], output: { file: 'dist/frigate-hass-card.js', format: 'es', diff --git a/src/main.ts b/src/card.ts similarity index 88% rename from src/main.ts rename to src/card.ts index bfffab27..7de4addf 100644 --- a/src/main.ts +++ b/src/card.ts @@ -11,7 +11,8 @@ import { customElement, property, query, state } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map.js'; import { until } from 'lit/directives/until.js'; -import { renderMessage, renderErrorMessage, renderProgressIndicator } from './message'; +import { FrigateCardMenu } from './components/menu'; +import { renderMessage, renderErrorMessage, renderProgressIndicator } from './components/message'; import { HomeAssistant, @@ -22,9 +23,11 @@ import { } from 'custom-card-helpers'; import './editor'; +import './components/menu' +import './components/message' -import frigate_card_style from './scss/card.scss'; -import frigate_card_menu_style from './scss/menu.scss'; + +import cardStyle from './scss/card.scss'; import { MenuButton, @@ -39,7 +42,6 @@ import type { ExtendedHomeAssistant, FrigateCardConfig, FrigateCardView, - FrigateMenuMode, ResolvedMedia, } from './types'; import { CARD_VERSION } from './const'; @@ -70,8 +72,6 @@ console.info( 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, @@ -100,119 +100,6 @@ function shouldUpdateBasedOnHass( 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; @@ -285,7 +172,7 @@ export class FrigateCard extends LitElement { // Whether or not there is an active clip being played. protected _clipPlaying = false; - @query(`#${FrigateCardMenu.FRIGATE_CARD_MENU_ID}`) + @query("frigate-card-menu") _menu!: FrigateCardMenu | null; // A small cache to avoid needing to create a new list of entities every time @@ -1039,7 +926,6 @@ export class FrigateCard extends LitElement { }; return html` 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/message.ts b/src/components/message.ts similarity index 90% rename from src/message.ts rename to src/components/message.ts index 34ddd999..cb9683ec 100644 --- a/src/message.ts +++ b/src/components/message.ts @@ -1,8 +1,7 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators'; -import { localize } from './localize/localize'; - -import frigate_card_message_style from './scss/message.scss'; +import { localize } from '../localize/localize'; +import messageStyle from '../scss/message.scss'; const URL_TROUBLESHOOTING = 'https://github.com/dermotduffy/frigate-hass-card#troubleshooting'; @@ -26,7 +25,7 @@ export class FrigateCardMessage extends LitElement { } static get styles(): CSSResultGroup { - return unsafeCSS(frigate_card_message_style); + return unsafeCSS(messageStyle); } } @@ -54,7 +53,7 @@ export class FrigateCardProgressIndicator extends LitElement { } static get styles(): CSSResultGroup { - return unsafeCSS(frigate_card_message_style); + return unsafeCSS(messageStyle); } } From 9bbf46d4a59c678d2bafbd3319ffa81fa484e695 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 19 Sep 2021 13:36:34 -0700 Subject: [PATCH 3/9] Move gallery to component. --- src/card.ts | 155 +++++---------------------------- src/common.ts | 100 +++++++++++++++++++++ src/components/gallery.ts | 140 +++++++++++++++++++++++++++++ src/localize/languages/en.json | 3 +- src/scss/card.scss | 19 ---- src/scss/gallery.scss | 22 +++++ src/view.ts | 46 ++++++++++ 7 files changed, 334 insertions(+), 151 deletions(-) create mode 100644 src/common.ts create mode 100644 src/components/gallery.ts create mode 100644 src/scss/gallery.scss create mode 100644 src/view.ts diff --git a/src/card.ts b/src/card.ts index 7de4addf..cc1f67db 100644 --- a/src/card.ts +++ b/src/card.ts @@ -10,10 +10,13 @@ import { import { customElement, property, query, state } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map.js'; import { until } from 'lit/directives/until.js'; - +import { View } from './view'; import { FrigateCardMenu } from './components/menu'; -import { renderMessage, renderErrorMessage, renderProgressIndicator } from './components/message'; - +import { + renderMessage, + renderErrorMessage, + renderProgressIndicator, +} from './components/message'; import { HomeAssistant, LovelaceCardEditor, @@ -23,9 +26,9 @@ import { } from 'custom-card-helpers'; import './editor'; -import './components/menu' -import './components/message' - +import './components/menu'; +import './components/message'; +import './components/gallery'; import cardStyle from './scss/card.scss'; @@ -41,7 +44,6 @@ import type { BrowseMediaSource, ExtendedHomeAssistant, FrigateCardConfig, - FrigateCardView, ResolvedMedia, } from './types'; import { CARD_VERSION } from './const'; @@ -100,41 +102,6 @@ function shouldUpdateBasedOnHass( return false; } -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 { @@ -172,7 +139,7 @@ export class FrigateCard extends LitElement { // Whether or not there is an active clip being played. protected _clipPlaying = false; - @query("frigate-card-menu") + @query('frigate-card-menu') _menu!: FrigateCardMenu | null; // A small cache to avoid needing to create a new list of entities every time @@ -291,6 +258,9 @@ export class FrigateCard extends LitElement { this._changeView(); } + protected _changeViewHandler(e: CustomEvent): void { + this._changeView(e.detail); + } // Update the card view. protected _changeView(view?: View | undefined): void { if (view === undefined) { @@ -406,89 +376,6 @@ export class FrigateCard extends LitElement { return this._makeWSRequest(resolvedMediaSchema, request); } - // 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 renderErrorMessage(e.message); - } - - if (this._getFirstTrueMediaChildIndex(parent) == null) { - return renderMessage( - this._view.is('clips') - ? localize('common.no_clips') - : localize('common.no_snapshots'), - this._view.is('clips') ? 'mdi:filmstrip-off' : 'mdi:camera-off', - ); - } - - 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, - }), - ); - }} - />`} -
    -
  • `, - )} -
`; - } - protected _menuActionHandler(name: string): void { switch (name) { case 'frigate': @@ -886,10 +773,7 @@ export class FrigateCard extends LitElement { // is always rendered (but sometimes hidden). protected async _renderLiveViewer(): Promise { if (!this._hass || !(this.config.camera_entity in this._hass.states)) { - return renderMessage( - localize('error.no_live_camera'), - 'mdi:camera-off', - ); + return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off'); } if (this._webrtcElement) { return html`${this._webrtcElement}`; @@ -947,7 +831,16 @@ export class FrigateCard extends LitElement {
${this._view.is('clips') || this._view.is('snapshots') - ? until(this._renderEvents(), renderProgressIndicator()) + ? html` + ` : ``} ${this._view.is('clip') || this._view.is('snapshot') ? until(this._renderViewer(), renderProgressIndicator()) diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 00000000..3e6fddcc --- /dev/null +++ b/src/common.ts @@ -0,0 +1,100 @@ +import { ZodSchema, z } from 'zod'; +import { MessageBase } from 'home-assistant-js-websocket'; +import { HomeAssistant } from 'custom-card-helpers'; +import { localize } from './localize/localize'; +import { BrowseMediaSource, browseMediaSourceSchema, ExtendedHomeAssistant } 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); +} + +interface BrowseMediaQueryParameters { + hass: HomeAssistant & ExtendedHomeAssistant, + mediaType: "clips" | "snapshots", + clientId: string, + cameraName: string, + label?: string, + zone?: string, + before?: number, + after?: number, +} + +// Browse Frigate media with query parameters. +export async function browseMediaQuery(params: BrowseMediaQueryParameters): Promise { + return browseMedia( + params.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('/'), + ); +} diff --git a/src/components/gallery.ts b/src/components/gallery.ts new file mode 100644 index 00000000..2eb7a217 --- /dev/null +++ b/src/components/gallery.ts @@ -0,0 +1,140 @@ +/* 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 { renderMessage, renderErrorMessage, renderProgressIndicator } from './message'; + +import { HomeAssistant } from 'custom-card-helpers'; + +import galleryStyle from '../scss/gallery.scss'; + +import type { ExtendedHomeAssistant } from '../types'; +import { localize } from '../localize/localize'; + +import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common'; +import { View } from '../view'; + +@customElement('frigate-card-gallery') +export class FrigateCardGallery extends LitElement { + @property({ attribute: false }) + protected hass: (HomeAssistant & ExtendedHomeAssistant) | null = null; + + @property({ attribute: false }) + protected cameraName: string | null = null; + + @property({ attribute: false }) + protected clientId: string | null = null; + + @property({ attribute: false }) + protected view: View | null = null; + + @property({ attribute: false }) + protected label?: string; + + @property({ attribute: false }) + protected zone?: string; + + protected _getMediaType(): 'clips' | 'snapshots' { + return this.view?.view == 'clips' ? 'clips' : 'snapshots'; + } + + protected render(): TemplateResult | void { + return html`${until(this._renderEvents(), renderProgressIndicator())}`; + } + + protected async _renderEvents(): Promise { + if (!this.hass || !this.clientId || !this.cameraName || !this.view) { + return renderErrorMessage(localize('error.internal')); + } + + let parent; + try { + if (this.view.target) { + parent = await browseMedia(this.hass, this.view.target.media_content_id); + } else { + parent = await browseMediaQuery({ + hass: this.hass, + clientId: this.clientId, + mediaType: this._getMediaType(), + cameraName: this.cameraName, + label: this.label, + zone: this.zone, + }); + } + } catch (e: any) { + return renderErrorMessage(e.message); + } + + if (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/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/card.scss b/src/scss/card.scss index 2055ea1b..7d72a96d 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -1,5 +1,3 @@ -@use "@material/image-list/mdc-image-list"; -@use "@material/image-list"; @use './common.scss'; .container_16_9 { @@ -52,24 +50,7 @@ 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; 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/view.ts b/src/view.ts new file mode 100644 index 00000000..1c693eb8 --- /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 generateChangeEvent(node: HTMLElement): void { + node.dispatchEvent( + new CustomEvent('frigate-card:change-view', { + bubbles: true, + composed: true, + detail: this, + }), + ); + } +} From 6498ae22cd193dd58234a6085e932ffa82915bc7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 19 Sep 2021 20:59:40 -0700 Subject: [PATCH 4/9] Convert viewer to Lit component. --- src/card.ts | 403 +++------------------------- src/common.ts | 45 ++-- src/components/gallery.ts | 52 ++-- src/components/next-prev-control.ts | 74 +++++ src/components/viewer.ts | 268 ++++++++++++++++++ src/scss/card.scss | 40 --- src/scss/common.scss | 4 + src/scss/next-previous-control.scss | 39 +++ src/scss/viewer.scss | 3 + src/types.ts | 74 +++-- src/view.ts | 2 +- 11 files changed, 522 insertions(+), 482 deletions(-) create mode 100644 src/components/next-prev-control.ts create mode 100644 src/components/viewer.ts create mode 100644 src/scss/next-previous-control.scss create mode 100644 src/scss/viewer.scss diff --git a/src/card.ts b/src/card.ts index cc1f67db..0c8bba72 100644 --- a/src/card.ts +++ b/src/card.ts @@ -29,35 +29,25 @@ import './editor'; import './components/menu'; import './components/message'; import './components/gallery'; +import './components/viewer'; import cardStyle from './scss/card.scss'; import { MenuButton, - browseMediaSourceSchema, frigateCardConfigSchema, - resolvedMediaSchema, signedPathSchema, } from './types'; import type { - BrowseMediaNeighbors, - BrowseMediaSource, + BrowseMediaQueryParameters, ExtendedHomeAssistant, FrigateCardConfig, - 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'; - -// Load dayjs plugin(s). -dayjs.extend(dayjs_custom_parse_format); +import { getParseErrorKeys, homeAssistantWSRequest } from './common'; /* eslint no-console: 0 */ console.info( @@ -206,11 +196,6 @@ export class FrigateCard extends LitElement { 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) { @@ -219,7 +204,7 @@ export class FrigateCard extends LitElement { const parseResult = frigateCardConfigSchema.safeParse(inputConfig); if (!parseResult.success) { - const keys = this._getParseErrorKeys(parseResult.error); + const keys = getParseErrorKeys(parseResult.error); throw new Error(localize('error.invalid_configuration') + ': ' + keys.join(', ')); } const config = parseResult.data; @@ -259,8 +244,16 @@ export class FrigateCard extends LitElement { } protected _changeViewHandler(e: CustomEvent): void { - this._changeView(e.detail); + const view = e.detail; + + if (view === undefined) { + this._view = new View({ view: this.config.view_default }); + } else { + this._view = view; + } + this._resetJSMPEGIfNecessary(); } + // Update the card view. protected _changeView(view?: View | undefined): void { if (view === undefined) { @@ -296,86 +289,6 @@ export class FrigateCard extends LitElement { 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); - } - protected _menuActionHandler(name: string): void { switch (name) { case 'frigate': @@ -398,23 +311,6 @@ export class FrigateCard extends LitElement { } } - 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) { @@ -426,219 +322,6 @@ export class FrigateCard extends LitElement { 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 renderErrorMessage(e.message); - } - childIndex = this._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.config.autoplay_clip; - } - 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` - ${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 @@ -668,36 +351,6 @@ export class FrigateCard extends LitElement { }); } - // 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; @@ -712,7 +365,7 @@ export class FrigateCard extends LitElement { // Sign the path so it includes an authSig parameter. let response; try { - response = await this._makeWSRequest(signedPathSchema, request); + response = await homeAssistantWSRequest(this._hass, signedPathSchema, request); } catch (err) { console.warn(err); return null; @@ -818,6 +471,19 @@ export class FrigateCard extends LitElement { `; } + 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) { @@ -833,17 +499,22 @@ export class FrigateCard extends LitElement { ${this._view.is('clips') || this._view.is('snapshots') ? html` ` : ``} ${this._view.is('clip') || this._view.is('snapshot') - ? until(this._renderViewer(), renderProgressIndicator()) + ? html` + ` : ``} ${this._view.is('live') ? until(this._renderLiveViewer(), renderProgressIndicator()) diff --git a/src/common.ts b/src/common.ts index 3e6fddcc..79898353 100644 --- a/src/common.ts +++ b/src/common.ts @@ -2,7 +2,12 @@ import { ZodSchema, z } from 'zod'; import { MessageBase } from 'home-assistant-js-websocket'; import { HomeAssistant } from 'custom-card-helpers'; import { localize } from './localize/localize'; -import { BrowseMediaSource, browseMediaSourceSchema, ExtendedHomeAssistant } from './types'; +import type { + BrowseMediaQueryParameters, + BrowseMediaSource, + ExtendedHomeAssistant, +} from './types'; +import { browseMediaSourceSchema } from './types'; export function getParseErrorKeys(error: z.ZodError): string[] { const errors = error.format(); @@ -54,7 +59,7 @@ export function getFirstTrueMediaChildIndex( // Browse Frigate media with a media content id. export async function browseMedia( - hass: HomeAssistant & ExtendedHomeAssistant | null, + hass: (HomeAssistant & ExtendedHomeAssistant) | null, media_content_id: string, ): Promise { if (!hass) { @@ -67,21 +72,13 @@ export async function browseMedia( return homeAssistantWSRequest(hass, browseMediaSourceSchema, request); } -interface BrowseMediaQueryParameters { - hass: HomeAssistant & ExtendedHomeAssistant, - mediaType: "clips" | "snapshots", - clientId: string, - cameraName: string, - label?: string, - zone?: string, - before?: number, - after?: number, -} - // Browse Frigate media with query parameters. -export async function browseMediaQuery(params: BrowseMediaQueryParameters): Promise { +export async function browseMediaQuery( + hass: HomeAssistant & ExtendedHomeAssistant, + params: BrowseMediaQueryParameters, +): Promise { return browseMedia( - params.hass, + hass, // Defined in: // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py [ @@ -98,3 +95,21 @@ export async function browseMediaQuery(params: BrowseMediaQueryParameters): Prom ].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 index 2eb7a217..0b1148a9 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -9,7 +9,11 @@ import { HomeAssistant } from 'custom-card-helpers'; import galleryStyle from '../scss/gallery.scss'; -import type { ExtendedHomeAssistant } from '../types'; +import type { + BrowseMediaSource, + BrowseMediaQueryParameters, + ExtendedHomeAssistant, +} from '../types'; import { localize } from '../localize/localize'; import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common'; @@ -18,22 +22,13 @@ import { View } from '../view'; @customElement('frigate-card-gallery') export class FrigateCardGallery extends LitElement { @property({ attribute: false }) - protected hass: (HomeAssistant & ExtendedHomeAssistant) | null = null; + protected hass!: HomeAssistant & ExtendedHomeAssistant; @property({ attribute: false }) - protected cameraName: string | null = null; + protected view!: View; @property({ attribute: false }) - protected clientId: string | null = null; - - @property({ attribute: false }) - protected view: View | null = null; - - @property({ attribute: false }) - protected label?: string; - - @property({ attribute: false }) - protected zone?: string; + protected browseMediaQueryParameters!: BrowseMediaQueryParameters; protected _getMediaType(): 'clips' | 'snapshots' { return this.view?.view == 'clips' ? 'clips' : 'snapshots'; @@ -44,29 +39,18 @@ export class FrigateCardGallery extends LitElement { } protected async _renderEvents(): Promise { - if (!this.hass || !this.clientId || !this.cameraName || !this.view) { - return renderErrorMessage(localize('error.internal')); - } - - let parent; + let parent: BrowseMediaSource | null; try { if (this.view.target) { parent = await browseMedia(this.hass, this.view.target.media_content_id); } else { - parent = await browseMediaQuery({ - hass: this.hass, - clientId: this.clientId, - mediaType: this._getMediaType(), - cameraName: this.cameraName, - label: this.label, - zone: this.zone, - }); + parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); } } catch (e: any) { return renderErrorMessage(e.message); } - if (getFirstTrueMediaChildIndex(parent) == null) { + if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) { return renderMessage( this._getMediaType() == 'clips' ? localize('common.no_clips') @@ -83,7 +67,7 @@ export class FrigateCardGallery extends LitElement { { if (this.view && this.view.previous) { - this.view.previous.generateChangeEvent(this); + this.view.previous.dispatchChangeEvent(this); } }} outlined="" @@ -107,7 +91,7 @@ export class FrigateCardGallery extends LitElement { view: this._getMediaType(), target: child, previous: this.view ?? undefined, - }).generateChangeEvent(this); + }).dispatchChangeEvent(this); }} outlined="" class="frigate-card-gallery-folder" @@ -115,19 +99,21 @@ export class FrigateCardGallery extends LitElement {
${child.title}
` - : html` { new View({ view: this._getMediaType() == 'clips' ? 'clip' : 'snapshot', - target: parent, + target: parent ?? undefined, childIndex: index, previous: this.view ?? undefined, - }).generateChangeEvent(this); + }).dispatchChangeEvent(this); }} - />`} + />` + : ``}
`, )} diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts new file mode 100644 index 00000000..120e6dcf --- /dev/null +++ b/src/components/next-prev-control.ts @@ -0,0 +1,74 @@ + import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { classMap } from 'lit/directives/class-map'; +import controlStyle from '../scss/next-previous-control.scss'; +import { BrowseMediaSource, NextPreviousControlStyle } from '../types'; +import { View } from '../view'; + +@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..db97b624 --- /dev/null +++ b/src/components/viewer.ts @@ -0,0 +1,268 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { until } from 'lit/directives/until.js'; +import { View } from '../view'; +import { + renderMessage, + renderErrorMessage, + renderProgressIndicator, +} from '../components/message'; +import { HomeAssistant } from 'custom-card-helpers'; + +import viewerStyle from '../scss/viewer.scss'; + +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 dayjs from 'dayjs'; +import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat'; + +import './next-prev-control'; + +// 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._renderViewer(), renderProgressIndicator())}`; + } + + 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 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/scss/card.scss b/src/scss/card.scss index 7d72a96d..bbb38c8d 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -50,8 +50,6 @@ padding: 10%; } - - video, img { display: block; } @@ -80,41 +78,3 @@ webrtc-camera ha-card { 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/common.scss b/src/scss/common.scss index a8dac10b..068edef6 100644 --- a/src/scss/common.scss +++ b/src/scss/common.scss @@ -13,4 +13,8 @@ ha-icon-button.button { ha-icon-button.button.emphasize { color: var(--primary-color, white); +} + +video, img { + display: block; } \ 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..96db20f6 --- /dev/null +++ b/src/scss/next-previous-control.scss @@ -0,0 +1,39 @@ +@use './common.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; +} \ No newline at end of file diff --git a/src/scss/viewer.scss b/src/scss/viewer.scss new file mode 100644 index 00000000..f8f34bdf --- /dev/null +++ b/src/scss/viewer.scss @@ -0,0 +1,3 @@ +img.media,video.media,canvas.media { + width: 100%; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 425c6043..ad087b43 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,14 @@ 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 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 +58,42 @@ export const frigateCardConfigSchema = z.object({ .regex(/^\d+$/) .transform((val) => Number(val)), ) - .optional().default(180), + .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(), + 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 +113,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 +139,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 +154,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 index 1c693eb8..067e9a68 100644 --- a/src/view.ts +++ b/src/view.ts @@ -34,7 +34,7 @@ export class View { return undefined; } - public generateChangeEvent(node: HTMLElement): void { + public dispatchChangeEvent(node: HTMLElement): void { node.dispatchEvent( new CustomEvent('frigate-card:change-view', { bubbles: true, From 8d639fb6fe72532113e9b3a3bcb958dee72864dc Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 20 Sep 2021 21:01:00 -0700 Subject: [PATCH 5/9] Convert live to a Lit element. --- src/card.ts | 130 ++---------------------- src/components/gallery.ts | 4 +- src/components/live.ts | 203 ++++++++++++++++++++++++++++++++++++++ src/components/viewer.ts | 4 +- src/scss/live.scss | 3 + src/types.ts | 5 +- 6 files changed, 221 insertions(+), 128 deletions(-) create mode 100644 src/components/live.ts create mode 100644 src/scss/live.scss diff --git a/src/card.ts b/src/card.ts index 0c8bba72..fd071e94 100644 --- a/src/card.ts +++ b/src/card.ts @@ -9,14 +9,8 @@ import { } 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 { View } from './view'; import { FrigateCardMenu } from './components/menu'; -import { - renderMessage, - renderErrorMessage, - renderProgressIndicator, -} from './components/message'; import { HomeAssistant, LovelaceCardEditor, @@ -26,6 +20,7 @@ import { } from 'custom-card-helpers'; import './editor'; +import './components/live'; import './components/menu'; import './components/message'; import './components/gallery'; @@ -36,7 +31,6 @@ import cardStyle from './scss/card.scss'; import { MenuButton, frigateCardConfigSchema, - signedPathSchema, } from './types'; import type { BrowseMediaQueryParameters, @@ -46,8 +40,7 @@ import type { import { CARD_VERSION } from './const'; import { localize } from './localize/localize'; -import JSMpeg from '@cycjimmy/jsmpeg-player'; -import { getParseErrorKeys, homeAssistantWSRequest } from './common'; +import { getParseErrorKeys } from './common'; /* eslint no-console: 0 */ console.info( @@ -105,9 +98,6 @@ export class FrigateCard extends LitElement { return {}; } set hass(hass: HomeAssistant & ExtendedHomeAssistant) { - if (this._webrtcElement) { - this._webrtcElement.hass = hass; - } this._hass = hass; this._updateMenu(); } @@ -119,9 +109,6 @@ export class FrigateCard extends LitElement { 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(); @@ -222,19 +209,6 @@ export class FrigateCard extends LitElement { } } - 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), @@ -251,7 +225,6 @@ export class FrigateCard extends LitElement { } else { this._view = view; } - this._resetJSMPEGIfNecessary(); } // Update the card view. @@ -261,7 +234,6 @@ export class FrigateCard extends LitElement { } else { this._view = view; } - this._resetJSMPEGIfNecessary(); } // Determine whether the card should be updated. @@ -351,98 +323,6 @@ export class FrigateCard extends LitElement { }); } - 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 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'); - } - - 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 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._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 renderMessage(localize('error.no_live_camera'), 'mdi:camera-off'); - } - 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) { @@ -517,7 +397,11 @@ export class FrigateCard extends LitElement { ` : ``} ${this._view.is('live') - ? until(this._renderLiveViewer(), renderProgressIndicator()) + ? html` + ` : ``} diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 0b1148a9..52fe8965 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -35,10 +35,10 @@ export class FrigateCardGallery extends LitElement { } protected render(): TemplateResult | void { - return html`${until(this._renderEvents(), renderProgressIndicator())}`; + return html`${until(this._render(), renderProgressIndicator())}`; } - protected async _renderEvents(): Promise { + protected async _render(): Promise { let parent: BrowseMediaSource | null; try { if (this.view.target) { diff --git a/src/components/live.ts b/src/components/live.ts new file mode 100644 index 00000000..41f29dee --- /dev/null +++ b/src/components/live.ts @@ -0,0 +1,203 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { until } from 'lit/directives/until.js'; +import { + renderMessage, + renderErrorMessage, + renderProgressIndicator, +} from '../components/message'; +import { HomeAssistant } from 'custom-card-helpers'; + +import liveStyle from '../scss/live.scss'; + +import { signedPathSchema } from '../types'; +import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types'; +import { localize } from '../localize/localize'; +import { homeAssistantWSRequest } from '../common'; + +import JSMpeg from '@cycjimmy/jsmpeg-player'; + +@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` + `}`; + } +} + +@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` + `; + } +} + +// 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}`; + } +} + +@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/viewer.ts b/src/components/viewer.ts index db97b624..2b294503 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -152,10 +152,10 @@ export class FrigateCardViewer extends LitElement { } protected render(): TemplateResult | void { - return html`${until(this._renderViewer(), renderProgressIndicator())}`; + return html`${until(this._render(), renderProgressIndicator())}`; } - protected async _renderViewer(): Promise { + protected async _render(): Promise { let autoplay = true; let parent: BrowseMediaSource | null = null; diff --git a/src/scss/live.scss b/src/scss/live.scss new file mode 100644 index 00000000..1f1d77be --- /dev/null +++ b/src/scss/live.scss @@ -0,0 +1,3 @@ +canvas { + width: 100%; +} diff --git a/src/types.ts b/src/types.ts index ad087b43..eca53815 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,9 @@ 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). @@ -60,7 +63,7 @@ export const frigateCardConfigSchema = z.object({ ) .optional() .default(180), - live_provider: z.enum(['frigate', 'frigate-jsmpeg', 'webrtc']).default('frigate'), + live_provider: z.enum(LIVE_PROVIDERS).default('frigate'), webrtc: z .object({ entity: z.string().optional(), From 3a084ec2b15e66952040ca3999eea01a9ce84eac Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 20 Sep 2021 21:19:16 -0700 Subject: [PATCH 6/9] Sort imports. --- src/card.ts | 24 ++++++++++++------------ src/components/gallery.ts | 12 +++++------- src/components/live.ts | 17 +++++++++-------- src/components/menu.ts | 4 ++-- src/components/message.ts | 2 ++ src/components/next-prev-control.ts | 7 +++++-- src/components/viewer.ts | 19 ++++++++++--------- 7 files changed, 45 insertions(+), 40 deletions(-) diff --git a/src/card.ts b/src/card.ts index fd071e94..0b52e727 100644 --- a/src/card.ts +++ b/src/card.ts @@ -9,8 +9,6 @@ import { } from 'lit'; import { customElement, property, query, state } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map.js'; -import { View } from './view'; -import { FrigateCardMenu } from './components/menu'; import { HomeAssistant, LovelaceCardEditor, @@ -19,15 +17,6 @@ import { stateIcon, } from 'custom-card-helpers'; -import './editor'; -import './components/live'; -import './components/menu'; -import './components/message'; -import './components/gallery'; -import './components/viewer'; - -import cardStyle from './scss/card.scss'; - import { MenuButton, frigateCardConfigSchema, @@ -37,10 +26,21 @@ import type { 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 { getParseErrorKeys } from './common'; +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( diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 52fe8965..ec276b1d 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -2,22 +2,20 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators'; import { until } from 'lit/directives/until.js'; - -import { renderMessage, renderErrorMessage, renderProgressIndicator } from './message'; - import { HomeAssistant } from 'custom-card-helpers'; -import galleryStyle from '../scss/gallery.scss'; - import type { BrowseMediaSource, BrowseMediaQueryParameters, ExtendedHomeAssistant, } from '../types'; -import { localize } from '../localize/localize'; -import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common'; 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 { diff --git a/src/components/live.ts b/src/components/live.ts index 41f29dee..19fa0ecc 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1,22 +1,23 @@ 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 { HomeAssistant } from 'custom-card-helpers'; - -import liveStyle from '../scss/live.scss'; - -import { signedPathSchema } from '../types'; -import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types'; -import { localize } from '../localize/localize'; -import { homeAssistantWSRequest } from '../common'; import JSMpeg from '@cycjimmy/jsmpeg-player'; +import liveStyle from '../scss/live.scss'; + @customElement('frigate-card-live') export class FrigateCardViewer extends LitElement { @property({ attribute: false }) diff --git a/src/components/menu.ts b/src/components/menu.ts index 41ef6d40..deb15256 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -2,11 +2,11 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map.js'; -import menuStyle from '../scss/menu.scss'; - 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. diff --git a/src/components/message.ts b/src/components/message.ts index cb9683ec..939bb0ae 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -1,6 +1,8 @@ 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 = diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index 120e6dcf..b9f68e91 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -1,10 +1,13 @@ - import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map'; -import controlStyle from '../scss/next-previous-control.scss'; + 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 }) diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 2b294503..a9db985a 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -1,15 +1,10 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators'; import { until } from 'lit/directives/until.js'; -import { View } from '../view'; -import { - renderMessage, - renderErrorMessage, - renderProgressIndicator, -} from '../components/message'; import { HomeAssistant } from 'custom-card-helpers'; -import viewerStyle from '../scss/viewer.scss'; +import dayjs from 'dayjs'; +import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat'; import { resolvedMediaSchema } from '../types'; import type { @@ -29,11 +24,17 @@ import { homeAssistantWSRequest, } from '../common'; -import dayjs from 'dayjs'; -import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat'; +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); From 49cbff9b36290ea9153b1ed002ed600f627a53ac Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 20 Sep 2021 21:47:00 -0700 Subject: [PATCH 7/9] Minor main card tidyup. --- src/card.ts | 49 +++++++++++++++++++++---------------------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/src/card.ts b/src/card.ts index 0b52e727..a51e2ed5 100644 --- a/src/card.ts +++ b/src/card.ts @@ -88,20 +88,6 @@ function shouldUpdateBasedOnHass( // 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) { - this._hass = hass; - this._updateMenu(); - } - @property({ attribute: false }) protected _hass: (HomeAssistant & ExtendedHomeAssistant) | null = null; @@ -113,16 +99,31 @@ export class FrigateCard extends LitElement { @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; - @query('frigate-card-menu') - _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; + 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 @@ -130,7 +131,6 @@ export class FrigateCard extends LitElement { if (!this._menu || !this._hass) { return; } - this._menu.buttons = this._getMenuButtons(); } @@ -217,9 +217,7 @@ export class FrigateCard extends LitElement { this._changeView(); } - protected _changeViewHandler(e: CustomEvent): void { - const view = e.detail; - + protected _changeView(view?: View | undefined): void { if (view === undefined) { this._view = new View({ view: this.config.view_default }); } else { @@ -227,13 +225,8 @@ export class FrigateCard extends LitElement { } } - // 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; - } + protected _changeViewHandler(e: CustomEvent): void { + this._changeView(e.detail); } // Determine whether the card should be updated. From d02f7607937212b2b2eb70377977272850bd5f82 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 20 Sep 2021 22:37:08 -0700 Subject: [PATCH 8/9] Improve CSS encapslation (Chrome). --- src/components/viewer.ts | 3 --- src/scss/{common.scss => button.scss} | 4 ---- src/scss/card.scss | 3 --- src/scss/menu.scss | 4 ++-- src/scss/next-previous-control.scss | 4 ++-- src/scss/viewer.scss | 3 ++- 6 files changed, 6 insertions(+), 15 deletions(-) rename src/scss/{common.scss => button.scss} (91%) diff --git a/src/components/viewer.ts b/src/components/viewer.ts index a9db985a..08cd821d 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -212,7 +212,6 @@ export class FrigateCardViewer extends LitElement { ${this.view.is('clip') ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' ? html` ` : html`