feat: Automatically recover from frozen live streams (#2569)

- Closes: #2099
This commit is contained in:
Dermot Duffy
2026-07-07 21:37:24 -07:00
committed by GitHub
parent 9f956fe89f
commit fb1bbc739e
73 changed files with 3321 additions and 364 deletions
+2 -1
View File
@@ -474,8 +474,9 @@ export class AdvancedCameraCardImageUpdatingPlayer
this,
'issue:trigger',
{
key: 'media_load',
key: 'media_unavailable',
targetID: this.targetID,
reason: 'not_loading',
},
);
}
+5
View File
@@ -13,6 +13,7 @@ import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import type { CameraManager } from '../../camera-manager/manager.js';
import type { CameraManagerCameraMetadata } from '../../camera-manager/types.js';
import type { CallSession } from '../../card-controller/call/types.js';
import type { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher.js';
import type { MicrophoneState } from '../../card-controller/types.js';
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { resolveAutoHideState } from '../../components-lib/auto-hide.js';
@@ -60,6 +61,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public stateWatcher?: StateWatcherSubscriptionInterface;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@@ -274,6 +278,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.label=${cameraMetadata?.title ?? ''}
.liveConfig=${this.liveConfig}
.hass=${this.hass}
.stateWatcher=${this.stateWatcher}
.cardWideConfig=${this.cardWideConfig}
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
+5
View File
@@ -11,6 +11,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import type { CameraManager } from '../../camera-manager/manager.js';
import type { CallSession } from '../../card-controller/call/types.js';
import type { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher.js';
import type { MicrophoneState } from '../../card-controller/types.js';
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
import type { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
@@ -27,6 +28,9 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public stateWatcher?: StateWatcherSubscriptionInterface;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@@ -66,6 +70,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
grid-id=${ifDefined(cameraID)}
grid-width-factor=${ifDefined(gridWidthFactor)}
.hass=${this.hass}
.stateWatcher=${this.stateWatcher}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewFilterCameraID=${cameraID}
.liveConfig=${this.liveConfig}
+5
View File
@@ -10,6 +10,7 @@ import { customElement, property } from 'lit/decorators.js';
import type { CameraManager } from '../../camera-manager/manager.js';
import type { CallSession } from '../../card-controller/call/types.js';
import type { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher.js';
import type { MicrophoneManager } from '../../card-controller/microphone-manager.js';
import type { MicrophoneState } from '../../card-controller/types.js';
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
@@ -30,6 +31,9 @@ export class AdvancedCameraCardLive extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public stateWatcher?: StateWatcherSubscriptionInterface;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@@ -106,6 +110,7 @@ export class AdvancedCameraCardLive extends LitElement {
return html`
<advanced-camera-card-live-grid
.hass=${this.hass}
.stateWatcher=${this.stateWatcher}
.viewManagerEpoch=${this.viewManagerEpoch}
.liveConfig=${this.liveConfig}
.cardWideConfig=${this.cardWideConfig}
+47 -62
View File
@@ -12,9 +12,11 @@ import { guard } from 'lit/directives/guard.js';
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import type { Camera } from '../../camera-manager/camera.js';
import type { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher.js';
import { MEDIA_UNAVAILABLE_REASONS } from '../../card-controller/issues/issues/media-unavailable.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { isAudioIntendedOnLoad } from '../../components-lib/live/audio-intent.js';
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
import { StreamLivenessController } from '../../components-lib/live/liveness/stream-liveness-controller.js';
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
import type { PartialZoomSettings } from '../../components-lib/zoom/types.js';
import type { LiveConfig } from '../../config/schema/live.js';
@@ -44,6 +46,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
@property({ attribute: false })
public camera?: Camera;
@property({ attribute: false })
public stateWatcher?: StateWatcherSubscriptionInterface;
// The BASE camera ID (camera property may be a substream)
@property({ attribute: false })
public targetID?: string;
@@ -84,18 +89,16 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
getTargetID: () => this.targetID ?? null,
});
private _streamLivenessController = new StreamLivenessController(this, {
getTargetID: () => this.targetID ?? null,
getHASS: () => this.hass ?? null,
getCamera: () => this.camera ?? null,
getStateWatcher: () => this.stateWatcher ?? null,
});
@state()
private _zoomed = false;
@state()
private _hasProviderError = false;
// Whether the camera entity has ever been in a non-unavailable state. Used
// to suppress transient unavailability errors for entities that were
// previously working (e.g. during PTZ operations).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2124
private _entityHasBeenAvailable = false;
private _refProvider: Ref<MediaPlayerElement> = createRef();
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
@@ -128,27 +131,10 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
!!this.camera?.getConfig()?.camera_entity &&
!!this.hass &&
!!this.liveConfig?.show_image_during_load &&
!this._hasProviderError
this._streamLivenessController.isLive()
);
}
public disconnectedCallback(): void {
this._entityHasBeenAvailable = false;
super.disconnectedCallback();
}
private _providerErrorHandler(ev: Event): void {
ev.stopPropagation();
this._hasProviderError = true;
if (this.targetID) {
fireAdvancedCameraCardEvent(this, 'issue:trigger', {
key: 'media_load' as const,
targetID: this.targetID,
});
}
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('liveConfig') || changedProps.has('forceSelected')) {
this._lazyLoadController.setConfiguration({
@@ -168,8 +154,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
if (changedProps.has('camera')) {
this._hasProviderError = false;
this._entityHasBeenAvailable = false;
this._streamLivenessController.reset();
const provider = getResolvedLiveProvider(this.camera?.getConfig());
if (provider === 'jsmpeg') {
@@ -248,50 +233,59 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
return;
}
// If a card *re*-initializes (e.g. was already initialized and then there's
// a use of the editor to change the config), cameras will re-initialize in
// place, which means they might be asked to render (here) whilst not yet
// 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 },
);
}
// Set title and ariaLabel from the provided label property.
this.title = this.label;
this.ariaLabel = this.label;
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.
if (
provider === 'ha' ||
provider === 'image' ||
(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
) {
if (!cameraConfig?.camera_entity) {
dispatchLiveErrorEvent(this);
return renderNotificationBlockFromText(localize('error.no_live_camera'), {
icon: 'mdi:camera',
context: cameraConfig,
});
}
const stateObj = this.hass.states[cameraConfig.camera_entity];
if (!stateObj) {
dispatchLiveErrorEvent(this);
if (!this.hass.states[cameraConfig.camera_entity]) {
return renderNotificationBlockFromText(localize('error.live_camera_not_found'), {
icon: 'mdi:camera',
context: cameraConfig,
});
}
}
if (stateObj.state === 'unavailable') {
if (
!this._entityHasBeenAvailable ||
cameraConfig.always_error_if_entity_unavailable
) {
dispatchLiveErrorEvent(this);
return renderNotificationBlockFromText(
`${localize('error.live_camera_unavailable')}${
this.label ? `: ${this.label}` : ''
}`,
{ icon: 'mdi:cctv-off', in_progress: true },
);
}
} else {
this._entityHasBeenAvailable = true;
}
// 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) {
const { localizationKey: textKey, icon } =
MEDIA_UNAVAILABLE_REASONS[placeholder.reason];
return renderNotificationBlockFromText(
`${localize(textKey)}${this.label ? `: ${this.label}` : ''}`,
{ icon, in_progress: true },
);
}
const showImageDuringLoading = this._shouldShowImageDuringLoading();
@@ -314,8 +308,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
// so it should not be hidden.
hidden: false,
})}
@advanced-camera-card:live:error=${(ev: Event) =>
this._providerErrorHandler(ev)}
@advanced-camera-card:media:loaded=${(ev: Event) => {
// When the image is rendered as a placeholder behind another
// provider, suppress its load event so it doesn't reach the
@@ -339,8 +331,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.preferAudioStream=${this.forceSelected &&
isAudioIntendedOnLoad(this.liveConfig?.auto_unmute ?? [])}
?controls=${this._getEffectiveBuiltinControls()}
@advanced-camera-card:live:error=${(ev: Event) =>
this._providerErrorHandler(ev)}
>
</advanced-camera-card-live-ha>`
: provider === 'go2rtc'
@@ -353,8 +343,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.microphoneStream=${this.microphoneStream}
.microphoneConfig=${this.liveConfig.microphone}
?controls=${this._getEffectiveBuiltinControls()}
@advanced-camera-card:live:error=${(ev: Event) =>
this._providerErrorHandler(ev)}
>
</advanced-camera-card-live-go2rtc>`
: provider === 'webrtc-card'
@@ -366,8 +354,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.targetID=${this.targetID}
.cardWideConfig=${this.cardWideConfig}
?controls=${this._getEffectiveBuiltinControls()}
@advanced-camera-card:live:error=${(ev: Event) =>
this._providerErrorHandler(ev)}
>
</advanced-camera-card-live-webrtc-card>`
: provider === 'jsmpeg'
@@ -378,8 +364,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
.camera=${this.camera}
.targetID=${this.targetID}
.cardWideConfig=${this.cardWideConfig}
@advanced-camera-card:live:error=${(ev: Event) =>
this._providerErrorHandler(ev)}
>
</advanced-camera-card-live-jsmpeg>`
: html``}
@@ -388,7 +372,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
? html`<advanced-camera-card-icon
title=${localize('error.awaiting_live')}
.icon=${{ icon: 'mdi:progress-helper' }}
@click=${() => fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_load')}
@click=${() =>
fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_unavailable')}
></advanced-camera-card-icon>`
: ''}`;
}
@@ -526,8 +526,19 @@ export class VideoRTC extends HTMLElement {
this.ws = new WebSocket(this.wsURL);
this.ws.binaryType = 'arraybuffer';
this.ws.addEventListener('open', () => this.onopen());
this.ws.addEventListener('close', () => this.onclose());
// A socket superseded by teardown or reconnect while still connecting can
// still fire 'open'/'close' after `this.ws` was nulled or replaced. Ignore
// events from a socket that is no longer the active one -- otherwise a
// late 'open' dereferences a null `this.ws` (crash) and a late 'close'
// schedules a spurious reconnect.
const ws = this.ws;
ws.addEventListener('open', () => {
if (this.ws === ws) this.onopen();
});
ws.addEventListener('close', () => {
if (this.ws === ws) this.onclose();
});
return true;
}
@@ -628,6 +639,16 @@ export class VideoRTC extends HTMLElement {
this.wsState = WebSocket.CONNECTING;
this.ws = null;
// onconnect() refuses to run while a peer connection lingers, so both `ws`
// and `pc` need to be clearer so the scheduled reconnect can proceed
// instead of no-opping and leaving a stream permanently wedged.
if (this.pc) {
this.pc.close();
this.pc = null;
this.pcState = WebSocket.CLOSED;
this._microphoneTransceiver = null;
}
// reconnect no more than once every X seconds
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
@@ -694,7 +715,17 @@ export class VideoRTC extends HTMLElement {
}
}
if (!sb.updating && sb.buffered && sb.buffered.length) {
// For Advanced Camera Card: a queued `updateend` can fire after a
// teardown/remount has detached this SourceBuffer from the MediaSource,
// at which point reading `sb.buffered` throws InvalidStateError. The
// buffered-range trim is best-effort, so skip it once the source is no
// longer open.
if (
ms.readyState === 'open' &&
!sb.updating &&
sb.buffered &&
sb.buffered.length
) {
const end = sb.buffered.end(sb.buffered.length - 1);
const start = end - 5;
const start0 = sb.buffered.start(0);
+8 -3
View File
@@ -114,9 +114,14 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
// restart.
reconnectInterval: 0,
onVideoDecode: () => {
// This is the only callback that is called after the dimensions
// are available. It's called on every frame decode, so just
// ignore any subsequent calls.
// Called on every frame decode: feed the liveness watchdog so a
// silent freeze (decodes stop while the WebSocket stays open) is
// detected.
this._mediaPlayerController.notifyFrameDecoded();
// This is also the first callback called after the dimensions are
// available, so use the first call to resolve the player; ignore
// subsequent calls for that purpose.
if (!videoDecoded && this._jsmpegCanvasElement) {
videoDecoded = true;
resolve(player);
+5
View File
@@ -12,6 +12,7 @@ import { classMap } from 'lit/directives/class-map.js';
import type { CameraManager } from '../camera-manager/manager.js';
import type { CallSession } from '../card-controller/call/types.js';
import type { FoldersManager } from '../card-controller/folders/manager.js';
import type { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher.js';
import type { IssuePresence } from '../card-controller/issues/types.js';
import type { MicrophoneManager } from '../card-controller/microphone-manager.js';
import type { MicrophoneState } from '../card-controller/types.js';
@@ -39,6 +40,9 @@ export class AdvancedCameraCardViews extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public stateWatcher?: StateWatcherSubscriptionInterface;
@property({ attribute: false })
public viewManagerEpoch?: ViewManagerEpoch;
@@ -242,6 +246,7 @@ export class AdvancedCameraCardViews extends LitElement {
? html`
<advanced-camera-card-live
.hass=${this.hass}
.stateWatcher=${this.stateWatcher}
.viewManagerEpoch=${this.viewManagerEpoch}
.liveConfig=${this.config.live}
.cameraManager=${this.cameraManager}