feat: Add experimental rewrite of go2rtc live provider (MSE/WebRTC/MP4/MJPEG) (#2580)

- Closes #2556
- Closes #2450

**Key intended features:**
 - go2rtc compatible
- 100% test coverage to significantly improve ability to test, maintain
and work around browser weirdnesses (e.g. Safari).
 - Written from the ground up in the style of the rest of the project.

**To use:**
 - Change `live_provider` from `go2rtc` to `go2rtc-experimental`.
This commit is contained in:
Dermot Duffy
2026-07-14 14:22:41 -07:00
committed by GitHub
parent 5662e48c22
commit c02c692f68
125 changed files with 9608 additions and 807 deletions
+2 -1
View File
@@ -275,7 +275,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.microphoneStream=${microphoneStream}
.camera=${resolvedCamera}
.targetID=${cameraID}
.label=${cameraMetadata?.title ?? ''}
.cameraTitle=${cameraMetadata?.title}
.liveConfig=${this.liveConfig}
.hass=${this.hass}
.stateWatcher=${this.stateWatcher}
@@ -284,6 +284,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
.forceSelected=${isSelectedSlide}
.locked=${this.locked}
.suppressLoadingImage=${mediaEpoch > 0}
@advanced-camera-card:zoom:change=${(
ev: CustomEvent<ZoomSettingsObserved>,
) =>
+94 -37
View File
@@ -34,7 +34,7 @@ import { getResolvedLiveProvider } from '../../utils/live-provider.js';
import '../icon.js';
import { renderNotificationBlockFromText } from '../notification/block.js';
import { renderMediaNotification } from '../notification/media.js';
import './../media-dimensions-container';
@@ -56,9 +56,10 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
@property({ attribute: false })
public liveConfig?: LiveConfig;
// Label that is used for ARIA support and as tooltip.
// The camera's title, used for ARIA support, as tooltip, and to identify the
// camera in error messages.
@property({ attribute: false })
public label = '';
public cameraTitle?: string;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -85,6 +86,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
@property({ attribute: false })
public locked?: boolean;
// When true, suppress the loading snapshot (show_image_during_load). Set on a
// media reload after a failure so the snapshot doesn't flash back in on every
// retry; a first load still shows it.
@property({ attribute: false })
public suppressLoadingImage = false;
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
getTargetID: () => this.targetID ?? null,
});
@@ -127,6 +134,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
*/
private _shouldShowImageDuringLoading(): boolean {
return (
!this.suppressLoadingImage &&
!this._mediaLoadedInfoSinkController.has() &&
!!this.camera?.getConfig()?.camera_entity &&
!!this.hass &&
@@ -167,6 +175,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
this._importPromises.push(import('./providers/image.js'));
} else if (provider === 'go2rtc') {
this._importPromises.push(import('./providers/go2rtc/index.js'));
} else if (provider === 'go2rtc-experimental') {
this._importPromises.push(import('./providers/go2rtc-experimental/index.js'));
}
}
}
@@ -239,57 +249,70 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
// being initialized. This can cause spurious errors (e.g. lack of resolved
// endpoints). Instead, simply never render uninitialized cameras.
if (!this.camera.isInitialized()) {
return renderNotificationBlockFromText(
`${localize('error.awaiting_live')}${this.label ? `: ${this.label}` : ''}`,
{ icon: 'mdi:progress-helper', in_progress: true },
);
return renderMediaNotification({
icon: 'mdi:progress-helper',
title: localize('error.awaiting_live'),
targetTitle: this.cameraTitle,
});
}
// Set title and ariaLabel from the provided label property.
this.title = this.label;
this.ariaLabel = this.label;
this.title = this.cameraTitle ?? '';
this.ariaLabel = this.cameraTitle ?? '';
const provider = getResolvedLiveProvider(this.camera?.getConfig());
// `ha`/`image` cannot stream without a camera entity, so validate that
// here. Entity *availability* (including the always_error immediate path)
// is owned by the liveness controller's EntityAvailabilityDetector and
// surfaces via getPlaceholder() below, for all providers.
// surfaces via getFailure() below, for all providers.
if (
provider === 'ha' ||
provider === 'image' ||
(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
) {
if (!cameraConfig?.camera_entity) {
return renderNotificationBlockFromText(localize('error.no_live_camera'), {
return renderMediaNotification({
icon: 'mdi:camera',
context: cameraConfig,
title: localize('error.configuration_error'),
detail: localize('error.no_live_camera'),
targetTitle: this.cameraTitle,
});
}
if (!this.hass.states[cameraConfig.camera_entity]) {
return renderNotificationBlockFromText(localize('error.live_camera_not_found'), {
return renderMediaNotification({
icon: 'mdi:camera',
context: cameraConfig,
title: localize('error.configuration_error'),
detail: localize('error.live_camera_not_found'),
targetTitle: this.cameraTitle,
});
}
}
const failure = this._streamLivenessController.getFailure();
// A detector reports the stream is silently lost (the camera entity is
// unavailable, or the stream stalled): render a reconnecting placeholder,
// which unmounts the provider and unloads it via the existing media-loaded
// abort. The message names the specific cause.
const placeholder = this._streamLivenessController.getPlaceholder();
if (placeholder) {
if (failure?.renderPlaceholder) {
const { localizationKey: textKey, icon } =
MEDIA_UNAVAILABLE_REASONS[placeholder.reason];
return renderNotificationBlockFromText(
`${localize(textKey)}${this.label ? `: ${this.label}` : ''}`,
{ icon, in_progress: true },
);
MEDIA_UNAVAILABLE_REASONS[failure.reason];
return renderMediaNotification({
icon,
title: localize(textKey),
targetTitle: this.cameraTitle,
});
}
const showImageDuringLoading = this._shouldShowImageDuringLoading();
const showLoadingIcon = !this._mediaLoadedInfoSinkController.has();
const mediaLoaded = this._mediaLoadedInfoSinkController.has();
// Loaded media or a snapshot gives the frame a size; mark the host `sized`
// when one is present. In its absence CSS reserves an aspect ratio so the
// frame (whose loading/error fill is absolutely positioned) doesn't
// collapse.
this.toggleAttribute('sized', mediaLoaded || showImageDuringLoading);
const classes = {
hidden: showImageDuringLoading,
@@ -302,6 +325,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.hass=${this.hass}
.camera=${this.camera}
.targetID=${this.targetID}
.cameraTitle=${this.cameraTitle}
class=${classMap({
...classes,
// The image provider is providing the temporary loading image,
@@ -340,42 +364,75 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.hass=${this.hass}
.camera=${this.camera}
.targetID=${this.targetID}
.cameraTitle=${this.cameraTitle}
.microphoneStream=${this.microphoneStream}
.microphoneConfig=${this.liveConfig.microphone}
?controls=${this._getEffectiveBuiltinControls()}
>
</advanced-camera-card-live-go2rtc>`
: provider === 'webrtc-card'
? html`<advanced-camera-card-live-webrtc-card
: provider === 'go2rtc-experimental'
? html`<advanced-camera-card-live-go2rtc-experimental
${ref(this._refProvider)}
class=${classMap(classes)}
.hass=${this.hass}
.camera=${this.camera}
.targetID=${this.targetID}
.cameraTitle=${this.cameraTitle}
.microphoneStream=${this.microphoneStream}
.microphoneConfig=${this.liveConfig.microphone}
.cardWideConfig=${this.cardWideConfig}
?controls=${this._getEffectiveBuiltinControls()}
>
</advanced-camera-card-live-webrtc-card>`
: provider === 'jsmpeg'
? html` <advanced-camera-card-live-jsmpeg
</advanced-camera-card-live-go2rtc-experimental>`
: provider === 'webrtc-card'
? html`<advanced-camera-card-live-webrtc-card
${ref(this._refProvider)}
class=${classMap(classes)}
.hass=${this.hass}
.camera=${this.camera}
.targetID=${this.targetID}
.cameraTitle=${this.cameraTitle}
.cardWideConfig=${this.cardWideConfig}
?controls=${this._getEffectiveBuiltinControls()}
>
</advanced-camera-card-live-jsmpeg>`
: html``}
</advanced-camera-card-live-webrtc-card>`
: provider === 'jsmpeg'
? html` <advanced-camera-card-live-jsmpeg
${ref(this._refProvider)}
class=${classMap(classes)}
.hass=${this.hass}
.camera=${this.camera}
.targetID=${this.targetID}
.cameraTitle=${this.cameraTitle}
.cardWideConfig=${this.cardWideConfig}
>
</advanced-camera-card-live-jsmpeg>`
: html``}
`)}
${showLoadingIcon
? html`<advanced-camera-card-icon
title=${localize('error.awaiting_live')}
.icon=${{ icon: 'mdi:progress-helper' }}
@click=${() =>
fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_unavailable')}
></advanced-camera-card-icon>`
: ''}`;
${failure || mediaLoaded ? '' : this._renderLoadingOverlay(showImageDuringLoading)}`;
}
// The loading status drawn on top of the mounted provider while its media has
// not loaded: a subtle corner spinner over a snapshot that is already filling
// the frame, or a full "waiting for live" state. The cases that render nothing
// (a failure, or media already loaded) are handled at the call site.
private _renderLoadingOverlay(showImageDuringLoading: boolean): TemplateResult {
if (showImageDuringLoading) {
return html`<advanced-camera-card-icon
title=${localize('error.awaiting_live')}
.icon=${{ icon: 'mdi:progress-helper' }}
@click=${() =>
fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_unavailable')}
></advanced-camera-card-icon>`;
}
return html`<div class="fill">
${renderMediaNotification({
icon: 'mdi:progress-helper',
title: localize('error.awaiting_live'),
targetTitle: this.cameraTitle,
})}
</div>`;
}
static get styles(): CSSResultGroup {
@@ -0,0 +1,299 @@
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import type { Camera } from '../../../../camera-manager/camera.js';
import {
MEDIA_UNAVAILABLE_REASONS,
type MediaUnavailableIssueReason,
} from '../../../../card-controller/issues/issues/media-unavailable.js';
import { ImageSurfaceController } from '../../../../components-lib/live/providers/go2rtc-experimental/image-surface-controller.js';
import {
Go2RTCSessionController,
type SessionSurfaces,
type VideoSurface,
} from '../../../../components-lib/live/providers/go2rtc-experimental/session-controller.js';
import type { SurfaceKind } from '../../../../components-lib/live/providers/go2rtc-experimental/types.js';
import { mapFailureReasonToIssueReason } from '../../../../components-lib/live/providers/go2rtc-experimental/utils/failure-reason.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { MediaLoadedInfoSourceController } from '../../../../components-lib/media-loaded-info-source-controller.js';
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
import { SignedURLController } from '../../../../components-lib/signed-url-controller.js';
import type { MicrophoneConfig } from '../../../../config/schema/live.js';
import type { CardWideConfig } from '../../../../config/schema/types.js';
import type { HomeAssistant } from '../../../../ha/types.js';
import { localize } from '../../../../localize/localize.js';
import liveGo2RTCExperimentalStyle from '../../../../scss/live-go2rtc-experimental.scss';
import type { MediaPlayer, MediaPlayerController } from '../../../../types.js';
import {
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent,
} from '../../../../utils/media-info.js';
import { renderMediaNotification } from '../../../notification/media.js';
@customElement('advanced-camera-card-live-go2rtc-experimental')
export class AdvancedCameraCardGo2RTCExperimental
extends LitElement
implements MediaPlayer
{
// Not a reactive property to avoid resetting the video.
public hass?: HomeAssistant;
@property({ attribute: false })
public camera?: Camera;
// The BASE camera ID (camera property may be a substream)
@property({ attribute: false })
public targetID?: string;
@property({ attribute: false })
public microphoneStream?: MediaStream | null;
@property({ attribute: false })
public microphoneConfig?: MicrophoneConfig;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
// The camera's title, shown in error messages to identify the camera.
@property({ attribute: false })
public cameraTitle?: string;
@property({ attribute: true, type: Boolean })
public controls = false;
private _hasLiveError = false;
// ===========================================================================
// Surface: Video
// ===========================================================================
private _refVideo: Ref<HTMLVideoElement> = createRef();
private _videoMediaPlayerController = new VideoMediaPlayerController(
this,
() => this._refVideo.value ?? null,
() => this.controls,
);
private _videoSurface: VideoSurface = {
getElement: () => this._refVideo.value ?? null,
getMediaPlayer: () => this._videoMediaPlayerController,
};
// ===========================================================================
// Surface: Image
// ===========================================================================
private _refImage: Ref<HTMLImageElement> = createRef();
// A controller rather than a plain object (unlike the video surface): the
// image surface owns state, the object-URL lifecycle -- each frame's
// createObjectURL and revoking the previous one.
private _imageSurface = new ImageSurfaceController(
this,
() => this._refImage.value ?? null,
{
livenessOptions: {
isFrameExpected: () => true,
},
},
);
// ===========================================================================
// Surface Management
// ===========================================================================
// The surface currently showing committed media, or null before anything has
// committed (both surfaces hidden). Driven by the session's
// surfaceCommittedCallback.
@state()
private _activeSurface: SurfaceKind | null = null;
@state()
private _streamError: MediaUnavailableIssueReason | null = null;
// Built once and kept stable: the session compares this object by identity,
// so handing it a new one will trigger a reconnect.
private _surfaces: SessionSurfaces = {
video: this._videoSurface,
image: this._imageSurface,
};
private _signedURLController = new SignedURLController(this, () => {
const endpoint = this.camera?.getEndpoints()?.go2rtc;
if (!this.hass || !endpoint) {
return {};
}
return {
hass: this.hass,
endpoint,
proxyConfig: this.camera?.getLiveProxyConfig(),
proxyEndpointOptions: { websocket: true },
};
});
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
getTargetID: () => this.targetID ?? null,
});
private _session = new Go2RTCSessionController({
getControls: () => this.controls,
getCardWideConfig: () => this.cardWideConfig ?? null,
mediaLoadedCallback: (info) => this._mediaLoadedInfoSourceController.set(info),
surfaceCommittedCallback: (surface) => {
this._activeSurface = surface;
// A commit means the stream recovered: drop any prior error.
this._streamError = null;
},
// The session could not recover the stream on its own; surface it (with the
// failure's user-facing cause) so the card's media-load retry (reconnecting
// indicator, backoff, give-up) runs and can name why. The provider renders
// the error itself (below); the event drives the liveness verdict + retry.
errorCallback: (reason) => {
this._streamError = mapFailureReasonToIssueReason(reason);
dispatchLiveErrorEvent(this, this._streamError);
},
});
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._activeSurface === 'image'
? this._imageSurface.getMediaPlayer()
: this._videoMediaPlayerController;
}
connectedCallback(): void {
super.connectedCallback();
// Re-render (and thus re-establish the session) when reconnected to the
// DOM. https://github.com/dermotduffy/advanced-camera-card/issues/996
this.requestUpdate();
}
disconnectedCallback(): void {
// Tear down synchronously so streams (e.g. 2-way audio backchannels)
// release immediately.
this._session.reset();
this._activeSurface = null;
super.disconnectedCallback();
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('camera')) {
// The session is re-established by `updated()` once the new camera's
// signed URL resolves; the next commit picks the live surface. Blank the
// view meanwhile so the previous camera's last frame is not shown.
this._session.reset();
this._activeSurface = null;
this._streamError = null;
}
// Only treat a missing go2rtc endpoint as an error after the camera's
// endpoints have been explicitly set (not undefined / still loading).
const endpoints = this.camera?.getEndpoints();
const hasLiveError =
!!this._signedURLController.getError() || (!!endpoints && !endpoints.go2rtc);
if (hasLiveError && !this._hasLiveError) {
dispatchLiveErrorEvent(this);
}
this._hasLiveError = hasLiveError;
if (changedProps.has('controls')) {
// Only the video surface has native controls; the image surface has none.
this._videoMediaPlayerController.setControls(this.controls).catch(() => {});
}
if (changedProps.has('microphoneStream')) {
// The WebRTC lane swaps the outbound track in place; no visible reload.
this._session.setMicrophoneStream(this.microphoneStream ?? null);
}
}
protected updated(): void {
const url = this._signedURLController.getValue();
if (url) {
this._session.connect(
url,
this._surfaces,
this.camera?.getConfig()?.go2rtc?.modes,
);
} else {
// No usable URL: a signing/proxy error, or no go2rtc endpoint (which
// includes endpoints still loading). The render omits the surfaces, so
// drop the session -- otherwise a later URL, even an identical unsigned
// endpoint, would be skipped by connect()'s identity check and leave the
// session bound to the removed elements.
this._session.reset();
this._activeSurface = null;
}
}
protected render(): TemplateResult | void {
const error = this._signedURLController.getError();
if (error) {
return renderMediaNotification({
title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
targetTitle: this.cameraTitle,
});
}
if (!this.camera?.getEndpoints()?.go2rtc) {
return renderMediaNotification({
title: localize('error.configuration_error'),
detail: localize('error.live_camera_no_endpoint'),
targetTitle: this.cameraTitle,
});
}
if (this._streamError) {
// A stream-level failure the session gave up on: the provider must render
// its own error (marked in-progress, since the card keeps retrying).
return renderMediaNotification({
icon: MEDIA_UNAVAILABLE_REASONS[this._streamError].icon,
title: localize(MEDIA_UNAVAILABLE_REASONS[this._streamError].localizationKey),
targetTitle: this.cameraTitle,
});
}
// Both image and video surfaces are always rendered; only the committed one
// is shown (the other, and both before anything commits, are hidden). MSE
// and WebRTC play on the <video>; MP4 and MJPEG show frames on the <img>.
//
// Muted is bound as a property: Chrome ignores the `muted` content
// attribute on videos instantiated from cloned templates (as Lit does), so
// an attribute would not actually start the video muted. Media may be
// unmuted later in accordance with user configuration.
return html`
<video
${ref(this._refVideo)}
.muted=${true}
?hidden=${this._activeSurface !== 'video'}
playsinline
preload="auto"
@play=${() => dispatchMediaPlayEvent(this)}
@pause=${() => dispatchMediaPauseEvent(this)}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
></video>
<img ${ref(this._refImage)} ?hidden=${this._activeSurface !== 'image'} alt="" />
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(liveGo2RTCExperimentalStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-live-go2rtc-experimental': AdvancedCameraCardGo2RTCExperimental;
}
}
+13 -7
View File
@@ -17,7 +17,7 @@ import type { HomeAssistant } from '../../../../ha/types.js';
import { localize } from '../../../../localize/localize.js';
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
import type { MediaPlayer, MediaPlayerController } from '../../../../types.js';
import { renderNotificationBlockFromText } from '../../../notification/block.js';
import { renderMediaNotification } from '../../../notification/media.js';
import { VideoRTC } from './video-rtc.js';
customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC);
@@ -40,6 +40,10 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
@property({ attribute: false })
public microphoneConfig?: MicrophoneConfig;
// The camera's title, shown in error messages to identify the camera.
@property({ attribute: false })
public cameraTitle?: string;
@property({ attribute: true, type: Boolean })
public controls = false;
@@ -148,14 +152,16 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
protected render(): TemplateResult | void {
const error = this._signedURLController.getError();
if (error) {
return renderNotificationBlockFromText(
localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
{ context: this.camera?.getConfig() },
);
return renderMediaNotification({
title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
targetTitle: this.cameraTitle,
});
}
if (!this.camera?.getEndpoints()?.go2rtc) {
return renderNotificationBlockFromText(localize('error.live_camera_no_endpoint'), {
context: this.camera?.getConfig(),
return renderMediaNotification({
title: localize('error.configuration_error'),
detail: localize('error.live_camera_no_endpoint'),
targetTitle: this.cameraTitle,
});
}
return html`${this._player}`;
+10
View File
@@ -9,6 +9,8 @@ import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import type { Camera } from '../../../camera-manager/camera.js';
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import type { HomeAssistant } from '../../../ha/types';
import basicBlockStyle from '../../../scss/basic-block.scss';
import type {
@@ -31,6 +33,10 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
@property({ attribute: false })
public targetID?: string;
// The camera's title, shown in error messages to identify the camera.
@property({ attribute: false })
public cameraTitle?: string;
private _refImage: Ref<MediaPlayerElement> = createRef();
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
@@ -51,7 +57,11 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
.imageConfig=${cameraConfig.image}
.cameraConfig=${cameraConfig}
.targetID=${this.targetID}
.cameraTitle=${this.cameraTitle}
.proxyConfig=${this.camera?.getLiveProxyConfig()}
@advanced-camera-card:image-updating-player:error=${(
ev: CustomEvent<MediaUnavailableIssueReason>,
) => dispatchLiveErrorEvent(this, ev.detail)}
>
</advanced-camera-card-image-updating-player>
`;
+20 -13
View File
@@ -14,7 +14,7 @@ import type { Camera } from '../../../camera-manager/camera.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import { MediaLoadedInfoSourceController } from '../../../components-lib/media-loaded-info-source-controller.js';
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
import { createMediaNotification } from '../../../components-lib/notification/media.js';
import type { Notification } from '../../../config/schema/actions/types.js';
import type { CardWideConfig } from '../../../config/schema/types.js';
import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js';
@@ -22,13 +22,14 @@ import type { HomeAssistant } from '../../../ha/types.js';
import { localize } from '../../../localize/localize.js';
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
import type { MediaPlayer, MediaPlayerController } from '../../../types.js';
import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js';
import { errorToConsole } from '../../../utils/basic.js';
import {
createMediaLoadedInfo,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
} from '../../../utils/media-info.js';
import { Timer } from '../../../utils/timer.js';
import { convertToWebSocketURL } from '../../../utils/websocket-url.js';
import '../../notification/block.js';
@@ -58,6 +59,10 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
// The camera's title, shown in error messages to identify the camera.
@property({ attribute: false })
public cameraTitle?: string;
@state()
private _notification: Notification | null = null;
@@ -193,10 +198,11 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
const endpoint = this.camera?.getEndpoints()?.jsmpeg;
if (!endpoint) {
this._notification = createNotificationFromText(
localize('error.live_camera_no_endpoint'),
{ context: this.camera?.getConfig() },
);
this._notification = createMediaNotification({
title: localize('error.configuration_error'),
detail: localize('error.live_camera_no_endpoint'),
targetTitle: this.cameraTitle,
});
dispatchLiveErrorEvent(this);
return;
}
@@ -211,11 +217,12 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
} catch (e) {
errorToConsole(e);
}
const address = response ? convertHTTPAdressToWebsocket(response) : null;
const address = response ? convertToWebSocketURL(response) : null;
if (!address) {
this._notification = createNotificationFromText(localize('error.failed_sign'), {
context: this.camera?.getConfig(),
this._notification = createMediaNotification({
title: localize('error.failed_sign'),
targetTitle: this.cameraTitle,
});
dispatchLiveErrorEvent(this);
return;
@@ -238,10 +245,10 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
if (!this._notification) {
this._notification = createNotificationFromText(
localize('error.jsmpeg_no_player'),
{ context: this.camera?.getConfig() },
);
this._notification = createMediaNotification({
title: localize('error.jsmpeg_no_player'),
targetTitle: this.cameraTitle,
});
dispatchLiveErrorEvent(this);
}
return;
+11 -16
View File
@@ -14,24 +14,19 @@ import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispa
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
import { MediaLoadedInfoSourceController } from '../../../components-lib/media-loaded-info-source-controller.js';
import { VideoMediaPlayerController } from '../../../components-lib/media-player/video.js';
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
import { createMediaNotification } from '../../../components-lib/notification/media.js';
import type { Notification } from '../../../config/schema/actions/types.js';
import type { CardWideConfig } from '../../../config/schema/types.js';
import type { HomeAssistant } from '../../../ha/types.js';
import { localize } from '../../../localize/localize.js';
import liveWebRTCCardStyle from '../../../scss/live-webrtc-card.scss';
import {
AdvancedCameraCardError,
type MediaPlayer,
type MediaPlayerController,
} from '../../../types.js';
import type { MediaPlayer, MediaPlayerController } from '../../../types.js';
import { mayHaveAudio } from '../../../utils/audio.js';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../../../utils/controls.js';
import { getContextFromError } from '../../../utils/error-context.js';
import {
createMediaLoadedInfo,
dispatchMediaPauseEvent,
@@ -63,6 +58,10 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
// The camera's title, shown in error messages to identify the camera.
@property({ attribute: false })
public cameraTitle?: string;
@property({ attribute: true, type: Boolean })
public controls = false;
@@ -169,15 +168,11 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
try {
webrtcElement = this._createWebRTC();
} catch (e) {
const context = getContextFromError(e);
this._notification = createNotificationFromText(
e instanceof AdvancedCameraCardError
? e.message
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
{
...(context && { context }),
},
);
this._notification = createMediaNotification({
title: localize('error.webrtc_card_reported_error'),
detail: e instanceof Error ? e.message : String(e),
targetTitle: this.cameraTitle,
});
dispatchLiveErrorEvent(this);
return;
}