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
+8 -6
View File
@@ -38,7 +38,7 @@ import { getReviewedQueryFilterFromQuery } from '../../view/utils/query-filter.j
import '../media-filter.js';
import { renderNoMedia } from '../notification/no-media.js';
import { renderNoMediaNotification } from '../notification/media.js';
import '../surround-basic.js';
import '../thumbnail/thumbnail.js';
@@ -219,11 +219,13 @@ export class AdvancedCameraCardGallery extends LitElement {
</advanced-camera-card-media-filter>`
: ''}
${!hasItems
? renderNoMedia({
cameraID: this.viewManagerEpoch?.manager.getView()?.camera ?? null,
cameraManager: this.cameraManager ?? null,
loading: isLoading,
})
? renderNoMediaNotification(
{
cameraID: this.viewManagerEpoch?.manager.getView()?.camera ?? null,
inProgress: isLoading,
},
this.cameraManager,
)
: html`<advanced-camera-card-gallery-core
.hass=${this.hass}
.columnWidth=${this._controller.getColumnWidth(
+56 -20
View File
@@ -12,16 +12,17 @@ import { live } from 'lit/directives/live.js';
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
import type { MediaUnavailableIssueReason } from '../card-controller/issues/issues/media-unavailable.js';
import type { IssueTriggerEventData } from '../card-controller/issues/types.js';
import { CachedValueController } from '../components-lib/cached-value-controller.js';
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
import { ImageMediaPlayerController } from '../components-lib/media-player/image.js';
import { createMediaNotification } from '../components-lib/notification/media.js';
import { SignedURLController } from '../components-lib/signed-url-controller.js';
import type { Notification } from '../config/schema/actions/types.js';
import type { CameraConfig } from '../config/schema/cameras.js';
import { type ImageBaseConfig, type ImageMode } from '../config/schema/common/image.js';
import type { EnabledProxyConfig } from '../config/schema/common/proxy.js';
import { TROUBLESHOOTING_URL } from '../const.js';
import { isHassDifferent } from '../ha/is-hass-different.js';
import type { HomeAssistant } from '../ha/types.js';
import defaultImage from '../images/iris-screensaver.jpg';
@@ -38,6 +39,14 @@ import {
import type { View } from '../view/view.js';
import { renderNotificationBlock } from './notification/block.js';
declare global {
interface HTMLElementEventMap {
// A private signal to the immediate parent that the media failed.
// Non-bubbling.
'advanced-camera-card:image-updating-player:error': CustomEvent<MediaUnavailableIssueReason>;
}
}
// 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;
@@ -91,6 +100,10 @@ export class AdvancedCameraCardImageUpdatingPlayer
@property({ attribute: false })
public targetID?: string;
// The camera's title, shown in error messages to identify the camera.
@property({ attribute: false })
public cameraTitle?: string;
@property({ attribute: false, hasChanged: contentsChanged })
public proxyConfig?: EnabledProxyConfig;
@@ -103,6 +116,10 @@ export class AdvancedCameraCardImageUpdatingPlayer
@state()
private _imageLoadError = false;
// Tracks the signed/proxy error so it is reported once on the transition into
// failure, not on every update.
private _hasSignError = false;
private _refImage: Ref<HTMLImageElement> = createRef();
private _cachedValueController = new CachedValueController(
@@ -144,10 +161,19 @@ export class AdvancedCameraCardImageUpdatingPlayer
private _boundVisibilityHandler = this._visibilityHandler.bind(this);
private _mediaPlayerController = new UpdatingImageMediaPlayerController(
// A poll-refreshed snapshot: the cached-value timer is the pausable update
// loop, and its cached URL is the screenshot.
private _mediaPlayerController = new ImageMediaPlayerController(
this,
() => this._refImage.value ?? null,
() => this._cachedValueController,
{
updateControl: {
start: () => this._cachedValueController.startTimer(),
stop: () => this._cachedValueController.stopTimer(),
isRunning: () => this._cachedValueController.hasTimer(),
},
screenshotProvider: async () => this._cachedValueController.getValue(),
},
);
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
@@ -218,6 +244,19 @@ export class AdvancedCameraCardImageUpdatingPlayer
if (!this._cachedValueController?.getValue()) {
this._cachedValueController?.updateValue();
}
const hasSignError = !!this._signedURLController.getError();
if (hasSignError && !this._hasSignError) {
this._dispatchError('server_error');
}
this._hasSignError = hasSignError;
}
private _dispatchError(reason: MediaUnavailableIssueReason): void {
fireAdvancedCameraCardEvent(this, 'image-updating-player:error', reason, {
bubbles: false,
composed: false,
});
}
/**
@@ -411,24 +450,16 @@ export class AdvancedCameraCardImageUpdatingPlayer
private _getDisplayNotification(): Notification | null {
const error = this._signedURLController.getError();
if (error) {
return {
heading: {
text: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
icon: 'mdi:alert-circle',
},
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
context: this.proxyConfig ? [this.proxyConfig] : undefined,
};
return createMediaNotification({
title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
targetTitle: this.cameraTitle,
});
}
if (this._imageLoadError) {
return {
heading: {
text: localize('error.image_load_error'),
icon: 'mdi:alert-circle',
},
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
context: this.imageConfig ? [this.imageConfig] : undefined,
};
return createMediaNotification({
title: localize('error.image_load_error'),
targetTitle: this.cameraTitle,
});
}
return null;
}
@@ -468,6 +499,11 @@ export class AdvancedCameraCardImageUpdatingPlayer
this._forceSafeImage(true);
} else if (mode === 'url') {
this._imageLoadError = true;
// Report the failure to the parent. A live context marks the
// stream not-live so its wrapper stops covering the error with a
// loading overlay; the plain image view ignores it.
this._dispatchError('not_loading');
}
if (this.targetID) {
fireAdvancedCameraCardEvent<IssueTriggerEventData>(
+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;
}
+43
View File
@@ -0,0 +1,43 @@
import type { TemplateResult } from 'lit';
import type { CameraManager } from '../../camera-manager/manager.js';
import {
createMediaNotification,
type MediaNotificationOptions,
} from '../../components-lib/notification/media.js';
import { localize } from '../../localize/localize.js';
import { renderNotificationBlock } from './block.js';
// Render the standard media notification block: a short titled heading (with
// the camera name when there is one), an optional longer detail, a
// troubleshooting link, and a retry spinner. See `createMediaNotification`.
export function renderMediaNotification(
options: MediaNotificationOptions,
): TemplateResult {
return renderNotificationBlock(createMediaNotification(options));
}
interface NoMediaOptions {
cameraID: string | null;
inProgress?: boolean;
}
// The viewer/gallery no-media (or awaiting-media) state.
export function renderNoMediaNotification(
options: NoMediaOptions,
cameraManager?: CameraManager,
): TemplateResult {
const cameraID =
options.cameraID ?? cameraManager?.getStore().getDefaultCameraID() ?? null;
const targetTitle = cameraID
? cameraManager?.getCameraMetadata(cameraID)?.title ?? cameraID
: undefined;
return renderMediaNotification({
title: localize(options.inProgress ? 'error.awaiting_media' : 'common.no_media'),
icon: 'mdi:multimedia',
targetTitle,
inProgress: !!options.inProgress,
troubleshooting: false,
});
}
-32
View File
@@ -1,32 +0,0 @@
import type { TemplateResult } from 'lit';
import type { CameraManager } from '../../camera-manager/manager.js';
import { localize } from '../../localize/localize.js';
import { renderNotificationBlock } from './block.js';
interface NoMediaOptions {
cameraID: string | null;
cameraManager: CameraManager | null;
loading?: boolean;
}
export function renderNoMedia(options: NoMediaOptions): TemplateResult {
const cameraID =
options.cameraID ?? options.cameraManager?.getStore().getDefaultCameraID() ?? null;
const cameraTitle = cameraID
? options.cameraManager?.getCameraMetadata(cameraID)?.title ?? cameraID
: null;
return renderNotificationBlock({
heading: {
text: options.loading
? localize('error.awaiting_media')
: localize('common.no_media'),
icon: 'mdi:multimedia',
},
in_progress: options.loading,
...(cameraTitle && {
metadata: [{ text: cameraTitle, icon: 'mdi:cctv' }],
}),
});
}
+15 -13
View File
@@ -38,7 +38,7 @@ import type { ViewMedia } from '../../view/item.js';
import '../carousel';
import '../next-prev-control.js';
import { renderNoMedia } from '../notification/no-media.js';
import { renderNoMediaNotification } from '../notification/media.js';
import '../ptz.js';
import './provider.js';
@@ -321,13 +321,15 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
protected render(): TemplateResult | void {
const mediaCount = this._media?.length ?? 0;
if (!this._media || !mediaCount) {
return renderNoMedia({
cameraID:
this.viewFilterCameraID ??
this.viewManagerEpoch?.manager.getView()?.camera ??
null,
cameraManager: this.cameraManager ?? null,
});
return renderNoMediaNotification(
{
cameraID:
this.viewFilterCameraID ??
this.viewManagerEpoch?.manager.getView()?.camera ??
null,
},
this.cameraManager,
);
}
if (!this.hass || !this.cameraManager || this._selected === null) {
@@ -443,17 +445,17 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
const seekTimeInMedia = selectedMedia.includesTime(seek);
this.toggleAttribute('unseekable', !seekTimeInMedia);
if (!seekTimeInMedia && !mediaPlayerController.isPaused()) {
void mediaPlayerController.pause();
} else if (seekTimeInMedia && mediaPlayerController.isPaused()) {
void mediaPlayerController.play();
if (!seekTimeInMedia && !mediaPlayerController.playback?.isPaused()) {
void mediaPlayerController.playback?.pause();
} else if (seekTimeInMedia && mediaPlayerController.playback?.isPaused()) {
void mediaPlayerController.playback?.play();
}
const seekTime =
(await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null;
if (seekTime !== null) {
void mediaPlayerController.seek(seekTime);
void mediaPlayerController.seek?.(seekTime);
}
}
+8 -6
View File
@@ -20,7 +20,7 @@ import '../../patches/ha-hls-player.js';
import viewerStyle from '../../scss/viewer.scss';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import { renderNoMedia } from '../notification/no-media.js';
import { renderNoMediaNotification } from '../notification/media.js';
import './grid';
@@ -84,11 +84,13 @@ export class AdvancedCameraCardViewer extends LitElement {
// Directly render an error message (instead of dispatching it upwards)
// to preserve the mini-timeline if the user pans into an area with no
// media.
return renderNoMedia({
cameraID: this.viewManagerEpoch.manager.getView()?.camera ?? null,
cameraManager: this.cameraManager ?? null,
loading: !!this.viewManagerEpoch.manager.getView()?.context?.loading?.query,
});
return renderNoMediaNotification(
{
cameraID: this.viewManagerEpoch.manager.getView()?.camera ?? null,
inProgress: !!this.viewManagerEpoch.manager.getView()?.context?.loading?.query,
},
this.cameraManager,
);
}
return html` <advanced-camera-card-viewer-grid