diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2ec5442..539a242a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,4 +35,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: frigate-hass-card - path: dist/frigate-hass-card.js + path: dist/*.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a63f0bfe..c11afea5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ name: Release on: release: types: [published] + workflow_dispatch: jobs: release: @@ -18,14 +19,14 @@ jobs: yarn install yarn run build - # Upload build file to the releas as an asset. + # Upload build file to the release as an asset. - name: Upload zip to release uses: svenstaro/upload-release-action@v1-release with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: dist/frigate-hass-card.js - asset_name: frigate-hass-card.js + file: dist/*.js + file_glob: true tag: ${{ github.ref }} overwrite: true diff --git a/rollup.config.js b/rollup.config.js index 028b98ec..13c20382 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -65,8 +65,10 @@ const plugins = [ */ const config = { input: 'src/card.ts', + preserveEntrySignatures: false, output: { - file: 'dist/frigate-hass-card.js', + entryFileNames: 'frigate-hass-card.js', + dir: 'dist', format: 'es', ...(dev && { sourcemap: true, diff --git a/src/card.ts b/src/card.ts index 6c7e072f..1ef0ae84 100644 --- a/src/card.ts +++ b/src/card.ts @@ -27,8 +27,8 @@ import { FrigateCardElements } from './components/elements.js'; import './components/gallery.js'; import './components/image.js'; import { FrigateCardImage } from './components/image.js'; -import './components/live.js'; -import { FrigateCardLive } from './components/live.js'; +import './components/live/live.js'; +import type { FrigateCardLive } from './components/live/live.js'; import './components/menu.js'; import { FrigateCardMenu, FRIGATE_BUTTON_MENU_ICON } from './components/menu.js'; import './components/message.js'; diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts new file mode 100644 index 00000000..c742eb95 --- /dev/null +++ b/src/components/live/live-ha.ts @@ -0,0 +1,114 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import { localize } from '../../localize/localize.js'; +import liveFrigateStyle from '../../scss/live-frigate.scss'; +import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js'; +import { getCameraTitle } from '../../utils/camera.js'; +import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js'; +import '../../patches/ha-camera-stream'; + +@customElement('frigate-card-live-ha') +export class FrigateCardLiveHA extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + protected _playerRef: Ref = createRef(); + + /** + * Play the video. + */ + public play(): void { + this._playerRef.value?.play(); + } + + /** + * Pause the video. + */ + public pause(): void { + this._playerRef.value?.pause(); + } + + /** + * Mute the video. + */ + public mute(): void { + this._playerRef.value?.mute(); + } + + /** + * Unmute the video. + */ + public unmute(): void { + this._playerRef.value?.unmute(); + } + + /** + * Seek the video. + */ + public seek(seconds: number): void { + this._playerRef.value?.seek(seconds); + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass) { + return; + } + + if (!this.cameraConfig?.camera_entity) { + return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), { + context: this.cameraConfig, + }); + } + + const stateObj = this.hass.states[this.cameraConfig.camera_entity]; + if (!stateObj) { + return dispatchErrorMessageEvent(this, localize('error.live_camera_not_found'), { + context: this.cameraConfig, + }); + } + + if (stateObj.state === 'unavailable') { + // Don't treat state unavailability as an error per se. + return dispatchMessageEvent( + this, + localize('error.live_camera_unavailable'), + 'info', + { + icon: 'mdi:connection', + context: getCameraTitle(this.hass, this.cameraConfig), + }, + ); + } + + return html` + `; + } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveFrigateStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-ha': FrigateCardLiveHA; + } +} diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts new file mode 100644 index 00000000..e970da19 --- /dev/null +++ b/src/components/live/live-jsmpeg.ts @@ -0,0 +1,262 @@ +import JSMpeg from '@cycjimmy/jsmpeg-player'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { until } from 'lit/directives/until.js'; +import { renderProgressIndicator } from '../../components/message.js'; +import { localize } from '../../localize/localize.js'; +import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss'; +import { + CameraConfig, + CardWideConfig, + ExtendedHomeAssistant, + JSMPEGConfig, +} from '../../types.js'; +import { homeAssistantSignPath } from '../../utils/ha'; +import { dispatchMediaLoadedEvent } from '../../utils/media-info.js'; +import { dispatchErrorMessageEvent } from '../message.js'; +import { contentsChanged, errorToConsole } from '../../utils/basic.js'; + +// Number of seconds a signed URL is valid for. +const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; + +// Number of seconds before the expiry to trigger a refresh. +const URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60; + +@customElement('frigate-card-live-jsmpeg') +export class FrigateCardLiveJSMPEG extends LitElement { + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + @property({ attribute: false, hasChanged: contentsChanged }) + public jsmpegConfig?: JSMPEGConfig; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + protected hass?: ExtendedHomeAssistant; + + protected _jsmpegCanvasElement?: HTMLCanvasElement; + protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; + protected _refreshPlayerTimerID?: number; + + /** + * Play the video. + */ + public play(): void { + this._jsmpegVideoPlayer?.play(); + } + + /** + * Pause the video. + */ + public pause(): void { + this._jsmpegVideoPlayer?.stop(); + } + + /** + * Mute the video (included for completeness, JSMPEG live disables audio as + * Frigate does not encode it). + */ + public mute(): void { + const player = this._jsmpegVideoPlayer?.player; + if (player) { + player.volume = 0; + } + } + + /** + * Unmute the video (included for completeness, JSMPEG live disables audio as + * Frigate does not encode it). + */ + public unmute(): void { + const player = this._jsmpegVideoPlayer?.player; + if (player) { + player.volume = 1; + } + } + + /** + * Seek the video (unsupported). + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public seek(_seconds: number): void { + // JSMPEG does not support seeking. + } + + /** + * Get a signed player URL. + * @returns A URL or null. + */ + protected async _getURL(): Promise { + if ( + !this.hass || + !this.cameraConfig?.frigate.client_id || + !this.cameraConfig?.frigate.camera_name + ) { + return null; + } + + let response: string | null | undefined; + try { + response = await homeAssistantSignPath( + this.hass, + `/api/frigate/${this.cameraConfig.frigate.client_id}` + + `/jsmpeg/${this.cameraConfig.frigate.camera_name}`, + URL_SIGN_EXPIRY_SECONDS, + ); + } catch (e) { + errorToConsole(e as Error); + return null; + } + if (!response) { + return null; + } + return response.replace(/^http/i, 'ws'); + } + + /** + * Create a JSMPEG player. + * @param url The URL for the player to connect to. + * @returns A JSMPEG player. + */ + protected async _createJSMPEGPlayer(url: string): Promise { + return 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, + + // Override with user-specified options. + ...this.jsmpegConfig?.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; + dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement); + resolve(player); + } + }, + }, + ); + }); + } + + /** + * Reset / destroy the player. + */ + protected _resetPlayer(): void { + if (this._refreshPlayerTimerID) { + window.clearTimeout(this._refreshPlayerTimerID); + this._refreshPlayerTimerID = undefined; + } + if (this._jsmpegVideoPlayer) { + try { + this._jsmpegVideoPlayer.destroy(); + } catch (err) { + // Pass. + } + this._jsmpegVideoPlayer = undefined; + } + if (this._jsmpegCanvasElement) { + this._jsmpegCanvasElement.remove(); + this._jsmpegCanvasElement = undefined; + } + } + + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + if (this.isConnected) { + this.requestUpdate(); + } + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + if (!this.isConnected) { + this._resetPlayer(); + } + super.disconnectedCallback(); + } + + /** + * Refresh the JSMPEG player. + */ + protected async _refreshPlayer(): Promise { + this._resetPlayer(); + + this._jsmpegCanvasElement = document.createElement('canvas'); + this._jsmpegCanvasElement.className = 'media'; + + if (!this.cameraConfig?.frigate.camera_name) { + return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), { + context: this.cameraConfig, + }); + } + + const url = await this._getURL(); + if (url) { + this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url); + this._refreshPlayerTimerID = window.setTimeout(() => { + this.requestUpdate(); + }, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); + } else { + dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); + } + } + + /** + * Master render method. + */ + protected render(): TemplateResult | void { + const _render = async (): Promise => { + await this._refreshPlayer(); + + if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { + return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); + } + return html`${this._jsmpegCanvasElement}`; + }; + return html`${until( + _render(), + renderProgressIndicator({ + cardWideConfig: this.cardWideConfig, + }), + )}`; + } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveJSMPEGStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-jsmpeg': FrigateCardLiveJSMPEG; + } +} diff --git a/src/components/live/live-webrtc.ts b/src/components/live/live-webrtc.ts new file mode 100644 index 00000000..04082148 --- /dev/null +++ b/src/components/live/live-webrtc.ts @@ -0,0 +1,202 @@ +import { Task } from '@lit-labs/task'; +import { HomeAssistant } from 'custom-card-helpers'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { localize } from '../../localize/localize.js'; +import liveWebRTCStyle from '../../scss/live-webrtc.scss'; +import { + CameraConfig, + CardWideConfig, + FrigateCardError, + WebRTCCardConfig, +} from '../../types.js'; +import { contentsChanged } from '../../utils/basic.js'; +import { dispatchMediaLoadedEvent } from '../../utils/media-info.js'; +import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js'; +import { renderTask } from '../../utils/task.js'; + +// Create a wrapper for AlexxIT's WebRTC card +// - https://github.com/AlexxIT/WebRTC +@customElement('frigate-card-live-webrtc-card') +export class FrigateCardLiveWebRTCCard extends LitElement { + @property({ attribute: false, hasChanged: contentsChanged }) + public webRTCConfig?: WebRTCCardConfig; + + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + protected hass?: HomeAssistant; + + // A task to await the load of the WebRTC component. + protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]); + + /** + * Play the video. + */ + public play(): void { + this._getPlayer() + ?.play() + .catch(() => { + // WebRTC appears to generate additional spurious load events, which may + // result in loads after a play() call, which causes the browser to spam + // the logs unless the promise rejection is handled here. + }); + } + + /** + * Pause the video. + */ + public pause(): void { + this._getPlayer()?.pause(); + } + + /** + * Mute the video. + */ + public mute(): void { + const player = this._getPlayer(); + if (player) { + player.muted = true; + } + } + + /** + * Unmute the video. + */ + public unmute(): void { + const player = this._getPlayer(); + if (player) { + player.muted = false; + } + } + + /** + * Seek the video. + */ + public seek(seconds: number): void { + const player = this._getPlayer(); + if (player) { + player.currentTime = seconds; + } + } + + /** + * Get the underlying video player. + * @returns The player or `null` if not found. + */ + protected _getPlayer(): HTMLVideoElement | null { + return this.renderRoot?.querySelector('#video') as HTMLVideoElement | null; + } + + protected async _getWebRTCCardElement(): Promise< + CustomElementConstructor | undefined + > { + await customElements.whenDefined('webrtc-camera'); + return customElements.get('webrtc-camera'); + } + + /** + * Create the WebRTC element. May throw. + */ + protected _createWebRTC(): HTMLElement | null { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const webrtcElement = this._webrtcTask.value; + if (webrtcElement && this.hass) { + const webrtc = new webrtcElement() as HTMLElement & { + hass: HomeAssistant; + setConfig: (config: Record) => void; + }; + const config = { ...this.webRTCConfig }; + + // If the live WebRTC configuration does not specify a URL/entity to use, + // then take values from the camera configuration instead (if there are + // any). + if (!config.url) { + config.url = this.cameraConfig?.webrtc_card?.url; + } + if (!config.entity) { + config.entity = this.cameraConfig?.webrtc_card?.entity; + } + webrtc.setConfig(config); + webrtc.hass = this.hass; + return webrtc; + } + return null; + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + const render = (): TemplateResult | void => { + let webrtcElement: HTMLElement | null; + try { + webrtcElement = this._createWebRTC(); + } catch (e) { + return dispatchErrorMessageEvent( + this, + e instanceof FrigateCardError + ? e.message + : localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message, + { context: (e as FrigateCardError).context }, + ); + } + if (webrtcElement) { + // Set the id to ensure that the relevant CSS styles will have + // sufficient specifity to overcome some styles that are otherwise + // applied to in Safari. + webrtcElement.id = 'webrtc'; + } + return html`${webrtcElement}`; + }; + + // Use a task to allow us to asynchronously wait for the WebRTC card to + // load, but yet still have the card load be followed by the updated() + // lifecycle callback (unlike just using `until`). + return renderTask(this, this._webrtcTask, render, { + inProgressFunc: () => + renderProgressIndicator({ + message: localize('error.webrtc_card_waiting'), + cardWideConfig: this.cardWideConfig, + }), + }); + } + + /** + * Updated lifecycle callback. + */ + public updated(): void { + // Extract the video component after it has been rendered and generate the + // media load event. + this.updateComplete.then(() => { + const video = this._getPlayer(); + if (video) { + const onloadeddata = video.onloadeddata; + + video.onloadeddata = (e) => { + if (onloadeddata) { + onloadeddata.call(video, e); + } + dispatchMediaLoadedEvent(this, video); + }; + } + }); + } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveWebRTCStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-webrtc-card': FrigateCardLiveWebRTCCard; + } +} diff --git a/src/components/live.ts b/src/components/live/live.ts similarity index 62% rename from src/components/live.ts rename to src/components/live/live.ts index 4a3b2f34..d290f21d 100644 --- a/src/components/live.ts +++ b/src/components/live/live.ts @@ -1,6 +1,3 @@ -import JSMpeg from '@cycjimmy/jsmpeg-player'; -import { Task } from '@lit-labs/task'; -import { HomeAssistant } from 'custom-card-helpers'; import { EmblaOptionsType } from 'embla-carousel'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { @@ -15,67 +12,49 @@ import { customElement, property, state } from 'lit/decorators.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { guard } from 'lit/directives/guard.js'; import { keyed } from 'lit/directives/keyed.js'; -import { until } from 'lit/directives/until.js'; -import { ConditionState, getOverriddenConfig } from '../card-condition.js'; -import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; -import { localize } from '../localize/localize.js'; -import liveFrigateStyle from '../scss/live-frigate.scss'; -import liveJSMPEGStyle from '../scss/live-jsmpeg.scss'; -import liveWebRTCStyle from '../scss/live-webrtc.scss'; -import liveStyle from '../scss/live.scss'; -import liveCarouselStyle from '../scss/live-carousel.scss'; -import liveProviderStyle from '../scss/live-provider.scss'; +import { ConditionState, getOverriddenConfig } from '../../card-condition.js'; +import { localize } from '../../localize/localize.js'; +import liveStyle from '../../scss/live.scss'; +import liveCarouselStyle from '../../scss/live-carousel.scss'; +import liveProviderStyle from '../../scss/live-provider.scss'; import { CameraConfig, CardWideConfig, ExtendedHomeAssistant, frigateCardConfigDefaults, - FrigateCardError, FrigateCardMediaPlayer, - JSMPEGConfig, LiveConfig, LiveOverrides, LiveProvider, MediaLoadedInfo, Message, TransitionEffect, - WebRTCCardConfig, -} from '../types.js'; -import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; -import { contentsChanged, errorToConsole } from '../utils/basic.js'; -import { getCameraIcon, getCameraTitle } from '../utils/camera.js'; -import { homeAssistantSignPath } from '../utils/ha'; -import { getFullDependentBrowseMediaQueryParameters } from '../utils/ha/browse-media.js'; +} from '../../types.js'; +import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; +import { contentsChanged } from '../../utils/basic.js'; +import { getCameraIcon, getCameraTitle } from '../../utils/camera.js'; +import { getFullDependentBrowseMediaQueryParameters } from '../../utils/ha/browse-media.js'; import { dispatchExistingMediaLoadedInfoAsEvent, - dispatchMediaLoadedEvent, dispatchMediaUnloadedEvent, -} from '../utils/media-info.js'; -import { dispatchViewContextChangeEvent, View } from '../view.js'; -import { AutoMediaPlugin } from './embla-plugins/automedia.js'; -import { Lazyload } from './embla-plugins/lazyload.js'; +} from '../../utils/media-info.js'; +import { dispatchViewContextChangeEvent, View } from '../../view.js'; +import { AutoMediaPlugin } from './../embla-plugins/automedia.js'; +import { Lazyload } from './../embla-plugins/lazyload.js'; import { FrigateCardMediaCarousel, wrapMediaLoadedEventForCarousel, wrapMediaUnloadedEventForCarousel, -} from './media-carousel.js'; -import { dispatchErrorMessageEvent } from './message.js'; -import './next-prev-control.js'; -import './title-control.js'; -import './surround.js'; -import '../patches/ha-camera-stream'; -import { EmblaCarouselPlugins } from './carousel.js'; -import { renderTask } from '../utils/task.js'; +} from '../media-carousel.js'; +import '../next-prev-control.js'; +import '../title-control.js'; +import '../surround.js'; +import '../../patches/ha-camera-stream'; +import { EmblaCarouselPlugins } from '../carousel.js'; import { classMap } from 'lit/directives/class-map.js'; -import './image'; -import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; -import { DataManager } from '../utils/data-manager.js'; - -// Number of seconds a signed URL is valid for. -const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; - -// Number of seconds before the expiry to trigger a refresh. -const URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60; +import '../image'; +import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; +import { DataManager } from '../../utils/data-manager.js'; @customElement('frigate-card-live') export class FrigateCardLive extends LitElement { @@ -492,7 +471,7 @@ export class FrigateCardLiveCarousel extends LitElement { const liveProvider = slide?.querySelector( 'frigate-card-live-provider', - ) as FrigateCardLiveProvider; + ) as FrigateCardLiveProvider | null; if (liveProvider) { liveProvider.disabled = action !== 'load'; } @@ -782,6 +761,15 @@ export class FrigateCardLiveProvider extends LitElement { if (changedProps.has('liveConfig')) { updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout); } + if (changedProps.has('cameraConfig')) { + if (this._getResolvedProvider() === 'frigate-jsmpeg') { + import('./live-jsmpeg.js'); + } else if (this._getResolvedProvider() === 'ha') { + import('./live-ha.js'); + } else if (this._getResolvedProvider() === 'webrtc-card') { + import('./live-webrtc.js'); + } + } } /** @@ -856,522 +844,8 @@ export class FrigateCardLiveProvider extends LitElement { } } -@customElement('frigate-card-live-ha') -export class FrigateCardLiveFrigate extends LitElement { - @property({ attribute: false }) - public hass?: HomeAssistant; - - @property({ attribute: false }) - public cameraConfig?: CameraConfig; - - protected _playerRef: Ref = createRef(); - - /** - * Play the video. - */ - public play(): void { - this._playerRef.value?.play(); - } - - /** - * Pause the video. - */ - public pause(): void { - this._playerRef.value?.pause(); - } - - /** - * Mute the video. - */ - public mute(): void { - this._playerRef.value?.mute(); - } - - /** - * Unmute the video. - */ - public unmute(): void { - this._playerRef.value?.unmute(); - } - - /** - * Seek the video. - */ - public seek(seconds: number): void { - this._playerRef.value?.seek(seconds); - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if (!this.hass) { - return; - } - - if (!this.cameraConfig?.camera_entity) { - return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), { - context: this.cameraConfig, - }); - } - - const stateObj = this.hass.states[this.cameraConfig.camera_entity]; - if (!stateObj) { - return dispatchErrorMessageEvent(this, localize('error.live_camera_not_found'), { - context: this.cameraConfig, - }); - } - - if (stateObj.state === 'unavailable') { - // Don't treat state unavailability as an error per se. - return dispatchMessageEvent( - this, - localize('error.live_camera_unavailable'), - 'info', - { - icon: 'mdi:connection', - context: getCameraTitle(this.hass, this.cameraConfig), - }, - ); - } - - return html` - `; - } - - /** - * Get styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(liveFrigateStyle); - } -} - -// Create a wrapper for AlexxIT's WebRTC card -// - https://github.com/AlexxIT/WebRTC -@customElement('frigate-card-live-webrtc-card') -export class FrigateCardLiveWebRTCCard extends LitElement { - @property({ attribute: false, hasChanged: contentsChanged }) - public webRTCConfig?: WebRTCCardConfig; - - @property({ attribute: false }) - public cameraConfig?: CameraConfig; - - @property({ attribute: false }) - public cardWideConfig?: CardWideConfig; - - protected hass?: HomeAssistant; - - // A task to await the load of the WebRTC component. - protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]); - - /** - * Play the video. - */ - public play(): void { - this._getPlayer() - ?.play() - .catch(() => { - // WebRTC appears to generate additional spurious load events, which may - // result in loads after a play() call, which causes the browser to spam - // the logs unless the promise rejection is handled here. - }); - } - - /** - * Pause the video. - */ - public pause(): void { - this._getPlayer()?.pause(); - } - - /** - * Mute the video. - */ - public mute(): void { - const player = this._getPlayer(); - if (player) { - player.muted = true; - } - } - - /** - * Unmute the video. - */ - public unmute(): void { - const player = this._getPlayer(); - if (player) { - player.muted = false; - } - } - - /** - * Seek the video. - */ - public seek(seconds: number): void { - const player = this._getPlayer(); - if (player) { - player.currentTime = seconds; - } - } - - /** - * Get the underlying video player. - * @returns The player or `null` if not found. - */ - protected _getPlayer(): HTMLVideoElement | null { - return this.renderRoot?.querySelector('#video') as HTMLVideoElement | null; - } - - protected async _getWebRTCCardElement(): Promise< - CustomElementConstructor | undefined - > { - await customElements.whenDefined('webrtc-camera'); - return customElements.get('webrtc-camera'); - } - - /** - * Create the WebRTC element. May throw. - */ - protected _createWebRTC(): HTMLElement | null { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const webrtcElement = this._webrtcTask.value; - if (webrtcElement && this.hass) { - const webrtc = new webrtcElement() as HTMLElement & { - hass: HomeAssistant; - setConfig: (config: Record) => void; - }; - const config = { ...this.webRTCConfig }; - - // If the live WebRTC configuration does not specify a URL/entity to use, - // then take values from the camera configuration instead (if there are - // any). - if (!config.url) { - config.url = this.cameraConfig?.webrtc_card?.url; - } - if (!config.entity) { - config.entity = this.cameraConfig?.webrtc_card?.entity; - } - webrtc.setConfig(config); - webrtc.hass = this.hass; - return webrtc; - } - return null; - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - const render = (): TemplateResult | void => { - let webrtcElement: HTMLElement | null; - try { - webrtcElement = this._createWebRTC(); - } catch (e) { - return dispatchErrorMessageEvent( - this, - e instanceof FrigateCardError - ? e.message - : localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message, - { context: (e as FrigateCardError).context }, - ); - } - if (webrtcElement) { - // Set the id to ensure that the relevant CSS styles will have - // sufficient specifity to overcome some styles that are otherwise - // applied to in Safari. - webrtcElement.id = 'webrtc'; - } - return html`${webrtcElement}`; - }; - - // Use a task to allow us to asynchronously wait for the WebRTC card to - // load, but yet still have the card load be followed by the updated() - // lifecycle callback (unlike just using `until`). - return renderTask(this, this._webrtcTask, render, { - inProgressFunc: () => - renderProgressIndicator({ - message: localize('error.webrtc_card_waiting'), - cardWideConfig: this.cardWideConfig, - }), - }); - } - - /** - * Updated lifecycle callback. - */ - public updated(): void { - // Extract the video component after it has been rendered and generate the - // media load event. - this.updateComplete.then(() => { - const video = this._getPlayer(); - if (video) { - const onloadeddata = video.onloadeddata; - - video.onloadeddata = (e) => { - if (onloadeddata) { - onloadeddata.call(video, e); - } - dispatchMediaLoadedEvent(this, video); - }; - } - }); - } - - /** - * Get styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(liveWebRTCStyle); - } -} - -@customElement('frigate-card-live-jsmpeg') -export class FrigateCardLiveJSMPEG extends LitElement { - @property({ attribute: false }) - public cameraConfig?: CameraConfig; - - @property({ attribute: false, hasChanged: contentsChanged }) - public jsmpegConfig?: JSMPEGConfig; - - @property({ attribute: false }) - public cardWideConfig?: CardWideConfig; - - protected hass?: ExtendedHomeAssistant; - - protected _jsmpegCanvasElement?: HTMLCanvasElement; - protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; - protected _refreshPlayerTimerID?: number; - - /** - * Play the video. - */ - public play(): void { - this._jsmpegVideoPlayer?.play(); - } - - /** - * Pause the video. - */ - public pause(): void { - this._jsmpegVideoPlayer?.stop(); - } - - /** - * Mute the video (included for completeness, JSMPEG live disables audio as - * Frigate does not encode it). - */ - public mute(): void { - const player = this._jsmpegVideoPlayer?.player; - if (player) { - player.volume = 0; - } - } - - /** - * Unmute the video (included for completeness, JSMPEG live disables audio as - * Frigate does not encode it). - */ - public unmute(): void { - const player = this._jsmpegVideoPlayer?.player; - if (player) { - player.volume = 1; - } - } - - /** - * Seek the video (unsupported). - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public seek(_seconds: number): void { - // JSMPEG does not support seeking. - } - - /** - * Get a signed player URL. - * @returns A URL or null. - */ - protected async _getURL(): Promise { - if ( - !this.hass || - !this.cameraConfig?.frigate.client_id || - !this.cameraConfig?.frigate.camera_name - ) { - return null; - } - - let response: string | null | undefined; - try { - response = await homeAssistantSignPath( - this.hass, - `/api/frigate/${this.cameraConfig.frigate.client_id}` + - `/jsmpeg/${this.cameraConfig.frigate.camera_name}`, - URL_SIGN_EXPIRY_SECONDS, - ); - } catch (e) { - errorToConsole(e as Error); - return null; - } - if (!response) { - return null; - } - return response.replace(/^http/i, 'ws'); - } - - /** - * Create a JSMPEG player. - * @param url The URL for the player to connect to. - * @returns A JSMPEG player. - */ - protected async _createJSMPEGPlayer(url: string): Promise { - return 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, - - // Override with user-specified options. - ...this.jsmpegConfig?.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; - dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement); - resolve(player); - } - }, - }, - ); - }); - } - - /** - * Reset / destroy the player. - */ - protected _resetPlayer(): void { - if (this._refreshPlayerTimerID) { - window.clearTimeout(this._refreshPlayerTimerID); - this._refreshPlayerTimerID = undefined; - } - if (this._jsmpegVideoPlayer) { - try { - this._jsmpegVideoPlayer.destroy(); - } catch (err) { - // Pass. - } - this._jsmpegVideoPlayer = undefined; - } - if (this._jsmpegCanvasElement) { - this._jsmpegCanvasElement.remove(); - this._jsmpegCanvasElement = undefined; - } - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - if (this.isConnected) { - this.requestUpdate(); - } - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - if (!this.isConnected) { - this._resetPlayer(); - } - super.disconnectedCallback(); - } - - /** - * Refresh the JSMPEG player. - */ - protected async _refreshPlayer(): Promise { - this._resetPlayer(); - - this._jsmpegCanvasElement = document.createElement('canvas'); - this._jsmpegCanvasElement.className = 'media'; - - if (!this.cameraConfig?.frigate.camera_name) { - return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), { - context: this.cameraConfig, - }); - } - - const url = await this._getURL(); - if (url) { - this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url); - this._refreshPlayerTimerID = window.setTimeout(() => { - this.requestUpdate(); - }, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); - } else { - dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); - } - } - - /** - * Master render method. - */ - protected render(): TemplateResult | void { - const _render = async (): Promise => { - await this._refreshPlayer(); - - if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { - return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); - } - return html`${this._jsmpegCanvasElement}`; - }; - return html`${until( - _render(), - renderProgressIndicator({ - cardWideConfig: this.cardWideConfig, - }), - )}`; - } - - /** - * Get styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(liveJSMPEGStyle); - } -} - declare global { interface HTMLElementTagNameMap { - 'frigate-card-live-jsmpeg': FrigateCardLiveJSMPEG; - 'frigate-card-live-webrtc-card': FrigateCardLiveWebRTCCard; - 'frigate-card-live-ha': FrigateCardLiveFrigate; 'frigate-card-live-provider': FrigateCardLiveProvider; 'frigate-card-live-carousel': FrigateCardLiveCarousel; 'frigate-card-live': FrigateCardLive; diff --git a/src/components/surround.ts b/src/components/surround.ts index 04cf6bf9..a0989c5a 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -29,7 +29,6 @@ import { dispatchFrigateCardErrorEvent } from './message.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import './surround-basic.js'; -import './timeline-core.js'; import { ifDefined } from 'lit/directives/if-defined.js'; interface ThumbnailViewContext { @@ -122,6 +121,10 @@ export class FrigateCardSurround extends LitElement { * Called before each update. */ protected willUpdate(changedProperties: PropertyValues): void { + if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') { + import('./timeline-core.js'); + } + // Once the component will certainly update, dispatch a media request. Only // do so if properties relevant to the request have changed (as per their // hasChanged). diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 080652da..390b0c54 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -19,9 +19,8 @@ import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { isEqual, throttle } from 'lodash-es'; import { ViewContext } from 'view'; import { DataView, DataSet } from 'vis-data/esnext'; +import type { DataGroupCollectionType, IdType } from 'vis-timeline/esnext'; import { - DataGroupCollectionType, - IdType, Timeline, TimelineEventPropertiesResult, TimelineItem, @@ -458,7 +457,8 @@ export class FrigateCardTimelineCore extends LitElement { this.hass, this.dataManager, this.cameras, - this.view, { + this.view, + { cameraIDs: new Set([String(properties.group)]), targetTime: properties.what === 'background' ? properties.time : window.end, @@ -471,7 +471,8 @@ export class FrigateCardTimelineCore extends LitElement { this.hass, this.dataManager, this.cameras, - this.view, { + this.view, + { targetTime: window.end, }, ); @@ -483,12 +484,13 @@ export class FrigateCardTimelineCore extends LitElement { this.hass, this.dataManager, this.cameras, - this.view, { + this.view, + { cameraIDs: this._getAllCameraIDs(), start: startOfHour(properties.time), end: endOfHour(properties.time), targetTime: properties.time, - } + }, ); } } else if ( diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 9c7f1c1e..33d3fc8f 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -5,7 +5,6 @@ import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; import { DataManager } from '../utils/data-manager'; import { View } from '../view'; import './surround.js'; -import './timeline-core.js'; // This file is kept separate from timeline-core.ts to avoid a circular dependency: // FrigateCardTimeline -> @@ -29,6 +28,13 @@ export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) public dataManager?: DataManager; + /** + * Called on first update. + */ + protected firstUpdated(): void { + import('./timeline-core.js'); + } + /** * Master render method. * @returns A rendered template. diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 465f941d..8a71c1d2 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -240,7 +240,7 @@ "info": "This card configuration has manually specified overrides configured which may override values shown in the visual editor, please consult the code editor to view/modify these overrides" }, "performance": { - "warning": "This card is in low profile mode so many defaults have changed to optimize performance", + "warning": "This card is in low profile mode so defaults have changed to optimize performance", "features": { "editor_label": "Feature Options", "animated_progress_indicator": "Animated Progress Indicator" diff --git a/src/utils/data-manager.ts b/src/utils/data-manager.ts index 44eb6485..c2a08f94 100644 --- a/src/utils/data-manager.ts +++ b/src/utils/data-manager.ts @@ -1,6 +1,6 @@ import { HomeAssistant } from 'custom-card-helpers'; import { DataSet, DataView } from 'vis-data/esnext'; -import { IdType, TimelineItem } from 'vis-timeline/esnext'; +import type { IdType, TimelineItem } from 'vis-timeline/esnext'; import { CAMERA_BIRDSEYE } from '../const.js'; import { CameraConfig,