From 8c3663709203d99349deb304d44393a83246fd68 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 22 Jul 2026 13:45:46 -0700 Subject: [PATCH] fix: Only report a live error from the visible ha-camera-stream player (#2596) - Closes: #2583 --- src/patches/ha-camera-stream.ts | 69 ++++++++++++++++++++++++++++++++ src/patches/ha-hls-player.ts | 34 +++++++++++++++- src/patches/ha-web-rtc-player.ts | 32 ++++++++++++++- 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index ce9cb53c..97db1e20 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -19,7 +19,9 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import type { MediaUnavailableIssueReason } from '../card-controller/issues/issues/media-unavailable.js'; import { HA_CAMERA_STREAM_MUTE_CHANGE_EVENT } from '../components-lib/live/ha-stream-mute-controller.js'; +import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js'; import '../components/image-player.js'; @@ -36,6 +38,15 @@ import { onAbort } from '../utils/abort-signal.js'; import './ha-hls-player.js'; import './ha-web-rtc-player.js'; +// A failure reported by one of the inner players. Its existence is the failure; +// `reason` is present only when the player named a specific cause. `dispatched` +// records whether it has already been announced, so a stream that fails again +// after recovering is announced again. +interface StreamError { + reason?: MediaUnavailableIssueReason; + dispatched: boolean; +} + void customElements.whenDefined('ha-camera-stream').then(() => { // ======================================================================================== // From: @@ -73,6 +84,15 @@ void customElements.whenDefined('ha-camera-stream').then(() => { }, ); + // An inner player that fails renders its own error, but a hidden one is in + // a `display: none` subtree and its failure says nothing about the stream + // the user is watching. Errors are therefore captured per type here + // (`stopPropagation` in `_captureInnerError`) and only re-dispatched once + // the failing type is the visible one, which also covers HA later promoting + // a previously-hidden stream. See: + // https://github.com/dermotduffy/advanced-camera-card/issues/2583 + private _errorPerStream: Partial> = {}; + // The currently-visible stream type, refreshed in `updated()`. private _visibleStreamType: StreamType | null = null; @@ -141,6 +161,10 @@ void customElements.whenDefined('ha-camera-stream').then(() => { // controller in updated(). ev.stopPropagation(); this._mediaLoadedInfoPerStream[stream] = ev.detail.info; + + // Media playing is proof this stream recovered. + delete this._errorPerStream[stream]; + onAbort(ev.detail.signal, () => { if (this._mediaLoadedInfoPerStream[stream] === ev.detail.info) { delete this._mediaLoadedInfoPerStream[stream]; @@ -149,6 +173,17 @@ void customElements.whenDefined('ha-camera-stream').then(() => { this.requestUpdate(); } + private _captureInnerError( + stream: StreamType, + ev: CustomEvent, + ) { + // Stop the inner-player event at the aggregator boundary; it is + // re-dispatched from updated() only if this stream is the visible one. + ev.stopPropagation(); + this._errorPerStream[stream] = { reason: ev.detail, dispatched: false }; + this.requestUpdate(); + } + protected _renderStream(stream: Stream) { if (!this.stateObj) { return nothing; @@ -183,6 +218,9 @@ void customElements.whenDefined('ha-camera-stream').then(() => { @advanced-camera-card:media:loaded=${( ev: CustomEvent, ) => this._captureInnerLoad(STREAM_TYPE_HLS, ev)} + @advanced-camera-card:live:error=${( + ev: CustomEvent, + ) => this._captureInnerError(STREAM_TYPE_HLS, ev)} @streams=${this._handleHlsStreams} class="player ${stream.visible ? '' : 'hidden'}" >`; @@ -201,6 +239,9 @@ void customElements.whenDefined('ha-camera-stream').then(() => { @advanced-camera-card:media:loaded=${( ev: CustomEvent, ) => this._captureInnerLoad(STREAM_TYPE_WEB_RTC, ev)} + @advanced-camera-card:live:error=${( + ev: CustomEvent, + ) => this._captureInnerError(STREAM_TYPE_WEB_RTC, ev)} @streams=${this._handleWebRtcStreams} class="player ${stream.visible ? '' : 'hidden'}" >`; @@ -244,6 +285,34 @@ void customElements.whenDefined('ha-camera-stream').then(() => { }, }); } + + this._discardErrorsOnEntityChange(changedProps); + this._dispatchVisibleStreamError(); + } + + // A different camera entity restarts the inner players from scratch (HA + // clears their errors), so previously-recorded failures no longer describe + // what is playing and must not suppress a fresh one. + private _discardErrorsOnEntityChange(changedProps: PropertyValues): void { + const previousStateObj = changedProps.get('stateObj'); + if (!previousStateObj || previousStateObj.entity_id === this.stateObj?.entity_id) { + return; + } + this._errorPerStream = {}; + } + + // Surface the visible stream's failure (if any) as this element's own + // error. The decision belongs here rather than in `_captureInnerError` + // because the visible type is only known once `_streams()` has been + // re-evaluated for this update. + private _dispatchVisibleStreamError(): void { + const stream = this._visibleStreamType; + const error = stream ? this._errorPerStream[stream] : null; + if (!error || error.dispatched) { + return; + } + error.dispatched = true; + dispatchLiveErrorEvent(this, error.reason); } static get styles(): CSSResultGroup { diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index f4890282..d23d2620 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -9,7 +9,14 @@ // available as compilation time. // ==================================================================== -import { css, html, unsafeCSS, type CSSResultGroup, type TemplateResult } from 'lit'; +import { + css, + html, + unsafeCSS, + type CSSResultGroup, + type PropertyValues, + type TemplateResult, +} from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; @@ -61,6 +68,8 @@ void customElements.whenDefined('ha-hls-player').then(() => { }, ); + private _lastErrored = false; + public async getMediaPlayerController(): Promise { return this._mediaPlayerController; } @@ -72,7 +81,6 @@ void customElements.whenDefined('ha-hls-player').then(() => { protected render(): TemplateResult { if (this._error) { if (this._errorIsFatal) { - dispatchLiveErrorEvent(this); return renderMediaNotification({ title: localize('issues.media_unavailable.reasons.playback_error'), detail: this._error, @@ -106,6 +114,28 @@ void customElements.whenDefined('ha-hls-player').then(() => { `; } + protected updated(changedProps: PropertyValues): void { + // A new entity is a different stream, so an earlier failure no longer + // describes it. Cleared before the superclass runs, because that is what + // restarts the stream and may raise the new entity's first failure in + // this same update. + if (changedProps.has('entityid')) { + this._lastErrored = false; + } + + super.updated(changedProps); + + // Announce each transition into fatal failure. The error is cleared + // whenever the stream is restarted, so a player can fail more than once + // and every failure must be reported. Non-fatal errors are recoverable + // and are only logged (see render()). + const errored = !!this._error && this._errorIsFatal; + if (errored && !this._lastErrored) { + dispatchLiveErrorEvent(this); + } + this._lastErrored = errored; + } + private _loadedDataHandler(ev: Event) { super._loadedData(); const info = createMediaLoadedInfo(ev, { diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index c1be18e7..4b1bed07 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -9,7 +9,14 @@ // available as compilation time. // ==================================================================== -import { css, html, unsafeCSS, type CSSResultGroup, type TemplateResult } from 'lit'; +import { + css, + html, + unsafeCSS, + type CSSResultGroup, + type PropertyValues, + type TemplateResult, +} from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -63,6 +70,8 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { protected _audioTracksMuteStateCleanup: AudioTracksMuteStateCleanup = null; + private _lastErrored = false; + public async getMediaPlayerController(): Promise { return this._mediaPlayerController; } @@ -112,7 +121,6 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { // ===================================================================================== protected render(): TemplateResult | void { if (this._error) { - dispatchLiveErrorEvent(this); return renderMediaNotification({ title: localize('issues.media_unavailable.reasons.playback_error'), detail: this._error, @@ -143,6 +151,26 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { `; } + protected updated(changedProps: PropertyValues): void { + // A new entity is a different stream, so an earlier failure no longer + // describes it. The restart may already have raised the new entity's + // first failure by this point, so this must not gate on the error itself. + if (changedProps.has('entityid')) { + this._lastErrored = false; + } + + super.updated(changedProps); + + // Announce each transition into failure. The error is cleared whenever + // the stream is restarted (on reconnection, or an entity change), so a + // player can fail more than once and every failure must be reported. + const errored = !!this._error; + if (errored && !this._lastErrored) { + dispatchLiveErrorEvent(this); + } + this._lastErrored = errored; + } + private _loadedDataHandler(ev: Event) { super._loadedData(); const info = createMediaLoadedInfo(ev, {