feat: Automatically recover from frozen live streams (#2569)
- Closes: #2099
This commit is contained in:
@@ -474,8 +474,9 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
this,
|
||||
'issue:trigger',
|
||||
{
|
||||
key: 'media_load',
|
||||
key: 'media_unavailable',
|
||||
targetID: this.targetID,
|
||||
reason: 'not_loading',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user