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(),