import JSMpeg from '@cycjimmy/jsmpeg-player'; import { html, LitElement, unsafeCSS, type CSSResultGroup, type PropertyValues, type TemplateResult, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { until } from 'lit/directives/until.js'; import type { Camera } from '../../../camera-manager/camera.js'; import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js'; import { MediaLoadedInfoSourceController } from '../../../components-lib/media-loaded-info-source-controller.js'; import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js'; import { createNotificationFromText } from '../../../components-lib/notification/factory.js'; import type { Notification } from '../../../config/schema/actions/types.js'; import type { CardWideConfig } from '../../../config/schema/types.js'; import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js'; import type { HomeAssistant } from '../../../ha/types.js'; import { localize } from '../../../localize/localize.js'; import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss'; import type { MediaPlayer, MediaPlayerController } from '../../../types.js'; import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js'; import { createMediaLoadedInfo, dispatchMediaPauseEvent, dispatchMediaPlayEvent, } from '../../../utils/media-info.js'; import { Timer } from '../../../utils/timer.js'; import '../../notification/block.js'; import { renderNotificationBlock } from '../../notification/block.js'; import '../../progress-indicator.js'; import { renderProgressIndicator } from '../../progress-indicator.js'; // Number of seconds a signed URL is valid for. const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; // Number of seconds before the expiry to trigger a refresh. const JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60; @customElement('advanced-camera-card-live-jsmpeg') export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPlayer { private hass?: HomeAssistant; @property({ attribute: false }) public camera?: Camera; // The BASE camera ID (camera property may be a substream) @property({ attribute: false }) public targetID?: string; @property({ attribute: false }) public cardWideConfig?: CardWideConfig; @state() private _notification: Notification | null = null; private _jsmpegCanvasElement?: HTMLCanvasElement; private _jsmpegVideoPlayer?: JSMpeg.VideoElement; private _refreshPlayerTimer = new Timer(); private _mediaPlayerController = new JSMPEGMediaPlayerController( this, () => this._jsmpegVideoPlayer ?? null, () => this._jsmpegCanvasElement ?? null, ); private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, { getTargetID: () => this.targetID ?? null, }); public async getMediaPlayerController(): Promise { return this._mediaPlayerController; } protected willUpdate(changedProperties: PropertyValues): void { if (changedProperties.has('camera')) { this._notification = null; } } private async _createJSMPEGPlayer(url: string): Promise { this._jsmpegVideoPlayer = await new Promise((resolve) => { let videoDecoded = false; const player = new JSMpeg.VideoElement( this, url, { canvas: this._jsmpegCanvasElement, }, { // The media carousel may automatically pause when the browser tab is // inactive, JSMPEG does not need to also do so independently. pauseWhenHidden: false, autoplay: false, protocols: [], audio: false, videoBufferSize: 1024 * 1024 * 4, // Necessary for screenshots. preserveDrawingBuffer: true, // Override with user-specified options. ...this.camera?.getConfig()?.jsmpeg?.options, // Don't allow the player to internally reconnect, as it may re-use a // URL with a (newly) invalid signature, e.g. during a Home Assistant // restart. reconnectInterval: 0, onVideoDecode: () => { // This is the only callback that is called after the dimensions // are available. It's called on every frame decode, so just // ignore any subsequent calls. if (!videoDecoded && this._jsmpegCanvasElement) { videoDecoded = true; resolve(player); } }, onPlay: () => dispatchMediaPlayEvent(this), onPause: () => dispatchMediaPauseEvent(this), }, ); }); // The media-loaded info must be reported after the player is assigned to // `this._jsmpegVideoPlayer`, since the registration may result in calls // back to the player to check for pause status for menu buttons. if (this._jsmpegCanvasElement) { const info = createMediaLoadedInfo(this._jsmpegCanvasElement, { mediaPlayerController: this._mediaPlayerController, capabilities: { supportsPause: true, }, technology: ['jsmpeg'], }); if (info) { this._mediaLoadedInfoSourceController.set(info); } } } private _resetPlayer(): void { this._notification = null; this._refreshPlayerTimer.stop(); if (this._jsmpegVideoPlayer) { try { this._jsmpegVideoPlayer.destroy(); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { // Pass. } this._jsmpegVideoPlayer = undefined; } if (this._jsmpegCanvasElement) { this._jsmpegCanvasElement.remove(); this._jsmpegCanvasElement = undefined; } } connectedCallback(): void { super.connectedCallback(); if (this.isConnected) { this.requestUpdate(); } } disconnectedCallback(): void { if (!this.isConnected) { this._resetPlayer(); } super.disconnectedCallback(); } private async _refreshPlayer(): Promise { if (!this.hass) { return; } this._resetPlayer(); this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement.className = 'media'; const endpoint = this.camera?.getEndpoints()?.jsmpeg; if (!endpoint) { this._notification = createNotificationFromText( localize('error.live_camera_no_endpoint'), { context: this.camera?.getConfig() }, ); dispatchLiveErrorEvent(this); return; } let response: string | null | undefined; try { response = await homeAssistantGetSignedURLIfNecessary( this.hass, endpoint, JSMPEG_URL_SIGN_EXPIRY_SECONDS, ); } catch (e) { errorToConsole(e); } const address = response ? convertHTTPAdressToWebsocket(response) : null; if (!address) { this._notification = createNotificationFromText(localize('error.failed_sign'), { context: this.camera?.getConfig(), }); dispatchLiveErrorEvent(this); return; } await this._createJSMPEGPlayer(address); this._refreshPlayerTimer.start( JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS, () => this.requestUpdate(), ); } protected render(): TemplateResult | void { if (this._notification) { return renderNotificationBlock(this._notification); } const _render = async (): Promise => { await this._refreshPlayer(); if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { if (!this._notification) { this._notification = createNotificationFromText( localize('error.jsmpeg_no_player'), { context: this.camera?.getConfig() }, ); dispatchLiveErrorEvent(this); } return; } return html`${this._jsmpegCanvasElement}`; }; return html`${until( _render(), renderProgressIndicator({ cardWideConfig: this.cardWideConfig, }), )}`; } static get styles(): CSSResultGroup { return unsafeCSS(liveJSMPEGStyle); } } declare global { interface HTMLElementTagNameMap { 'advanced-camera-card-live-jsmpeg': AdvancedCameraCardLiveJSMPEG; } }