fix: Send the WebRTC player's error text to the media_unavailable notification (#2741)

- Closes: #2723
This commit is contained in:
Dermot Duffy
2026-08-30 19:08:03 -07:00
committed by GitHub
parent 299973a1fa
commit 95b85bf608
4 changed files with 206 additions and 58 deletions
+33 -18
View File
@@ -1,12 +1,9 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
// ==================================================================== // ====================================================================
// ** Keep modifications to this file to a minimum ** // ** Keep modifications to this file to a minimum **
// //
// Type checking is disabled since this is a modified copy-and-paste of // This is a modified copy-and-paste of the underlying render() function.
// underlying render() function, but the rest of the class source it not // The base class is only registered at runtime, so the members this file
// available as compilation time. // uses from it are declared in ./types.ts.
// ==================================================================== // ====================================================================
import { import {
@@ -16,8 +13,9 @@ import {
unsafeCSS, unsafeCSS,
type CSSResultGroup, type CSSResultGroup,
type PropertyValues, type PropertyValues,
type TemplateResult,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import { HA_CAMERA_STREAM_MUTE_CHANGE_EVENT } from '../components-lib/live/ha-stream-mute-controller.js'; import { HA_CAMERA_STREAM_MUTE_CHANGE_EVENT } from '../components-lib/live/ha-stream-mute-controller.js';
import { import {
@@ -28,6 +26,7 @@ import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-
import '../components/image-player.js'; import '../components/image-player.js';
import type { HomeAssistant } from '../ha/types.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss?inline'; import liveHAComponentsStyle from '../scss/live-ha-components.scss?inline';
import type { import type {
MediaLoadedInfo, MediaLoadedInfo,
@@ -40,6 +39,13 @@ import { onAbort } from '../utils/abort-signal.js';
import './ha-hls-player.js'; import './ha-hls-player.js';
import './ha-web-rtc-player.js'; import './ha-web-rtc-player.js';
import type {
AdvancedCameraCardHaCameraStreamElement,
CameraEntity,
ConstructableHaCameraStream,
HaStream,
} from './types.js';
// A failure reported by one of the inner players. Its existence is the failure; // A failure reported by one of the inner players. Its existence is the failure;
// `error` carries whatever the player knew about it. `dispatched` records // `error` carries whatever the player knew about it. `dispatched` records
// whether it has already been announced, so a stream that fails again after // whether it has already been announced, so a stream that fails again after
@@ -64,14 +70,18 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
const STREAM_TYPE_HLS = 'hls'; const STREAM_TYPE_HLS = 'hls';
const STREAM_TYPE_WEB_RTC = 'web_rtc'; const STREAM_TYPE_WEB_RTC = 'web_rtc';
const STREAM_TYPE_MJPEG = 'mjpeg'; const STREAM_TYPE_MJPEG = 'mjpeg';
type StreamType = STREAM_TYPE_HLS | STREAM_TYPE_WEB_RTC | STREAM_TYPE_MJPEG; type StreamType =
| typeof STREAM_TYPE_HLS
| typeof STREAM_TYPE_WEB_RTC
| typeof STREAM_TYPE_MJPEG;
const HaCameraStream = customElements.get(
'ha-camera-stream',
) as ConstructableHaCameraStream;
class AdvancedCameraCardHaCameraStream extends HaCameraStream implements MediaPlayer {
public declare hass?: HomeAssistant;
@customElement('advanced-camera-card-ha-camera-stream')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class AdvancedCameraCardHaCameraStream
extends customElements.get('ha-camera-stream')
implements MediaPlayer
{
@property({ attribute: false }) @property({ attribute: false })
public targetID?: string; public targetID?: string;
@@ -80,7 +90,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
// independently -- we suppress those at this boundary (`stopPropagation` in // independently -- we suppress those at this boundary (`stopPropagation` in
// `_captureInnerLoad`), cache the latest per type, and republish the // `_captureInnerLoad`), cache the latest per type, and republish the
// visible one's info via our own source controller in `updated()`. // visible one's info via our own source controller in `updated()`.
private _mediaLoadedInfoPerStream: Record<StreamType, MediaLoadedInfo> = {}; private _mediaLoadedInfoPerStream: Partial<Record<StreamType, MediaLoadedInfo>> = {};
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController( private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(
this, this,
{ {
@@ -185,7 +195,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
this.requestUpdate(); this.requestUpdate();
} }
protected _renderStream(stream: Stream) { protected _renderStream(stream: HaStream): TemplateResult | typeof nothing {
if (!this.stateObj) { if (!this.stateObj) {
return nothing; return nothing;
} }
@@ -301,7 +311,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
// clears their errors), so previously-recorded failures no longer describe // clears their errors), so previously-recorded failures no longer describe
// what is playing and must not suppress a fresh one. // what is playing and must not suppress a fresh one.
private _discardErrorsOnEntityChange(changedProps: PropertyValues): void { private _discardErrorsOnEntityChange(changedProps: PropertyValues): void {
const previousStateObj = changedProps.get('stateObj'); const previousStateObj: CameraEntity | undefined = changedProps.get('stateObj');
if (!previousStateObj || previousStateObj.entity_id === this.stateObj?.entity_id) { if (!previousStateObj || previousStateObj.entity_id === this.stateObj?.entity_id) {
return; return;
} }
@@ -339,10 +349,15 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
]; ];
} }
} }
customElements.define(
'advanced-camera-card-ha-camera-stream',
AdvancedCameraCardHaCameraStream,
);
}); });
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'advanced-camera-card-ha-camera-stream': AdvancedCameraCardHaCameraStream; 'advanced-camera-card-ha-camera-stream': AdvancedCameraCardHaCameraStreamElement;
} }
} }
+27 -18
View File
@@ -1,12 +1,9 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
// ==================================================================== // ====================================================================
// ** Keep modifications to this file to a minimum ** // ** Keep modifications to this file to a minimum **
// //
// Type checking is disabled since this is a modified copy-and-paste of // This is a modified copy-and-paste of the underlying render() function.
// underlying render() function, but the rest of the class source is not // The base class is only registered at runtime, so the members this file
// available as compilation time. // uses from it are declared in ./types.ts.
// ==================================================================== // ====================================================================
import { import {
@@ -17,7 +14,7 @@ import {
type PropertyValues, type PropertyValues,
type TemplateResult, type TemplateResult,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js'; import { query } from 'lit/decorators/query.js';
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
@@ -39,25 +36,26 @@ import {
dispatchMediaPlayEvent, dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent, dispatchMediaVolumeChangeEvent,
} from '../utils/media-info.js'; } from '../utils/media-info.js';
import type { ConstructableLitElement } from './types.js'; import type {
AdvancedCameraCardHaHlsPlayerElement,
ConstructableHaHlsPlayer,
} from './types.js';
void customElements.whenDefined('ha-hls-player').then(() => { void customElements.whenDefined('ha-hls-player').then(() => {
const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableLitElement; const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableHaHlsPlayer;
@customElement('advanced-camera-card-ha-hls-player')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class AdvancedCameraCardHaHlsPlayer extends HaHlsPlayer implements MediaPlayer { class AdvancedCameraCardHaHlsPlayer extends HaHlsPlayer implements MediaPlayer {
// Due to an obscure behavior when this card is casted, this element needs // Due to an obscure behavior when this card is casted, this element needs
// to use query rather than the ref directive to find the player. // to use query rather than the ref directive to find the player.
@query('#video') @query('#video')
protected _video: HTMLVideoElement; protected _video?: HTMLVideoElement;
@property({ attribute: false }) @property({ attribute: false })
public targetID?: string; public targetID?: string;
private _mediaPlayerController = new VideoMediaPlayerController( private _mediaPlayerController = new VideoMediaPlayerController(
this, this,
() => this._video, () => this._video ?? null,
() => this.controls, () => this.controls,
); );
@@ -99,14 +97,14 @@ void customElements.whenDefined('ha-hls-player').then(() => {
?playsinline=${this.playsInline} ?playsinline=${this.playsInline}
?controls=${this.controls} ?controls=${this.controls}
@loadedmetadata=${() => { @loadedmetadata=${() => {
if (this.controls) { if (this.controls && this._video) {
hideMediaControlsTemporarily( hideMediaControlsTemporarily(
this._video, this._video,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
); );
} }
}} }}
@loadeddata=${(ev) => this._loadedDataHandler(ev)} @loadeddata=${(ev: Event) => this._loadedDataHandler(ev)}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)} @volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@play=${() => dispatchMediaPlayEvent(this)} @play=${() => dispatchMediaPlayEvent(this)}
@pause=${() => dispatchMediaPauseEvent(this)} @pause=${() => dispatchMediaPauseEvent(this)}
@@ -136,13 +134,19 @@ void customElements.whenDefined('ha-hls-player').then(() => {
this._lastErrored = errored; this._lastErrored = errored;
} }
private _loadedDataHandler(ev: Event) { private _loadedDataHandler(ev: Event): void {
super._loadedData(); super._loadedData();
const video = this._video;
if (!video) {
return;
}
const info = createMediaLoadedInfo(ev, { const info = createMediaLoadedInfo(ev, {
mediaPlayerController: this._mediaPlayerController, mediaPlayerController: this._mediaPlayerController,
capabilities: { capabilities: {
supportsPause: true, supportsPause: true,
hasAudio: mayHaveAudio(this._video), hasAudio: mayHaveAudio(video),
}, },
technology: ['hls'], technology: ['hls'],
}); });
@@ -168,10 +172,15 @@ void customElements.whenDefined('ha-hls-player').then(() => {
]; ];
} }
} }
customElements.define(
'advanced-camera-card-ha-hls-player',
AdvancedCameraCardHaHlsPlayer,
);
}); });
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'advanced-camera-card-ha-hls-player': AdvancedCameraCardHaHlsPlayer; 'advanced-camera-card-ha-hls-player': AdvancedCameraCardHaHlsPlayerElement;
} }
} }
+22 -19
View File
@@ -1,12 +1,9 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
// ==================================================================== // ====================================================================
// ** Keep modifications to this file to a minimum ** // ** Keep modifications to this file to a minimum **
// //
// Type checking is disabled since this is a modified copy-and-paste of // This is a modified copy-and-paste of the underlying render() function.
// underlying render() function, but the rest of the class source it not // The base class is only registered at runtime, so the members this file
// available as compilation time. // uses from it are declared in ./types.ts.
// ==================================================================== // ====================================================================
import { import {
@@ -17,7 +14,7 @@ import {
type PropertyValues, type PropertyValues,
type TemplateResult, type TemplateResult,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js'; import { ifDefined } from 'lit/directives/if-defined.js';
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
@@ -42,15 +39,16 @@ import {
dispatchMediaPlayEvent, dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent, dispatchMediaVolumeChangeEvent,
} from '../utils/media-info.js'; } from '../utils/media-info.js';
import type { ConstructableLitElement } from './types.js'; import type {
AdvancedCameraCardHaWebRtcPlayerElement,
ConstructableHaWebRtcPlayer,
} from './types.js';
void customElements.whenDefined('ha-web-rtc-player').then(() => { void customElements.whenDefined('ha-web-rtc-player').then(() => {
const HaWebRtcPlayer = customElements.get( const HaWebRtcPlayer = customElements.get(
'ha-web-rtc-player', 'ha-web-rtc-player',
) as ConstructableLitElement; ) as ConstructableHaWebRtcPlayer;
@customElement('advanced-camera-card-ha-web-rtc-player')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class AdvancedCameraCardHaWebRtcPlayer extends HaWebRtcPlayer implements MediaPlayer { class AdvancedCameraCardHaWebRtcPlayer extends HaWebRtcPlayer implements MediaPlayer {
@property({ attribute: false }) @property({ attribute: false })
public targetID?: string; public targetID?: string;
@@ -76,7 +74,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
return this._mediaPlayerController; return this._mediaPlayerController;
} }
private async _startWebRtc(): Promise<void> { protected async _startWebRtc(): Promise<void> {
// There is a race condition in the underlying HA frontend code between // There is a race condition in the underlying HA frontend code between
// the element connection and the async start of the WebRTC session. If // the element connection and the async start of the WebRTC session. If
// the element is rapidly connected and disconnected, the RTC connection // the element is rapidly connected and disconnected, the RTC connection
@@ -94,7 +92,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
} }
} }
private _addTrack = async (event: RTCTrackEvent) => { protected _addTrack = async (event: RTCTrackEvent) => {
if (!this._remoteStream) { if (!this._remoteStream) {
return; return;
} }
@@ -143,7 +141,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
); );
} }
}} }}
@loadeddata=${(ev) => this._loadedDataHandler(ev)} @loadeddata=${(ev: Event) => this._loadedDataHandler(ev)}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)} @volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@play=${() => dispatchMediaPlayEvent(this)} @play=${() => dispatchMediaPlayEvent(this)}
@pause=${() => dispatchMediaPauseEvent(this)} @pause=${() => dispatchMediaPauseEvent(this)}
@@ -166,12 +164,12 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
// player can fail more than once and every failure must be reported. // player can fail more than once and every failure must be reported.
const errored = !!this._error; const errored = !!this._error;
if (errored && !this._lastErrored) { if (errored && !this._lastErrored) {
dispatchLiveErrorEvent(this, { detail: this._error }); dispatchLiveErrorEvent(this, { description: this._error });
} }
this._lastErrored = errored; this._lastErrored = errored;
} }
private _loadedDataHandler(ev: Event) { private _loadedDataHandler(ev: Event): void {
super._loadedData(); super._loadedData();
const info = createMediaLoadedInfo(ev, { const info = createMediaLoadedInfo(ev, {
mediaPlayerController: this._mediaPlayerController, mediaPlayerController: this._mediaPlayerController,
@@ -189,7 +187,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
// capabilities reflect the current state. // capabilities reflect the current state.
this._audioTracksMuteStateCleanup?.(); this._audioTracksMuteStateCleanup?.();
this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener( this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener(
this._peerConnection, this._peerConnection ?? null,
() => { () => {
const info = createMediaLoadedInfo(this._videoEl, { const info = createMediaLoadedInfo(this._videoEl, {
mediaPlayerController: this._mediaPlayerController, mediaPlayerController: this._mediaPlayerController,
@@ -206,7 +204,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
); );
} }
private _cleanUp(): void { protected _cleanUp(): void {
super._cleanUp(); super._cleanUp();
this._audioTracksMuteStateCleanup?.(); this._audioTracksMuteStateCleanup?.();
this._audioTracksMuteStateCleanup = null; this._audioTracksMuteStateCleanup = null;
@@ -229,10 +227,15 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
]; ];
} }
} }
customElements.define(
'advanced-camera-card-ha-web-rtc-player',
AdvancedCameraCardHaWebRtcPlayer,
);
}); });
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'advanced-camera-card-ha-web-rtc-player': AdvancedCameraCardHaWebRtcPlayer; 'advanced-camera-card-ha-web-rtc-player': AdvancedCameraCardHaWebRtcPlayerElement;
} }
} }
+124 -3
View File
@@ -1,5 +1,126 @@
import type { LitElement } from 'lit'; import type { HassEntity } from 'home-assistant-js-websocket';
import type { CSSResultGroup, LitElement, nothing, TemplateResult } from 'lit';
export interface ConstructableLitElement { import type { HomeAssistant } from '../ha/types.js';
new (...args: unknown[]): LitElement; import type { MediaPlayer } from '../types.js';
// The Home Assistant elements the card subclasses are only registered at
// runtime, so their source is unavailable at compilation time. The declarations
// below name the members each patch uses, taken from the Home Assistant
// frontend source linked at the top of each patch. They deliberately describe
// only that subset.
// A camera entity, whose `access_token` attribute the MJPEG stream URL is built
// from.
export interface CameraEntity extends HassEntity {
attributes: HassEntity['attributes'] & {
access_token?: string;
};
} }
// The stream types Home Assistant can serve from a camera entity. MJPEG is not
// among them: it is the fallback `ha-camera-stream` renders itself.
type HaStreamType = 'hls' | 'web_rtc';
// What a player reports about the stream it loaded, on the `streams` event.
interface HaStreamStatus {
hasAudio: boolean;
hasVideo: boolean;
}
// One of the candidate streams `ha-camera-stream` renders. Only one is visible;
// the rest are rendered hidden so they are ready to be promoted.
export interface HaStream {
type: HaStreamType | 'mjpeg';
visible: boolean;
}
// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
declare class HaHlsPlayerElement extends LitElement {
static override styles: CSSResultGroup;
entityid?: string;
url?: string;
posterUrl?: string;
controls: boolean;
muted: boolean;
autoPlay: boolean;
playsInline: boolean;
allowExoPlayer: boolean;
protected _error?: string;
// Whether `_error` stopped the stream, as opposed to one the player went on
// to recover from by itself.
protected _errorIsFatal: boolean;
protected _loadedData(): void;
}
// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
declare class HaWebRtcPlayerElement extends LitElement {
static override styles: CSSResultGroup;
entityid?: string;
posterUrl?: string;
controls: boolean;
muted: boolean;
autoPlay: boolean;
playsInline: boolean;
protected _error?: string;
protected _videoEl: HTMLVideoElement;
protected _peerConnection?: RTCPeerConnection;
protected _remoteStream?: MediaStream;
protected _startWebRtc(): Promise<void>;
protected _addTrack: (event: RTCTrackEvent) => Promise<void>;
protected _cleanUp(): void;
protected _loadedData(): void;
}
// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts
declare class HaCameraStreamElement extends LitElement {
static override styles: CSSResultGroup;
stateObj?: CameraEntity;
controls: boolean;
muted: boolean;
allowExoPlayer: boolean;
protected _posterUrl?: string;
protected _connected: boolean;
protected _capabilities?: { frontend_stream_types: HaStreamType[] };
protected _hlsStreams?: HaStreamStatus;
protected _webRtcStreams?: HaStreamStatus;
protected _handleHlsStreams(ev: CustomEvent): void;
protected _handleWebRtcStreams(ev: CustomEvent): void;
// Picks which of the camera's streams to render and which one is visible.
protected _streams(
supportedTypes?: HaStreamType[],
hlsStreams?: HaStreamStatus,
webRtcStreams?: HaStreamStatus,
muted?: boolean,
): HaStream[];
protected _renderStream(stream: HaStream): TemplateResult | typeof nothing;
}
// `customElements.get()` cannot know which element a tag resolves to, so each
// patch casts its base to the matching constructor.
export type ConstructableHaHlsPlayer = typeof HaHlsPlayerElement;
export type ConstructableHaWebRtcPlayer = typeof HaWebRtcPlayerElement;
export type ConstructableHaCameraStream = typeof HaCameraStreamElement;
// The elements the card registers: the Home Assistant element plus the card's
// own additions. `hass` is set by the card rather than declared by the Home
// Assistant element, which takes its connection from a context instead.
export type AdvancedCameraCardHaHlsPlayerElement = HaHlsPlayerElement &
MediaPlayer & { hass?: HomeAssistant; targetID?: string };
export type AdvancedCameraCardHaWebRtcPlayerElement = HaWebRtcPlayerElement &
MediaPlayer & { hass?: HomeAssistant; targetID?: string };
export type AdvancedCameraCardHaCameraStreamElement = HaCameraStreamElement &
MediaPlayer & { hass?: HomeAssistant; targetID?: string; outputMute: boolean };