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
@@ -0,0 +1,142 @@
import { isEqual } from 'lodash-es';
import type { StateWatcherSubscriptionInterface } from '../../../../card-controller/hass/state-watcher';
import type { HassStateDifference, HomeAssistant } from '../../../../ha/types';
import { Timer } from '../../../../utils/timer';
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
// A camera entity must stay `unavailable` this long before the stream is
// treated as lost. Shorter blips (e.g. during PTZ, see issue #2124) are
// tolerated.
export const LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS = 10;
interface EntityAvailabilityDetectorConfig {
getHASS: () => HomeAssistant | null;
getStateWatcher: () => StateWatcherSubscriptionInterface | null;
getCameraEntity: () => string | null;
// `always_error_if_entity_unavailable` (issue #1650): report the loss with no
// grace tolerance, so any unavailability surfaces immediately.
isAlwaysError: () => boolean;
onChange: () => void;
}
/**
* Detects a silent freeze via the camera entity: a `camera_entity` that stays
* `unavailable` past the grace window (e.g. a Frigate restart or camera
* power-cycle) is reported as not live (asking for a reconnecting placeholder),
* and live again when it returns. Observes the entity via StateWatcher (an event
* subscription), not by polling.
*/
export class EntityAvailabilityDetector implements LivenessDetector {
private _config: EntityAvailabilityDetectorConfig;
private _timer = new Timer();
private _active = false;
private _watchedEntity: string | null = null;
private _verdict: LivenessVerdict = { state: 'unknown' };
constructor(config: EntityAvailabilityDetectorConfig) {
this._config = config;
}
public subscribe(): void {
this._active = true;
this._watch();
}
public unsubscribe(): void {
// Stop watching and pause the grace timer, but keep `_verdict` so a
// reconnect resumes rather than restarts.
this._active = false;
this._config.getStateWatcher()?.unsubscribe(this._onEntityStateChange);
this._watchedEntity = null;
this._timer.stop();
}
public reset(): void {
// Re-point the subscription at the (possibly different) camera entity and
// start fresh.
this._verdict = { state: 'unknown' };
this._timer.stop();
this._watch();
}
public getVerdict(): LivenessVerdict {
return this._verdict;
}
// Point the subscription at the current camera entity and re-check its state.
private _watch(): void {
if (!this._active) {
return;
}
const stateWatcher = this._config.getStateWatcher();
const entityID = this._config.getCameraEntity();
if (entityID !== this._watchedEntity) {
stateWatcher?.unsubscribe(this._onEntityStateChange);
this._watchedEntity = entityID;
if (entityID) {
stateWatcher?.subscribe(this._onEntityStateChange, [entityID]);
}
}
this._check();
}
private _onEntityStateChange = (difference: HassStateDifference): void =>
// Trap: Evaluate the state carried by the event, not `getHASS()`: the
// StateWatcher fires synchronously from the card-level hass update, before
// the wrapper's `hass` prop (what `getHASS()` reads) has propagated via
// Lit, so re-reading it would still see the pre-change state and miss the
// transition.
this._evaluate(difference.newState.state);
private _check(): void {
const stateObj = this._watchedEntity
? this._config.getHASS()?.states[this._watchedEntity]
: undefined;
this._evaluate(stateObj?.state);
}
private _evaluate(state?: string): void {
if (state === 'unavailable') {
if (this._config.isAlwaysError()) {
// No grace time, and authoritative (overrides direct frame evidence):
// the user opted into treating any unavailability as an error.
this._setVerdict({
state: 'not_live',
authority: 'hard',
renderPlaceholder: true,
reason: 'entity_unavailable',
});
} else if (this._verdict.state !== 'not_live' && !this._timer.isRunning()) {
// Wait out the grace window before declaring the stream lost, so short
// blips are tolerated. This is only an indirect signal of stream health,
// so it is suppressed when the media itself is confirmed live (frames
// arriving).
this._timer.start(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS, () =>
this._setVerdict({
state: 'not_live',
authority: 'indirect',
renderPlaceholder: true,
reason: 'entity_unavailable',
}),
);
}
} else {
// Entity available is not positive proof the stream is live (it can be
// available while the stream is frozen), so report `unknown`, not `live`.
this._timer.stop();
this._setVerdict({ state: 'unknown' });
}
}
private _setVerdict(verdict: LivenessVerdict): void {
if (isEqual(this._verdict, verdict)) {
return;
}
this._verdict = verdict;
this._config.onChange();
}
}
@@ -0,0 +1,167 @@
import { isEqual } from 'lodash-es';
import type {
MediaLoadedInfoEventDetail,
MediaPlayerController,
UnsubscribeCallback,
} from '../../../../types';
import { onAbort } from '../../../../utils/abort-signal';
import { VisibilityObserver } from '../../../visibility-observer';
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
/**
* Detects a silent freeze by observing the media player's own liveness signal
* (`subscribeLiveness`): when the player reports it has stopped delivering
* media, this reports the stream not live so the wrapper shows the reconnecting
* placeholder. How a player detects that (e.g. a video watching frame progress)
* is the player's concern.
*
* It only watches while a stall is actionable -- the stream is loaded AND this
* provider is actually visible to the user (on-screen and the tab is focused).
* An off-screen or backgrounded video legitimately stops presenting media (the
* browser pauses `requestVideoFrameCallback`), which is not considered a real
* freeze. Visibility comes from the shared `VisibilityObserver` (intersection +
* tab visibility), which correctly tracks every visible provider in a grid, not
* just the single selected camera.
*
* Recovery is not self-driven. Once a freeze is reported and the placeholder
* unmounts the frozen stream, the media player is gone -- so the detector holds
* its not-live verdict rather than flipping back to live, which would clear the
* placeholder and remount immediately in an unthrottled loop. The throttled
* media_unavailable issue (see issue-manager.ts) retry remounts a fresh provider (a
* new detector) to re-check the stream instead.
*/
export class MediaPlayerLivenessDetector implements LivenessDetector {
private _host: HTMLElement;
private _onChange: () => void;
private _verdict: LivenessVerdict = { state: 'unknown' };
private _visibilityObserver: VisibilityObserver | null = null;
private _visible = false;
private _mediaPlayer: MediaPlayerController | null = null;
private _watchedPlayer: MediaPlayerController | null = null;
private _unsubscribeLiveness: UnsubscribeCallback | null = null;
constructor(host: HTMLElement, onChange: () => void) {
this._host = host;
this._onChange = onChange;
}
public subscribe(): void {
this._host.addEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
this._visibilityObserver = new VisibilityObserver(this._onVisibleChange, {
emitInitial: true,
});
this._visibilityObserver.setRoot(this._host);
}
public unsubscribe(): void {
// Retain the verdict so a reconnect resumes where it left off; use reset()
// to discard it.
this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
this._visibilityObserver?.destroy();
this._visibilityObserver = null;
this._unwatch();
}
public reset(): void {
// The underlying stream changed (e.g. a substream switch): discard the
// verdict and re-evaluate against the new media.
this._unwatch();
this._setVerdict({ state: 'unknown' });
}
public getVerdict(): LivenessVerdict {
return this._verdict;
}
private _onVisibleChange = (visible: boolean): void => {
this._visible = visible;
this._watch();
};
private _onMediaLoaded = (ev: CustomEvent<MediaLoadedInfoEventDetail>): void => {
const player = ev.detail.info.mediaPlayerController ?? null;
this._mediaPlayer = player;
// Drop the player when the source retires this media (unmount), so a stale
// player is never watched.
onAbort(ev.detail.signal, () => {
if (this._mediaPlayer === player) {
this._mediaPlayer = null;
this._watch();
}
});
this._watch();
};
private _watch(): void {
// Only a visible player that exposes the liveness capability carries an
// actionable stall.
const player = this._visible ? this._mediaPlayer : null;
const target = player?.subscribeLiveness ? player : null;
if (target === this._watchedPlayer) {
return;
}
this._unwatch();
this._watchedPlayer = target;
if (player?.subscribeLiveness) {
// Start (or resume) watching; the verdict stays `unknown` until a real
// frame or a stall is observed.
this._unsubscribeLiveness = player.subscribeLiveness((isLive) =>
this._onLiveness(isLive),
);
return;
}
// The media is gone because our own not-live placeholder unmounted the
// frozen stream: hold that verdict. Flipping back to live here would clear
// the placeholder and remount into the same freeze; recovery is instead the
// throttled media_unavailable retry, which replaces this whole provider.
if (!this._mediaPlayer && this._verdict.state === 'not_live') {
return;
}
// Otherwise there is no current evidence: the provider is off-screen (an
// off-screen video legitimately stops presenting frames) or exposes no
// liveness signal, or the media went away for an ordinary reason. Report
// `unknown` rather than leaving a stale `live` that would suppress other
// detectors (e.g. entity availability).
this._setVerdict({ state: 'unknown' });
}
private _onLiveness(isLive: boolean): void {
this._setVerdict(
isLive
? { state: 'live', authority: 'direct' }
: {
state: 'not_live',
authority: 'direct',
renderPlaceholder: true,
reason: 'stalled',
},
);
}
private _unwatch(): void {
this._unsubscribeLiveness?.();
this._unsubscribeLiveness = null;
this._watchedPlayer = null;
}
private _setVerdict(verdict: LivenessVerdict): void {
if (isEqual(this._verdict, verdict)) {
return;
}
this._verdict = verdict;
this._onChange();
}
}
@@ -0,0 +1,50 @@
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
/**
* Detects a provider-reported stream failure: a live provider (or one of its
* inner players) dispatches `live:error` when it cannot play. The listener sits
* on the wrapper host and catches errors bubbling up from any descendant
* provider.
*
* The provider is expected to render its own error, so no reconnecting
* placeholder is asked for; the wrapper leaves the provider mounted (and
* suppresses the load image).
*/
export class ProviderErrorDetector implements LivenessDetector {
private _host: HTMLElement;
private _onChange: () => void;
private _verdict: LivenessVerdict = { state: 'unknown' };
constructor(host: HTMLElement, onChange: () => void) {
this._host = host;
this._onChange = onChange;
}
public subscribe(): void {
this._host.addEventListener(LIVE_ERROR_EVENT, this._handler);
}
public unsubscribe(): void {
this._host.removeEventListener(LIVE_ERROR_EVENT, this._handler);
}
public reset(): void {
this._verdict = { state: 'unknown' };
}
public getVerdict(): LivenessVerdict {
return this._verdict;
}
private _handler = (ev: Event): void => {
ev.stopPropagation();
if (this._verdict.state !== 'not_live') {
// Authoritative: an explicit provider error overrides even direct frame
// evidence. No placeholder -- the provider renders its own error.
this._verdict = { state: 'not_live', authority: 'hard', reason: 'playback_error' };
this._onChange();
}
};
}