From dab9ff304510c7b1cd52d4d949dde75a4481cbdb Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 15 Dec 2024 20:39:18 -0800 Subject: [PATCH] fix: Substantially reduce cases where errors block whole card (#1764) --- src/components-lib/live/live-controller.ts | 37 +--------- .../live/utils/dispatch-live-error.ts | 5 ++ src/components/image.ts | 24 ++++-- src/components/live/index.ts | 57 +++++---------- src/components/live/provider.ts | 45 +++++++++--- src/components/live/providers/go2rtc/index.ts | 43 ++++++++--- src/components/live/providers/ha.ts | 19 +---- src/components/live/providers/image.ts | 3 - src/components/live/providers/jsmpeg.ts | 66 ++++++++++++++--- src/components/live/providers/webrtc-card.ts | 67 +++++++++++------ src/components/message.ts | 19 +++-- src/components/next-prev-control.ts | 21 +++++- src/components/thumbnail.ts | 15 +++- src/components/viewer/carousel.ts | 11 ++- src/const.ts | 2 +- src/patches/ha-hls-player.ts | 13 +++- src/patches/ha-web-rtc-player.ts | 16 ++-- src/utils/endpoint.ts | 13 +--- src/utils/get-state-obj.ts | 36 --------- src/utils/task.ts | 11 ++- src/utils/thumbnail.ts | 3 +- .../live/live-controller.test.ts | 73 ++----------------- .../live/utils/dispatch-live-error.test.ts | 12 +++ tests/utils/endpoint.test.ts | 49 +++---------- tests/utils/get-state-obj.test.ts | 72 ------------------ 25 files changed, 327 insertions(+), 405 deletions(-) create mode 100644 src/components-lib/live/utils/dispatch-live-error.ts delete mode 100644 src/utils/get-state-obj.ts create mode 100644 tests/components-lib/live/utils/dispatch-live-error.test.ts delete mode 100644 tests/utils/get-state-obj.test.ts diff --git a/src/components-lib/live/live-controller.ts b/src/components-lib/live/live-controller.ts index b7fa45c6..101dbe71 100644 --- a/src/components-lib/live/live-controller.ts +++ b/src/components-lib/live/live-controller.ts @@ -1,6 +1,6 @@ import { LitElement, ReactiveController } from 'lit'; import { FrigateCardMessageEventTarget } from '../../components/message.js'; -import { MediaLoadedInfo, Message } from '../../types.js'; +import { MediaLoadedInfo } from '../../types.js'; import { FrigateCardMediaLoadedEventTarget, dispatchExistingMediaLoadedInfoAsEvent, @@ -38,16 +38,11 @@ export class LiveController implements ReactiveController { // foreground and background (in preload mode). protected _intersectionObserver: IntersectionObserver; - // Whether or not to allow updates. - protected _messageReceived = false; - // MediaLoadedInfo object and target from the underlying live media. In the // case of pre-loading these may be propagated later (from the original // source). protected _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null; - protected _renderEpoch = 0; - constructor(host: LiveControllerHost) { this._host = host; @@ -58,50 +53,22 @@ export class LiveController implements ReactiveController { ); } - public shouldUpdate(): boolean { - // Don't process updates if it's in the background and a message was - // received (otherwise an error message thrown by the background live - // component may continually be re-spammed hitting performance). - return !(this._inBackground && this._messageReceived); - } - public hostConnected(): void { this._intersectionObserver.observe(this._host); this._host.addEventListener('frigate-card:media:loaded', this._handleMediaLoaded); - this._host.addEventListener('frigate-card:message', this._handleMessage); } public hostDisconnected(): void { this._intersectionObserver.disconnect(); this._host.removeEventListener('frigate-card:media:loaded', this._handleMediaLoaded); - this._host.removeEventListener('frigate-card:message', this._handleMessage); - } - - public clearMessageReceived(): void { - this._messageReceived = false; } public isInBackground(): boolean { return this._inBackground; } - public getRenderEpoch(): number { - return this._renderEpoch; - } - - protected _handleMessage = (ev: CustomEvent): void => { - this._messageReceived = true; - - if (this._inBackground) { - ev.stopPropagation(); - - // Force the whole DOM to re-render next time. - this._renderEpoch++; - } - }; - protected _handleMediaLoaded = (ev: CustomEvent): void => { this._lastMediaLoadedInfo = { source: ev.composedPath()[0], @@ -117,7 +84,7 @@ export class LiveController implements ReactiveController { const wasInBackground = this._inBackground; this._inBackground = !entries.some((entry) => entry.isIntersecting); - if (!this._inBackground && !this._messageReceived && this._lastMediaLoadedInfo) { + if (!this._inBackground && this._lastMediaLoadedInfo) { // If this isn't being rendered in the background, the last render did not // generate a message and there's a saved MediaInfo, dispatch it upwards. dispatchExistingMediaLoadedInfoAsEvent( diff --git a/src/components-lib/live/utils/dispatch-live-error.ts b/src/components-lib/live/utils/dispatch-live-error.ts new file mode 100644 index 00000000..ed0b99dd --- /dev/null +++ b/src/components-lib/live/utils/dispatch-live-error.ts @@ -0,0 +1,5 @@ +import { dispatchFrigateCardEvent } from '../../../utils/basic'; + +export function dispatchLiveErrorEvent(element: EventTarget): void { + dispatchFrigateCardEvent(element, 'live:error'); +} diff --git a/src/components/image.ts b/src/components/image.ts index 58b5798b..38f0c1f0 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -8,7 +8,7 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { live } from 'lit/directives/live.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import isEqual from 'lodash-es/isEqual'; @@ -18,7 +18,7 @@ import { CameraConfig, ImageMode, ImageViewConfig } from '../config/types.js'; import defaultImage from '../images/frigate-bird-in-sky.jpg'; import { localize } from '../localize/localize.js'; import imageStyle from '../scss/image.scss'; -import { FrigateCardMediaPlayer, MediaLoadedInfo } from '../types.js'; +import { FrigateCardMediaPlayer, MediaLoadedInfo, Message } from '../types.js'; import { contentsChanged } from '../utils/basic.js'; import { isHassDifferent } from '../utils/ha'; import { @@ -28,7 +28,7 @@ import { dispatchMediaPlayEvent, } from '../utils/media-info.js'; import { View } from '../view/view.js'; -import { dispatchErrorMessageEvent } from './message.js'; +import { renderMessage } from './message.js'; // See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py . const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000; @@ -53,6 +53,9 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay @property({ attribute: false, hasChanged: contentsChanged }) public imageConfig?: ImageViewConfig; + @state() + protected _message: Message | null = null; + protected _refImage: Ref = createRef(); protected _cachedValueController?: CachedValueController; @@ -175,6 +178,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay if (!this._cachedValueController?.value) { this._cachedValueController?.updateValue(); } + + if (['imageConfig', 'view'].some((prop) => changedProps.has(prop))) { + this._message = null; + } } /** @@ -211,6 +218,7 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay */ disconnectedCallback(): void { this._cachedValueController?.stopTimer(); + this._message = null; document.removeEventListener('visibilitychange', this._boundVisibilityHandler); super.disconnectedCallback(); } @@ -329,6 +337,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay } protected render(): TemplateResult | void { + if (this._message) { + return renderMessage(this._message); + } + const src = this._cachedValueController?.value; // Note the use of live() below to ensure the update will restore the image // src if it's been changed via _forceSafeImage(). @@ -363,9 +375,11 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay } else if (mode === 'url') { // In url mode, the user likely specified a URL that cannot be // resolved. Show an error message. - dispatchErrorMessageEvent(this, localize('error.image_load_error'), { + this._message = { + type: 'error', + message: localize('error.image_load_error'), context: this.imageConfig, - }); + }; } }} /> diff --git a/src/components/live/index.ts b/src/components/live/index.ts index 1210754d..11a23b11 100644 --- a/src/components/live/index.ts +++ b/src/components/live/index.ts @@ -1,13 +1,5 @@ -import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { keyed } from 'lit/directives/keyed.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { ConditionsManagerEpoch } from '../../card-controller/conditions-manager.js'; import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js'; @@ -53,15 +45,6 @@ export class FrigateCardLive extends LitElement { protected _controller = new LiveController(this); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected shouldUpdate(_changedProps: PropertyValues): boolean { - return this._controller.shouldUpdate(); - } - - protected willUpdate(): void { - this._controller.clearMessageReceived(); - } - protected render(): TemplateResult | void { if (!this.hass || !this.nonOverriddenLiveConfig || !this.cameraManager) { return; @@ -73,28 +56,22 @@ export class FrigateCardLive extends LitElement { // carousel (not necessarily the selected camera). // - Various events are captured to prevent them propagating upwards if the // card is in the background. - // - The entire returned template is keyed to allow for the whole template - // to be re-rendered in certain circumstances (specifically: if a message - // is received when the card is in the background). - return html`${keyed( - this._controller.getRenderEpoch(), - html` - - - `, - )}`; + return html` + + + `; } static get styles(): CSSResultGroup { diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 69a45e77..352c47ef 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -23,7 +23,6 @@ import { localize } from '../../localize/localize.js'; import liveProviderStyle from '../../scss/live-provider.scss'; import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js'; import { aspectRatioToString } from '../../utils/basic.js'; -import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js'; import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; import { playMediaMutingIfNecessary } from '../../utils/media.js'; @@ -31,6 +30,7 @@ import { renderMessage } from '../message.js'; import '../next-prev-control.js'; import '../ptz.js'; import '../surround.js'; +import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js'; @customElement('frigate-card-live-provider') export class FrigateCardLiveProvider @@ -71,6 +71,9 @@ export class FrigateCardLiveProvider @state() protected _isVideoMediaLoaded = false; + @state() + protected _hasProviderError = false; + protected _refProvider: Ref = createRef(); // A note on dynamic imports: @@ -165,7 +168,9 @@ export class FrigateCardLiveProvider return ( !!this.cameraConfig?.camera_entity && !!this.hass && - !!this.liveConfig?.show_image_during_load + !!this.liveConfig?.show_image_during_load && + // Do not continue to show image during loading if an error has occurred. + !this._hasProviderError ); } @@ -177,6 +182,10 @@ export class FrigateCardLiveProvider this._isVideoMediaLoaded = true; } + protected _providerErrorHandler(): void { + this._hasProviderError = true; + } + protected willUpdate(changedProps: PropertyValues): void { if (changedProps.has('load')) { if (!this.load) { @@ -263,17 +272,30 @@ export class FrigateCardLiveProvider }; if (provider === 'ha' || provider === 'image') { - const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig); + if (!this.cameraConfig?.camera_entity) { + dispatchLiveErrorEvent(this); + return renderMessage({ + message: localize('error.no_live_camera'), + type: 'error', + icon: 'mdi:camera', + context: this.cameraConfig, + }); + } + + const stateObj = this.hass.states[this.cameraConfig.camera_entity]; if (!stateObj) { - return; + dispatchLiveErrorEvent(this); + return renderMessage({ + message: localize('error.live_camera_not_found'), + type: 'error', + icon: 'mdi:camera', + context: this.cameraConfig, + }); } + if (stateObj.state === 'unavailable') { + dispatchLiveErrorEvent(this); dispatchMediaUnloadedEvent(this); - - // An unavailable camera gets a message rendered in place vs dispatched, - // as this may be a common occurrence (e.g. Frigate cameras that stop - // receiving frames). Otherwise a single temporarily unavailable camera - // would render a whole carousel inoperable. return renderMessage({ message: `${localize('error.live_camera_unavailable')}${ this.label ? `: ${this.label}` : '' @@ -291,6 +313,7 @@ export class FrigateCardLiveProvider ${ref(this._refProvider)} .hass=${this.hass} .cameraConfig=${this.cameraConfig} + @frigate-card:live:error=${() => this._providerErrorHandler()} @frigate-card:media:loaded=${(ev: Event) => { if (provider === 'image') { // Only count the media has loaded if the required provider is @@ -311,6 +334,7 @@ export class FrigateCardLiveProvider .hass=${this.hass} .cameraConfig=${this.cameraConfig} ?controls=${this.liveConfig.controls.builtin} + @frigate-card:live:error=${() => this._providerErrorHandler()} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` @@ -324,6 +348,7 @@ export class FrigateCardLiveProvider .microphoneStream=${this.microphoneStream} .microphoneConfig=${this.liveConfig.microphone} ?controls=${this.liveConfig.controls.builtin} + @frigate-card:live:error=${() => this._providerErrorHandler()} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` @@ -336,6 +361,7 @@ export class FrigateCardLiveProvider .cameraEndpoints=${this.cameraEndpoints} .cardWideConfig=${this.cardWideConfig} ?controls=${this.liveConfig.controls.builtin} + @frigate-card:live:error=${() => this._providerErrorHandler()} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` @@ -347,6 +373,7 @@ export class FrigateCardLiveProvider .cameraConfig=${this.cameraConfig} .cameraEndpoints=${this.cameraEndpoints} .cardWideConfig=${this.cardWideConfig} + @frigate-card:live:error=${() => this._providerErrorHandler()} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index 7124ddf2..08e7caf6 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -6,17 +6,22 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { CameraEndpoints } from '../../../../camera-manager/types.js'; +import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js'; import { CameraConfig, MicrophoneConfig } from '../../../../config/types.js'; import { localize } from '../../../../localize/localize.js'; import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss'; -import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../../../types.js'; -import { getEndpointAddressOrDispatchError } from '../../../../utils/endpoint.js'; +import { + ExtendedHomeAssistant, + FrigateCardMediaPlayer, + Message, +} from '../../../../types.js'; +import { convertEndpointAddressToSignedWebsocket } from '../../../../utils/endpoint.js'; import { setControlsOnVideo } from '../../../../utils/media.js'; import { screenshotMedia } from '../../../../utils/screenshot.js'; import '../../../image.js'; -import { dispatchErrorMessageEvent } from '../../../message.js'; +import { renderMessage } from '../../../message.js'; import { VideoRTC } from './video-rtc.js'; customElements.define('frigate-card-live-go2rtc-player', VideoRTC); @@ -48,6 +53,9 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla @property({ attribute: true, type: Boolean }) public controls = false; + @state() + protected _message: Message | null = null; + protected _player?: VideoRTC; public async play(): Promise { @@ -96,6 +104,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla disconnectedCallback(): void { this._player = undefined; + this._message = null; } connectedCallback(): void { @@ -113,18 +122,27 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla const endpoint = this.cameraEndpoints?.go2rtc; if (!endpoint) { - return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), { + this._message = { + type: 'error', + message: localize('error.live_camera_no_endpoint'), context: this.cameraConfig, - }); + }; + dispatchLiveErrorEvent(this); + return; } - const address = await getEndpointAddressOrDispatchError( - this, + const address = await convertEndpointAddressToSignedWebsocket( this.hass, endpoint, GO2RTC_URL_SIGN_EXPIRY_SECONDS, ); if (!address) { + this._message = { + type: 'error', + message: localize('error.failed_sign'), + context: this.cameraConfig, + }; + dispatchLiveErrorEvent(this); return; } @@ -143,7 +161,11 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla } protected willUpdate(changedProps: PropertyValues): void { - if (!this._player || changedProps.has('cameraEndpoints')) { + if (changedProps.has('cameraEndpoints')) { + this._message = null; + } + + if (!this._message && (!this._player || changedProps.has('cameraEndpoints'))) { this._createPlayer(); } @@ -163,6 +185,9 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla } protected render(): TemplateResult | void { + if (this._message) { + return renderMessage(this._message); + } return html`${this._player}`; } diff --git a/src/components/live/providers/ha.ts b/src/components/live/providers/ha.ts index 860c460f..bd31325f 100644 --- a/src/components/live/providers/ha.ts +++ b/src/components/live/providers/ha.ts @@ -3,14 +3,11 @@ 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 { CameraConfig } from '../../../config/types'; -import { localize } from '../../../localize/localize'; import '../../../patches/ha-camera-stream'; import '../../../patches/ha-hls-player.js'; import '../../../patches/ha-web-rtc-player.js'; import liveHAStyle from '../../../scss/live-ha.scss'; import { FrigateCardMediaPlayer } from '../../../types.js'; -import { renderMessage } from '../../message'; -import { getStateObjOrDispatchError } from '../../../utils/get-state-obj'; @customElement('frigate-card-live-ha') export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPlayer { @@ -66,22 +63,12 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla return; } - const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig); - if (!stateObj) { - return; - } - if (stateObj.state === 'unavailable') { - return renderMessage({ - message: localize('error.live_camera_unavailable'), - type: 'error', - icon: 'mdi:connection', - context: this.cameraConfig, - }); - } return html` diff --git a/src/components/live/providers/image.ts b/src/components/live/providers/image.ts index 9167731d..b7090233 100644 --- a/src/components/live/providers/image.ts +++ b/src/components/live/providers/image.ts @@ -5,7 +5,6 @@ import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { CameraConfig } from '../../../config/types'; import basicBlockStyle from '../../../scss/basic-block.scss'; import { FrigateCardMediaPlayer } from '../../../types.js'; -import { getStateObjOrDispatchError } from '../../../utils/get-state-obj'; import '../../image.js'; @customElement('frigate-card-live-image') @@ -59,8 +58,6 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia return; } - getStateObjOrDispatchError(this, this.hass, this.cameraConfig); - return html` { return this._jsmpegVideoPlayer?.play(); } @@ -84,6 +98,14 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null; } + protected willUpdate(changedProperties: PropertyValues): void { + if ( + ['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop)) + ) { + this._message = null; + } + } + /** * Create a JSMPEG player. * @param url The URL for the player to connect to. @@ -150,6 +172,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi * Reset / destroy the player. */ protected _resetPlayer(): void { + this._message = null; this._refreshPlayerTimer.stop(); if (this._jsmpegVideoPlayer) { try { @@ -199,18 +222,27 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi const endpoint = this.cameraEndpoints?.jsmpeg; if (!endpoint) { - return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), { + this._message = { + message: localize('error.live_camera_no_endpoint'), + type: 'error', context: this.cameraConfig, - }); + }; + dispatchLiveErrorEvent(this); + return; } - const address = await getEndpointAddressOrDispatchError( - this, + const address = await convertEndpointAddressToSignedWebsocket( this.hass, endpoint, JSMPEG_URL_SIGN_EXPIRY_SECONDS, ); if (!address) { + this._message = { + type: 'error', + message: localize('error.failed_sign'), + context: this.cameraConfig, + }; + dispatchLiveErrorEvent(this); return; } @@ -225,11 +257,23 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi * Master render method. */ protected render(): TemplateResult | void { + if (this._message) { + return renderMessage(this._message); + } + const _render = async (): Promise => { await this._refreshPlayer(); if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { - return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); + if (!this._message) { + this._message = { + message: localize('error.jsmpeg_no_player'), + type: 'error', + context: this.cameraConfig, + }; + dispatchLiveErrorEvent(this); + } + return; } return html`${this._jsmpegCanvasElement}`; }; diff --git a/src/components/live/providers/webrtc-card.ts b/src/components/live/providers/webrtc-card.ts index 98085c52..88352756 100644 --- a/src/components/live/providers/webrtc-card.ts +++ b/src/components/live/providers/webrtc-card.ts @@ -1,13 +1,21 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { Task } from '@lit-labs/task'; -import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; import { CameraEndpoints } from '../../../camera-manager/types.js'; +import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js'; import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js'; import { CameraConfig, CardWideConfig } from '../../../config/types.js'; import { localize } from '../../../localize/localize.js'; import liveWebRTCCardStyle from '../../../scss/live-webrtc-card.scss'; -import { FrigateCardError, FrigateCardMediaPlayer } from '../../../types.js'; +import { FrigateCardError, FrigateCardMediaPlayer, Message } from '../../../types.js'; import { mayHaveAudio } from '../../../utils/audio.js'; import { dispatchMediaLoadedEvent, @@ -22,7 +30,7 @@ import { } from '../../../utils/media.js'; import { screenshotMedia } from '../../../utils/screenshot.js'; import { renderTask } from '../../../utils/task.js'; -import { dispatchErrorMessageEvent, renderProgressIndicator } from '../../message.js'; +import { renderMessage, renderProgressIndicator } from '../../message.js'; import { VideoRTC } from './go2rtc/video-rtc.js'; // Create a wrapper for AlexxIT's WebRTC card @@ -44,6 +52,9 @@ export class FrigateCardLiveWebRTCCard @property({ attribute: true, type: Boolean }) public controls = false; + @state() + protected _message: Message | null = null; + protected hass?: HomeAssistant; // A task to await the load of the WebRTC component. @@ -106,6 +117,19 @@ export class FrigateCardLiveWebRTCCard this.requestUpdate(); } + disconnectedCallback(): void { + this._message = null; + super.disconnectedCallback(); + } + + protected willUpdate(changedProperties: PropertyValues): void { + if ( + ['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop)) + ) { + this._message = null; + } + } + protected _getVideoRTC(): VideoRTC | null { return (this.renderRoot?.querySelector('#webrtc') ?? null) as VideoRTC | null; } @@ -162,23 +186,28 @@ export class FrigateCardLiveWebRTCCard return null; } - /** - * Master render method. - * @returns A rendered template. - */ protected render(): TemplateResult | void { + if (this._message) { + return renderMessage(this._message); + } + 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 }, - ); + this._message = { + type: 'error', + message: + e instanceof FrigateCardError + ? e.message + : localize('error.webrtc_card_reported_error') + + ': ' + + (e as Error).message, + context: (e as FrigateCardError).context, + }; + dispatchLiveErrorEvent(this); + return; } if (webrtcElement) { // Set the id to ensure that the relevant CSS styles will have @@ -192,7 +221,7 @@ export class FrigateCardLiveWebRTCCard // 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, { + return renderTask(this._webrtcTask, render, { inProgressFunc: () => renderProgressIndicator({ message: localize('error.webrtc_card_waiting'), @@ -201,9 +230,6 @@ export class FrigateCardLiveWebRTCCard }); } - /** - * Updated lifecycle callback. - */ public updated(): void { // Extract the video component after it has been rendered and generate the // media load event. @@ -232,9 +258,6 @@ export class FrigateCardLiveWebRTCCard }); } - /** - * Get styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(liveWebRTCCardStyle); } diff --git a/src/components/message.ts b/src/components/message.ts index 6b553ede..20fd56b6 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -152,12 +152,15 @@ export function renderProgressIndicator(options?: { } /** - * Dispatch an event with a message to show to the user. + * Dispatch an event with a message to show to the user. Calling this method + * will grind the card to a halt, so should only be used for "global" / critical + * errors (i.e. not for individual errors with a given camera, since there may + * be multiple correctly functioning cameras in a grid). * @param element The element to send the event. * @param message The message to show. * @param options Optional icon and context to include. */ -export function dispatchMessageEvent( +function dispatchMessageEvent( element: EventTarget, message: string, type: MessageType, @@ -175,12 +178,15 @@ export function dispatchMessageEvent( } /** - * Dispatch an event with an error message to show to the user. + * Dispatch an event with an error message to show to the user. Calling this + * method will grind the card to a halt, so should only be used for "global" / + * critical errors (i.e. not for individual errors with a given camera, since + * there may be multiple correctly functioning cameras in a grid). * @param element The element to send the event. * @param message The message to show. * @param options Optional context to include. */ -export function dispatchErrorMessageEvent( +function dispatchErrorMessageEvent( element: EventTarget, message: string, options?: { @@ -193,7 +199,10 @@ export function dispatchErrorMessageEvent( } /** - * Dispatch an event with an error message to show to the user. + * Dispatch an event with an error message to show to the user. Calling this + * method will grind the card to a halt, so should only be used for "global" / + * critical errors (i.e. not for individual errors with a given camera, since + * there may be multiple correctly functioning cameras in a grid). * @param element The element to send the event. * @param message The message to show. */ diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index 64426211..83b5695b 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -37,6 +37,9 @@ export class FrigateCardNextPreviousControl extends LitElement { // Label that is used for ARIA support and as tooltip. @property() label = ''; + @state() + protected _thumbnailError = false; + protected _embedThumbnailTask = createFetchThumbnailTask( this, () => this.hass, @@ -49,7 +52,9 @@ export class FrigateCardNextPreviousControl extends LitElement { } const renderIcon = - !this.thumbnail || ['chevrons', 'icons'].includes(this._controlConfig.style); + !this.thumbnail || + ['chevrons', 'icons'].includes(this._controlConfig.style) || + this._thumbnailError; const classes = { controls: true, @@ -62,7 +67,10 @@ export class FrigateCardNextPreviousControl extends LitElement { if (renderIcon) { const icon = - !this.thumbnail || !this.icon || this._controlConfig.style === 'chevrons' + !this.thumbnail || + !this.icon || + this._controlConfig.style === 'chevrons' || + this._thumbnailError ? this.side === 'left' ? 'mdi:chevron-left' : 'mdi:chevron-right' @@ -74,7 +82,6 @@ export class FrigateCardNextPreviousControl extends LitElement { } return renderTask( - this, this._embedThumbnailTask, (embeddedThumbnail: string | null) => embeddedThumbnail @@ -85,7 +92,13 @@ export class FrigateCardNextPreviousControl extends LitElement { aria-label="${this.label}" />` : html``, - { inProgressFunc: () => html`
` }, + { + inProgressFunc: () => html`
`, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + errorFunc: (_e: Error) => { + this._thumbnailError = true; + }, + }, ); } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index a24c1ec8..6070487b 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -8,7 +8,7 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../camera-manager/manager.js'; import { CameraManagerCameraMetadata } from '../camera-manager/types.js'; @@ -45,6 +45,9 @@ export class FrigateCardThumbnailFeatureThumbnail extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; + @state() + protected _thumbnailError = false; + protected _embedThumbnailTask?: Task; // Only load thumbnails on view in case there is a very large number of them. @@ -107,17 +110,21 @@ export class FrigateCardThumbnailFeatureThumbnail extends LitElement { title=${localize('thumbnail.no_thumbnail')} > `; - if (!this._embedThumbnailTask) { + if (!this._embedThumbnailTask || this._thumbnailError) { return imageOff; } return html`${this.thumbnail ? renderTask( - this, this._embedThumbnailTask, (embeddedThumbnail: string | null) => embeddedThumbnail ? html`` : html``, - { inProgressFunc: () => imageOff }, + { + inProgressFunc: () => imageOff, + errorFunc: () => { + this._thumbnailError = true; + }, + }, ) : imageOff} `; } diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts index e8eec584..f7295002 100644 --- a/src/components/viewer/carousel.ts +++ b/src/components/viewer/carousel.ts @@ -38,7 +38,7 @@ import { getTextDirection } from '../../utils/text-direction.js'; import { ViewMedia } from '../../view/media.js'; import '../carousel'; import type { EmblaCarouselPlugins } from '../carousel.js'; -import { dispatchMessageEvent } from '../message.js'; +import { renderMessage } from '../message.js'; import '../next-prev-control.js'; import '../ptz.js'; import './provider.js'; @@ -348,8 +348,15 @@ export class FrigateCardViewerCarousel extends LitElement { protected render(): TemplateResult | void { const mediaCount = this._media?.length ?? 0; if (!this._media || !mediaCount) { - return dispatchMessageEvent(this, localize('common.no_media'), 'info', { + return renderMessage({ + message: localize('common.no_media'), + type: 'info', icon: 'mdi:multimedia', + ...(this.viewFilterCameraID && { + context: { + camera_id: this.viewFilterCameraID, + }, + }), }); } diff --git a/src/const.ts b/src/const.ts index f25e45ec..b60b536a 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,5 +1,5 @@ export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const; -export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const; +export const TROUBLESHOOTING_URL = `https://card.camera/#/troubleshooting` as const; export const CONF_AUTOMATIONS = 'automations' as const; diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 86b2133f..c9e5e5f2 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -12,7 +12,8 @@ import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; -import { dispatchErrorMessageEvent } from '../components/message.js'; +import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; +import { renderMessage } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { FrigateCardMediaPlayer } from '../types.js'; import { mayHaveAudio } from '../utils/audio.js'; @@ -97,8 +98,14 @@ customElements.whenDefined('ha-hls-player').then(() => { protected render(): TemplateResult { if (this._error) { if (this._errorIsFatal) { - // Use native Frigate card error handling for fatal errors. - return dispatchErrorMessageEvent(this, this._error); + dispatchLiveErrorEvent(this); + return renderMessage({ + type: 'error', + message: this._error, + context: { + entity_id: this.entityid, + }, + }); } else { errorToConsole(this._error, console.error); } diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 5929167b..410a3a3b 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -12,8 +12,8 @@ import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; -import { screenshotMedia } from '../utils/screenshot.js'; -import { dispatchErrorMessageEvent } from '../components/message.js'; +import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; +import { renderMessage } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { FrigateCardMediaPlayer } from '../types.js'; import { mayHaveAudio } from '../utils/audio.js'; @@ -28,6 +28,7 @@ import { MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo, } from '../utils/media.js'; +import { screenshotMedia } from '../utils/screenshot.js'; customElements.whenDefined('ha-web-rtc-player').then(() => { @customElement('frigate-card-ha-web-rtc-player') @@ -94,9 +95,14 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { // ===================================================================================== protected render(): TemplateResult | void { if (this._error) { - // Use native Frigate card error handling, and attach the entityid to - // clarify which camera the error refers to. - return dispatchErrorMessageEvent(this, `${this._error} (${this.entityid})`); + dispatchLiveErrorEvent(this); + return renderMessage({ + type: 'error', + message: this._error, + context: { + entity_id: this.entityid, + }, + }); } return html`