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,279 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { StateWatcherSubscriptionInterface } from '../../../../../src/card-controller/hass/state-watcher';
import {
EntityAvailabilityDetector,
LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS,
} from '../../../../../src/components-lib/live/liveness/detectors/entity-availability';
import type { HomeAssistant } from '../../../../../src/ha/types';
import {
callStateWatcherCallback,
createHASS,
createStateEntity,
} from '../../../../test-utils';
const ENTITY = 'camera.office';
const GRACE_MS = LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 1000;
const setup = (options?: {
alwaysError?: boolean;
entity?: string | null;
initialState?: string;
}) => {
const entity = options?.entity === undefined ? ENTITY : options.entity;
let currentEntity = entity;
const hass: HomeAssistant = createHASS(
entity
? { [entity]: createStateEntity({ state: options?.initialState ?? 'idle' }) }
: {},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
stateWatcher.subscribe.mockReturnValue(true);
const onChange = vi.fn();
const detector = new EntityAvailabilityDetector({
getHASS: () => hass,
getStateWatcher: () => stateWatcher,
getCameraEntity: () => currentEntity,
isAlwaysError: () => options?.alwaysError ?? false,
onChange,
});
const setEntityState = (state: string): void => {
if (entity) {
hass.states[entity] = createStateEntity({ state });
}
};
const setCameraEntity = (value: string | null): void => {
currentEntity = value;
};
const fireStateChange = (state: string): void =>
callStateWatcherCallback(stateWatcher, {
entityID: entity ?? ENTITY,
newState: createStateEntity({ state }),
});
return {
detector,
onChange,
stateWatcher,
setEntityState,
setCameraEntity,
fireStateChange,
};
};
// @vitest-environment jsdom
describe('EntityAvailabilityDetector', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should subscribe to the camera entity and start unknown', () => {
const { detector, stateWatcher, onChange } = setup();
detector.subscribe();
expect(stateWatcher.subscribe).toHaveBeenCalledWith(expect.any(Function), [ENTITY]);
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
expect(onChange).not.toHaveBeenCalled();
});
it('should not subscribe when the camera has no entity', () => {
const { detector, stateWatcher } = setup({ entity: null });
detector.subscribe();
expect(stateWatcher.subscribe).not.toHaveBeenCalled();
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
});
it('should report not live after the grace window when the entity stays unavailable', () => {
const { detector, onChange, fireStateChange } = setup();
detector.subscribe();
fireStateChange('unavailable');
// Still live during the grace window.
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
vi.advanceTimersByTime(GRACE_MS);
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'indirect',
renderPlaceholder: true,
reason: 'entity_unavailable',
});
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should tolerate an unavailable blip shorter than the grace window', () => {
const { detector, onChange, fireStateChange } = setup();
detector.subscribe();
fireStateChange('unavailable');
vi.advanceTimersByTime(GRACE_MS - 1000);
fireStateChange('streaming');
vi.advanceTimersByTime(GRACE_MS);
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
expect(onChange).not.toHaveBeenCalled();
});
it('should report not live immediately when always_error is set', () => {
const { detector, onChange, fireStateChange } = setup({
alwaysError: true,
});
detector.subscribe();
fireStateChange('unavailable');
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'hard',
renderPlaceholder: true,
reason: 'entity_unavailable',
});
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should act on the state from the event, not a lagging wrapper hass', () => {
const { detector, setEntityState, fireStateChange } = setup({ alwaysError: true });
detector.subscribe();
// The wrapper's hass (what getHASS reads) has not yet propagated the change,
// so it still reports the pre-change available state, while the event carries
// the fresh unavailable state. The detector must act on the event.
setEntityState('streaming');
fireStateChange('unavailable');
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'hard',
renderPlaceholder: true,
reason: 'entity_unavailable',
});
});
it('should report live again when the entity returns', () => {
const { detector, onChange, fireStateChange } = setup();
detector.subscribe();
fireStateChange('unavailable');
vi.advanceTimersByTime(GRACE_MS);
expect(detector.getVerdict().state).toBe('not_live');
onChange.mockClear();
fireStateChange('streaming');
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should retain the verdict on unsubscribe and stop watching', () => {
const { detector, stateWatcher, fireStateChange } = setup();
detector.subscribe();
fireStateChange('unavailable');
vi.advanceTimersByTime(GRACE_MS);
detector.unsubscribe();
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
// Verdict retained so a reconnect resumes where it left off.
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'indirect',
renderPlaceholder: true,
reason: 'entity_unavailable',
});
});
it('should cancel the pending grace timer on unsubscribe', () => {
const { detector, onChange, fireStateChange } = setup();
detector.subscribe();
fireStateChange('unavailable');
detector.unsubscribe();
vi.advanceTimersByTime(GRACE_MS);
expect(onChange).not.toHaveBeenCalled();
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
});
it('should discard the verdict on reset', () => {
const { detector, setEntityState, fireStateChange } = setup();
detector.subscribe();
fireStateChange('unavailable');
vi.advanceTimersByTime(GRACE_MS);
expect(detector.getVerdict().state).toBe('not_live');
// Re-point at the (now available) entity from a fresh state.
setEntityState('streaming');
detector.reset();
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
});
it('should do nothing on reset before subscribe', () => {
const { detector, stateWatcher } = setup();
detector.reset();
expect(stateWatcher.subscribe).not.toHaveBeenCalled();
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
});
it('should drop the subscription when the camera entity is removed', () => {
const { detector, stateWatcher, setCameraEntity } = setup();
detector.subscribe();
setCameraEntity(null);
detector.reset();
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
});
it('should not restart the grace timer while it is already running', () => {
const { detector, onChange, fireStateChange } = setup();
detector.subscribe();
fireStateChange('unavailable');
// Still unavailable, grace timer already running
fireStateChange('unavailable');
vi.advanceTimersByTime(GRACE_MS);
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'indirect',
renderPlaceholder: true,
reason: 'entity_unavailable',
});
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should stay not live on further unavailable events after the grace window', () => {
const { detector, onChange, fireStateChange } = setup();
detector.subscribe();
fireStateChange('unavailable');
vi.advanceTimersByTime(GRACE_MS);
onChange.mockClear();
// Still unavailable, verdict already not live
fireStateChange('unavailable');
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'indirect',
renderPlaceholder: true,
reason: 'entity_unavailable',
});
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,287 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { MediaPlayerLivenessDetector } from '../../../../../src/components-lib/live/liveness/detectors/media-player-liveness';
import type { LivenessCallback, MediaPlayerController } from '../../../../../src/types';
import {
callIntersectionHandler,
createMediaLoadedInfo,
createMediaLoadedInfoEvent,
IntersectionObserverMock,
} from '../../../../test-utils';
const createPlayer = (): {
player: MediaPlayerController;
unsubscribe: ReturnType<typeof vi.fn>;
fireMediaPlayerLiveness: (isLive: boolean) => void;
} => {
const player = mock<MediaPlayerController>();
const unsubscribe = vi.fn();
let captured: LivenessCallback | null = null;
player.subscribeLiveness = vi.fn((callback: LivenessCallback) => {
captured = callback;
return unsubscribe;
});
return {
player,
unsubscribe,
fireMediaPlayerLiveness: (isLive: boolean) => captured?.(isLive),
};
};
const setup = () => {
const host = document.createElement('div');
document.body.append(host);
const onChange = vi.fn();
const detector = new MediaPlayerLivenessDetector(host, onChange);
const loadMedia = (player?: MediaPlayerController, signal?: AbortSignal): void => {
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
signal,
}),
);
};
return { host, onChange, detector, loadMedia };
};
// @vitest-environment jsdom
describe('MediaPlayerLivenessDetector', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
vi.spyOn(global.document, 'addEventListener');
});
afterAll(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
configurable: true,
});
});
it('should start unknown', () => {
const { detector } = setup();
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
});
it('should watch liveness only once both visible and media are loaded', async () => {
const { detector, loadMedia } = setup();
const { player } = createPlayer();
detector.subscribe();
// Media loaded but not yet visible -> not watched.
loadMedia(player);
expect(player.subscribeLiveness).not.toHaveBeenCalled();
// Becoming visible (initial intersection baseline, via emitInitial) -> watch.
await callIntersectionHandler(true);
expect(player.subscribeLiveness).toHaveBeenCalledTimes(1);
});
it('should report a stall as not live with a reconnecting placeholder', async () => {
const { detector, onChange, loadMedia } = setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
detector.subscribe();
loadMedia(player);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(false);
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'direct',
renderPlaceholder: true,
reason: 'stalled',
});
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should report live again when the stream recovers', async () => {
const { detector, loadMedia } = setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
detector.subscribe();
loadMedia(player);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(false);
fireMediaPlayerLiveness(true);
expect(detector.getVerdict()).toEqual({ state: 'live', authority: 'direct' });
});
it('should not re-notify when the liveness verdict is unchanged', async () => {
const { detector, onChange, loadMedia } = setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
detector.subscribe();
loadMedia(player);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(false);
fireMediaPlayerLiveness(false);
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should stop watching when it becomes not visible', async () => {
const { detector, loadMedia } = setup();
const { player, unsubscribe } = createPlayer();
detector.subscribe();
loadMedia(player);
await callIntersectionHandler(true);
await callIntersectionHandler(false);
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
it('should report unknown after going off-screen while confirmed live', async () => {
const { detector, onChange, loadMedia } = setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
detector.subscribe();
loadMedia(player);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(true);
expect(detector.getVerdict()).toEqual({ state: 'live', authority: 'direct' });
onChange.mockClear();
await callIntersectionHandler(false);
// Off-screen: no current frame evidence, so drop the stale `live` to
// `unknown` rather than suppressing the entity proxy with stale confidence.
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should not watch a player without the liveness capability', async () => {
const { detector, onChange, loadMedia } = setup();
const player = mock<MediaPlayerController>();
player.subscribeLiveness = undefined;
detector.subscribe();
loadMedia(player);
await callIntersectionHandler(true);
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
expect(onChange).not.toHaveBeenCalled();
});
it('should hold the not-live verdict when the frozen media unmounts', async () => {
const { detector, loadMedia } = setup();
const { player, unsubscribe, fireMediaPlayerLiveness } = createPlayer();
const abort = new AbortController();
detector.subscribe();
loadMedia(player, abort.signal);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(false);
// The placeholder unmounts the frozen stream -> the media aborts.
abort.abort();
expect(unsubscribe).toHaveBeenCalledTimes(1);
// Verdict held, not reset to live -- recovery is the throttled remount.
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'direct',
renderPlaceholder: true,
reason: 'stalled',
});
});
it('should drop a confirmed-live verdict to unknown when the media unmounts externally', async () => {
const { detector, loadMedia } = setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
const abort = new AbortController();
detector.subscribe();
loadMedia(player, abort.signal);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(true);
expect(detector.getVerdict()).toEqual({ state: 'live', authority: 'direct' });
// The media unmounts while confirmed live (an ordinary unload, not our own
// not-live placeholder). Drop the stale `live` to `unknown` so it does not
// suppress other detectors (e.g. entity availability).
abort.abort();
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
});
it('should discard the verdict on reset', async () => {
const { detector, loadMedia } = setup();
const { player, unsubscribe, fireMediaPlayerLiveness } = createPlayer();
detector.subscribe();
loadMedia(player);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(false);
detector.reset();
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
it('should tear down the watch and stop listening on unsubscribe', async () => {
const { detector, loadMedia } = setup();
const first = createPlayer();
detector.subscribe();
loadMedia(first.player);
await callIntersectionHandler(true);
detector.unsubscribe();
expect(first.unsubscribe).toHaveBeenCalledTimes(1);
// A later media:loaded is ignored (listener removed).
const second = createPlayer();
loadMedia(second.player);
expect(second.player.subscribeLiveness).not.toHaveBeenCalled();
});
it('should not watch when media loads without a player controller', async () => {
const { detector, onChange, loadMedia } = setup();
detector.subscribe();
loadMedia(undefined);
await callIntersectionHandler(true);
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
expect(onChange).not.toHaveBeenCalled();
});
it('should ignore a stale media abort after a newer load', async () => {
const { detector, loadMedia } = setup();
const first = createPlayer();
const second = createPlayer();
const abortFirst = new AbortController();
detector.subscribe();
loadMedia(first.player, abortFirst.signal);
loadMedia(second.player);
await callIntersectionHandler(true);
// The stale abort of the first load must not drop the current player.
abortFirst.abort();
second.fireMediaPlayerLiveness(false);
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'direct',
renderPlaceholder: true,
reason: 'stalled',
});
});
});
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import { ProviderErrorDetector } from '../../../../../src/components-lib/live/liveness/detectors/provider-error';
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
const createHostInDocument = (): HTMLElement => {
const host = document.createElement('div');
document.body.append(host);
return host;
};
// @vitest-environment jsdom
describe('ProviderErrorDetector', () => {
it('should start unknown', () => {
const detector = new ProviderErrorDetector(document.createElement('div'), vi.fn());
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
});
it('should report not live on a provider error, leaving the provider mounted', () => {
const host = createHostInDocument();
const onChange = vi.fn();
const detector = new ProviderErrorDetector(host, onChange);
detector.subscribe();
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
// not_live but no renderPlaceholder: the provider renders its own error.
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'hard',
reason: 'playback_error',
});
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should notify only on the transition to not live', () => {
const host = createHostInDocument();
const onChange = vi.fn();
const detector = new ProviderErrorDetector(host, onChange);
detector.subscribe();
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should stop the error from propagating past the host', () => {
const host = createHostInDocument();
const parentListener = vi.fn();
document.body.addEventListener(LIVE_ERROR_EVENT, parentListener);
const detector = new ProviderErrorDetector(host, vi.fn());
detector.subscribe();
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
expect(parentListener).not.toHaveBeenCalled();
document.body.removeEventListener(LIVE_ERROR_EVENT, parentListener);
});
it('should discard the verdict on reset', () => {
const host = createHostInDocument();
const detector = new ProviderErrorDetector(host, vi.fn());
detector.subscribe();
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
expect(detector.getVerdict().state).toBe('not_live');
detector.reset();
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
});
it('should ignore errors after unsubscribe', () => {
const host = createHostInDocument();
const onChange = vi.fn();
const detector = new ProviderErrorDetector(host, onChange);
detector.subscribe();
detector.unsubscribe();
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
expect(detector.getVerdict().state).toBe('unknown');
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,348 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { Camera } from '../../../../src/camera-manager/camera';
import type { StateWatcherSubscriptionInterface } from '../../../../src/card-controller/hass/state-watcher';
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
import { StreamLivenessController } from '../../../../src/components-lib/live/liveness/stream-liveness-controller';
import type { LivenessCallback, MediaPlayerController } from '../../../../src/types';
import {
callIntersectionHandler,
callStateWatcherCallback,
createCameraConfig,
createHASS,
createLitElement,
createMediaLoadedInfo,
createMediaLoadedInfoEvent,
createStateEntity,
IntersectionObserverMock,
} from '../../../test-utils';
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
const setup = (options?: { targetID?: string | null }) => {
const host = createLitElement();
document.body.append(host);
const controller = new StreamLivenessController(host, {
getTargetID: () =>
options?.targetID === undefined ? 'camera.office' : options.targetID,
getHASS: () => null,
getCamera: () => null,
getStateWatcher: () => null,
});
const issueTriggers: unknown[] = [];
host.addEventListener(ISSUE_TRIGGER_EVENT, (ev) =>
issueTriggers.push((ev as CustomEvent).detail),
);
const failViaProviderError = (): void => {
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
};
return { host, controller, issueTriggers, failViaProviderError };
};
const createPlayer = (): {
player: MediaPlayerController;
fireMediaPlayerLiveness: (isLive: boolean) => void;
} => {
const player = mock<MediaPlayerController>();
let captured: LivenessCallback | null = null;
player.subscribeLiveness = vi.fn((callback: LivenessCallback) => {
captured = callback;
return vi.fn();
});
return { player, fireMediaPlayerLiveness: (isLive: boolean) => captured?.(isLive) };
};
// @vitest-environment jsdom
describe('StreamLivenessController', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
vi.spyOn(global.document, 'addEventListener');
});
afterAll(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
configurable: true,
});
});
it('should register itself as a controller on the host', () => {
const { host, controller } = setup();
expect(host.addController).toHaveBeenCalledWith(controller);
});
it('should be live with no placeholder by default', () => {
const { controller } = setup();
expect(controller.isLive()).toBe(true);
expect(controller.getPlaceholder()).toBeNull();
});
it('should aggregate a detector losing liveness only after host connect', () => {
const { controller, failViaProviderError } = setup();
// Not yet connected -> the provider-error listener is not attached.
failViaProviderError();
expect(controller.isLive()).toBe(true);
controller.hostConnected();
failViaProviderError();
expect(controller.isLive()).toBe(false);
});
it('should fire the media_unavailable issue and request an update when liveness is lost', () => {
const { host, controller, issueTriggers, failViaProviderError } = setup();
controller.hostConnected();
failViaProviderError();
expect(issueTriggers).toEqual([
{ key: 'media_unavailable', targetID: 'camera.office', reason: 'playback_error' },
]);
expect(host.requestUpdate).toHaveBeenCalled();
});
it('should not request a placeholder when the detector renders its own error', () => {
const { controller, failViaProviderError } = setup();
controller.hostConnected();
failViaProviderError();
// Provider-error is not-live but does not want a placeholder.
expect(controller.isLive()).toBe(false);
expect(controller.getPlaceholder()).toBeNull();
});
it('should not fire the issue without a target', () => {
const { host, controller, issueTriggers, failViaProviderError } = setup({
targetID: null,
});
controller.hostConnected();
failViaProviderError();
expect(issueTriggers).toEqual([]);
expect(host.requestUpdate).toHaveBeenCalled();
});
it('should request a placeholder when a detector reports a silent freeze', async () => {
const { host, controller, issueTriggers } = setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
controller.hostConnected();
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
}),
);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(false);
expect(controller.isLive()).toBe(false);
expect(controller.getPlaceholder()).toEqual({ reason: 'stalled' });
expect(issueTriggers).toEqual([
{ key: 'media_unavailable', targetID: 'camera.office', reason: 'stalled' },
]);
});
it('should stop aggregating detector inputs after host disconnect', () => {
const { controller, failViaProviderError } = setup();
controller.hostConnected();
controller.hostDisconnected();
failViaProviderError();
expect(controller.isLive()).toBe(true);
});
it('should reset all detectors', () => {
const { controller, failViaProviderError } = setup();
controller.hostConnected();
failViaProviderError();
expect(controller.isLive()).toBe(false);
controller.reset();
expect(controller.isLive()).toBe(true);
});
it('should request an update without an issue when liveness recovers', async () => {
const { host, controller, issueTriggers } = setup();
const { player, fireMediaPlayerLiveness } = createPlayer();
controller.hostConnected();
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
}),
);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(false);
issueTriggers.length = 0;
vi.mocked(host.requestUpdate).mockClear();
fireMediaPlayerLiveness(true);
expect(issueTriggers).toEqual([]);
expect(host.requestUpdate).toHaveBeenCalled();
});
it('should surface an always_error unavailable entity as a placeholder', () => {
const host = createLitElement();
document.body.append(host);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
stateWatcher.subscribe.mockReturnValue(true);
const camera = mock<Camera>();
camera.getConfig.mockReturnValue(
createCameraConfig({
camera_entity: 'camera.office',
always_error_if_entity_unavailable: true,
}),
);
let currentCamera: Camera | null = camera;
const hass = createHASS({
'camera.office': createStateEntity({ state: 'unavailable' }),
});
const controller = new StreamLivenessController(host, {
getTargetID: () => 'camera.office',
getHASS: () => hass,
getCamera: () => currentCamera,
getStateWatcher: () => stateWatcher,
});
const issueTriggers: unknown[] = [];
host.addEventListener(ISSUE_TRIGGER_EVENT, (ev) =>
issueTriggers.push((ev as CustomEvent).detail),
);
controller.hostConnected();
expect(controller.isLive()).toBe(false);
expect(controller.getPlaceholder()).toEqual({ reason: 'entity_unavailable' });
expect(issueTriggers).toEqual([
{
key: 'media_unavailable',
targetID: 'camera.office',
reason: 'entity_unavailable',
},
]);
// The camera reference is removed while the entity is still watched; the
// always_error lookup must tolerate a now-null camera and leave the
// already-not-live verdict unchanged.
currentCamera = null;
callStateWatcherCallback(stateWatcher, {
entityID: 'camera.office',
newState: createStateEntity({ state: 'unavailable' }),
});
expect(controller.isLive()).toBe(false);
});
it('should keep a frame-confirmed stream live despite an unavailable entity', async () => {
vi.useFakeTimers();
const host = createLitElement();
document.body.append(host);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
stateWatcher.subscribe.mockReturnValue(true);
const camera = mock<Camera>();
camera.getConfig.mockReturnValue(
createCameraConfig({ camera_entity: 'camera.office' }),
);
const hass = createHASS({
'camera.office': createStateEntity({ state: 'unavailable' }),
});
const controller = new StreamLivenessController(host, {
getTargetID: () => 'camera.office',
getHASS: () => hass,
getCamera: () => camera,
getStateWatcher: () => stateWatcher,
});
const issueTriggers: unknown[] = [];
host.addEventListener(ISSUE_TRIGGER_EVENT, (ev) =>
issueTriggers.push((ev as CustomEvent).detail),
);
const { player, fireMediaPlayerLiveness } = createPlayer();
controller.hostConnected();
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
}),
);
await callIntersectionHandler(true);
// Frames confirm the stream is live, then the entity blips unavailable past
// its grace window.
fireMediaPlayerLiveness(true);
vi.advanceTimersByTime(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 1000);
// Direct frame evidence outranks the entity proxy: no teardown, no issue.
expect(controller.isLive()).toBe(true);
expect(controller.getPlaceholder()).toBeNull();
expect(issueTriggers).toEqual([]);
vi.useRealTimers();
});
it('should report not live despite confirmed frames when always_error overrides', async () => {
const host = createLitElement();
document.body.append(host);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
stateWatcher.subscribe.mockReturnValue(true);
const camera = mock<Camera>();
camera.getConfig.mockReturnValue(
createCameraConfig({
camera_entity: 'camera.office',
always_error_if_entity_unavailable: true,
}),
);
const hass = createHASS({
'camera.office': createStateEntity({ state: 'unavailable' }),
});
const controller = new StreamLivenessController(host, {
getTargetID: () => 'camera.office',
getHASS: () => hass,
getCamera: () => camera,
getStateWatcher: () => stateWatcher,
});
const { player, fireMediaPlayerLiveness } = createPlayer();
controller.hostConnected();
host.dispatchEvent(
createMediaLoadedInfoEvent({
info: createMediaLoadedInfo({ mediaPlayerController: player }),
}),
);
await callIntersectionHandler(true);
fireMediaPlayerLiveness(true);
// The always_error opt-in is authoritative: an unavailable entity overrides
// even confirmed frames.
expect(controller.isLive()).toBe(false);
expect(controller.getPlaceholder()).toEqual({ reason: 'entity_unavailable' });
});
});
@@ -60,6 +60,9 @@ describe('MediaLoadedInfoSourceController', () => {
});
expect(ev.detail.signal).toBeInstanceOf(AbortSignal);
expect(ev.detail.signal.aborted).toBe(false);
// A fresh load omits `cached` (only a replay marks it true).
expect(ev.detail.cached).toBeUndefined();
});
it('should dedup structurally-equal info', () => {
@@ -176,6 +179,10 @@ describe('MediaLoadedInfoSourceController', () => {
expect(firstSignal).not.toBe(secondSignal);
expect(firstSignal.aborted).toBe(true);
expect(secondSignal.aborted).toBe(false);
// The fresh load omits `cached`; the reconnect replay marks it `true`.
expect((handler.mock.calls[0][0] as CustomEvent).detail.cached).toBeUndefined();
expect((handler.mock.calls[1][0] as CustomEvent).detail.cached).toBe(true);
});
it('should be a no-op when there is nothing to re-dispatch', () => {
@@ -0,0 +1,264 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FRAME_STALL_SECONDS,
FrameStallWatchdog,
type FrameStallWatchdogConfig,
} from '../../../src/components-lib/media-player/frame-stall-watchdog';
const STALL_MS = FRAME_STALL_SECONDS * 1000;
const createConfig = (
overrides?: Partial<FrameStallWatchdogConfig>,
): FrameStallWatchdogConfig => ({
isPlaybackExpected: vi.fn().mockReturnValue(true),
startSource: vi.fn().mockReturnValue(true),
stopSource: vi.fn(),
...overrides,
});
// @vitest-environment jsdom
describe('FrameStallWatchdog', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('source lifecycle', () => {
it('should start the source only on the first subscriber', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
watchdog.subscribe(vi.fn());
watchdog.subscribe(vi.fn());
expect(config.startSource).toHaveBeenCalledTimes(1);
});
it('should stop the source only on the last unsubscribe', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
const unsubscribeFirst = watchdog.subscribe(vi.fn());
const unsubscribeSecond = watchdog.subscribe(vi.fn());
unsubscribeFirst();
expect(config.stopSource).not.toHaveBeenCalled();
unsubscribeSecond();
expect(config.stopSource).toHaveBeenCalledTimes(1);
});
it('should default to an always-available source needing no teardown', () => {
const watchdog = new FrameStallWatchdog({ isPlaybackExpected: () => true });
const callback = vi.fn();
const unsubscribe = watchdog.subscribe(callback);
vi.advanceTimersByTime(STALL_MS);
unsubscribe(); // no stopSource configured -> no throw
expect(callback).toHaveBeenCalledWith(false);
});
});
describe('stall detection', () => {
it('should report stalled when no frame arrives within the window', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
const callback = vi.fn();
watchdog.subscribe(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(false);
});
it('should stay live while frames keep arriving within the window', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
const callback = vi.fn();
watchdog.subscribe(callback);
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(STALL_MS - 1000);
watchdog.notifyFrame();
}
vi.advanceTimersByTime(STALL_MS - 1000);
// Confirmed live on the first frame, and never stalled thereafter.
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(true);
});
it('should report live again once a frame arrives after a stall', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
const callback = vi.fn();
watchdog.subscribe(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).toHaveBeenNthCalledWith(1, false);
watchdog.notifyFrame();
expect(callback).toHaveBeenNthCalledWith(2, true);
});
it('should not report stalled while the source is legitimately idle', () => {
const config = createConfig({
isPlaybackExpected: vi.fn().mockReturnValue(false),
});
const watchdog = new FrameStallWatchdog(config);
const callback = vi.fn();
watchdog.subscribe(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should re-arm while idle so a freeze that becomes actionable later is caught', () => {
const isPlaybackExpected = vi.fn().mockReturnValue(false);
const config = createConfig({ isPlaybackExpected });
const watchdog = new FrameStallWatchdog(config);
const callback = vi.fn();
watchdog.subscribe(callback);
// Idle window: no stall reported, but the watchdog re-arms rather than
// stopping.
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
// The source becomes actionable while still frozen (holding a frame), with
// no new frame to kick the timer. The re-armed timer catches the freeze on
// the next window.
isPlaybackExpected.mockReturnValue(true);
vi.advanceTimersByTime(STALL_MS);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(false);
});
it('should broadcast a stall to every subscriber', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
const first = vi.fn();
const second = vi.fn();
watchdog.subscribe(first);
watchdog.subscribe(second);
vi.advanceTimersByTime(STALL_MS);
expect(first).toHaveBeenCalledTimes(1);
expect(first).toHaveBeenCalledWith(false);
expect(second).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledWith(false);
});
it('should not report live merely on subscribing before any frame', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
const callback = vi.fn();
// Unconfirmed until a real frame arrives: subscribing alone does not
// report live.
watchdog.subscribe(callback);
expect(callback).not.toHaveBeenCalled();
});
it('should confirm live on the first frame but not re-notify on the next', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
const callback = vi.fn();
watchdog.subscribe(callback);
watchdog.notifyFrame();
watchdog.notifyFrame();
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(true);
});
});
describe('no available source', () => {
it('should never report stalled when the source is unavailable', () => {
const config = createConfig({ startSource: vi.fn().mockReturnValue(false) });
const watchdog = new FrameStallWatchdog(config);
const callback = vi.fn();
watchdog.subscribe(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
expect(config.isPlaybackExpected).not.toHaveBeenCalled();
});
it('should ignore notifyFrame when the source is unavailable', () => {
const config = createConfig({ startSource: vi.fn().mockReturnValue(false) });
const watchdog = new FrameStallWatchdog(config);
const callback = vi.fn();
watchdog.subscribe(callback);
watchdog.notifyFrame();
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should not stop a source that was never started on unsubscribe', () => {
const config = createConfig({ startSource: vi.fn().mockReturnValue(false) });
const watchdog = new FrameStallWatchdog(config);
const unsubscribe = watchdog.subscribe(vi.fn());
unsubscribe();
expect(config.stopSource).not.toHaveBeenCalled();
});
});
describe('teardown', () => {
it('should not report a stall after the last subscriber leaves', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
const callback = vi.fn();
const unsubscribe = watchdog.subscribe(callback);
unsubscribe();
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should stop cleanly if the last subscriber unsubscribes during a recovery notification', () => {
const config = createConfig();
const watchdog = new FrameStallWatchdog(config);
let unsubscribe: () => void = () => {};
const callback = vi.fn((isLive: boolean) => {
if (isLive) {
unsubscribe();
}
});
unsubscribe = watchdog.subscribe(callback);
// Stall -> callback(false)
vi.advanceTimersByTime(STALL_MS);
// Recovery -> callback(true) -> unsubscribe -> stop
watchdog.notifyFrame();
expect(config.stopSource).toHaveBeenCalledTimes(1);
// The timer armed by notifyFrame must have been stopped, so no further
// stall fires with no subscribers.
callback.mockClear();
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
});
});
@@ -1,7 +1,8 @@
import type JSMpeg from '@cycjimmy/jsmpeg-player';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { FRAME_STALL_SECONDS } from '../../../src/components-lib/media-player/frame-stall-watchdog';
import { JSMPEGMediaPlayerController } from '../../../src/components-lib/media-player/jsmpeg';
import { createLitElement } from '../../test-utils';
@@ -260,4 +261,64 @@ describe('JSMPEGMediaPlayerController', () => {
expect(controller.getPIPElement()).toBeNull();
});
describe('subscribeLiveness', () => {
const STALL_MS = FRAME_STALL_SECONDS * 1000;
const setup = (options?: { paused?: boolean; hasPlayer?: boolean }) => {
let videoElement: JSMpeg.VideoElement | null = null;
if (options?.hasPlayer !== false) {
videoElement = mock<JSMpeg.VideoElement>();
videoElement.player = mock<JSMpeg.Player>();
videoElement.player.paused = options?.paused ?? false;
}
const controller = new JSMPEGMediaPlayerController(
createLitElement(),
() => videoElement,
() => mock<HTMLCanvasElement>(),
);
return { controller };
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should report a stall when frame decodes stop', () => {
const { controller } = setup();
const callback = vi.fn();
controller.subscribeLiveness(callback);
controller.notifyFrameDecoded();
vi.advanceTimersByTime(STALL_MS);
// The first decoded frame confirms live, then decodes stop -> stall.
expect(callback).toHaveBeenNthCalledWith(1, true);
expect(callback).toHaveBeenNthCalledWith(2, false);
});
it('should not report a stall while paused', () => {
const { controller } = setup({ paused: true });
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should not report a stall without a player', () => {
const { controller } = setup({ hasPlayer: false });
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
});
});
+185 -1
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { FRAME_STALL_SECONDS } from '../../../src/components-lib/media-player/frame-stall-watchdog';
import { VideoMediaPlayerController } from '../../../src/components-lib/media-player/video';
import {
hideMediaControlsTemporarily,
@@ -16,6 +17,48 @@ class NotAllowedError extends Error {
name = 'NotAllowedError';
}
const STALL_MS = FRAME_STALL_SECONDS * 1000;
// A real jsdom <video> (so `'requestVideoFrameCallback' in video` is genuinely
// false unless we add it) with controllable properties and a driveable frame
// callback.
const createVideo = (options?: {
readyState?: number;
paused?: boolean;
seeking?: boolean;
ended?: boolean;
rvfc?: boolean;
}): {
video: HTMLVideoElement;
deliverFrame: () => void;
cancel: ReturnType<typeof vi.fn>;
} => {
const video = document.createElement('video');
const define = (prop: string, value: unknown): void => {
Object.defineProperty(video, prop, { value, configurable: true });
};
define('readyState', options?.readyState ?? HTMLMediaElement.HAVE_CURRENT_DATA);
define('paused', options?.paused ?? false);
define('seeking', options?.seeking ?? false);
define('ended', options?.ended ?? false);
let frameCallback: (() => void) | null = null;
const cancel = vi.fn();
if (options?.rvfc !== false) {
let handle = 0;
define(
'requestVideoFrameCallback',
vi.fn((cb: () => void) => {
frameCallback = cb;
return ++handle;
}),
);
define('cancelVideoFrameCallback', cancel);
}
return { video, deliverFrame: () => frameCallback?.(), cancel };
};
// @vitest-environment jsdom
describe('VideoMediaPlayerController', () => {
beforeEach(() => {
@@ -287,4 +330,145 @@ describe('VideoMediaPlayerController', () => {
expect(controller.getPIPElement()).toBeNull();
});
});
describe('subscribeLiveness', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should report a stall when frames stop arriving', () => {
const { video, deliverFrame } = createVideo();
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
deliverFrame();
vi.advanceTimersByTime(STALL_MS);
// The first frame confirms live, then no further frame arrives -> stall.
expect(callback).toHaveBeenNthCalledWith(1, true);
expect(callback).toHaveBeenNthCalledWith(2, false);
});
it('should not report a stall while a paused video still holds a frame', () => {
// A genuine user pause: paused with a current frame is idle, never
// reported -- not even after the stall window.
const { video } = createVideo({ paused: true });
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should report a video stuck paused with no current frame', () => {
// The observed go2rtc failure: its internal reconnect leaves the <video>
// paused at readyState 0 (spinning). That is not a genuine pause -- there
// is no frame to pause on -- so playback is still expected and a missing
// frame is a stall.
const { video } = createVideo({
paused: true,
readyState: HTMLMediaElement.HAVE_NOTHING,
});
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(false);
});
it('should report no stall when requestVideoFrameCallback is unavailable', () => {
const { video } = createVideo({ rvfc: false });
expect('requestVideoFrameCallback' in video).toBe(false);
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
controller.subscribeLiveness(callback);
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalled();
});
it('should stop watching and cancel the frame callback on unsubscribe', () => {
const { video, cancel } = createVideo();
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
const callback = vi.fn();
const unsubscribe = controller.subscribeLiveness(callback);
unsubscribe();
vi.advanceTimersByTime(STALL_MS);
expect(cancel).toHaveBeenCalled();
expect(callback).not.toHaveBeenCalled();
});
it('should not re-arm when the last subscriber unsubscribes during recovery', () => {
const { video, deliverFrame, cancel } = createVideo();
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
let unsubscribe: () => void = () => {};
const callback = vi.fn((isLive: boolean) => {
if (isLive) {
unsubscribe();
}
});
unsubscribe = controller.subscribeLiveness(callback);
// Stall -> callback(false)
vi.advanceTimersByTime(STALL_MS);
// Recovery -> callback(true) -> unsubscribe -> source stopped
deliverFrame();
expect(cancel).toHaveBeenCalled();
});
it('should skip re-arming and report no stall when the video is gone between frames', () => {
const { video, deliverFrame } = createVideo();
let currentVideo: HTMLVideoElement | null = video;
const controller = new VideoMediaPlayerController(
createLitElement(),
() => currentVideo,
);
const callback = vi.fn();
controller.subscribeLiveness(callback);
deliverFrame();
// Remove video
currentVideo = null;
// Re-arm skipped: no video
deliverFrame();
// The first frame reported live; the stall timer then fires with no
// video, so no stall is ever reported.
vi.advanceTimersByTime(STALL_MS);
expect(callback).not.toHaveBeenCalledWith(false);
});
it('should cancel nothing on unsubscribe when the video is already gone', () => {
const { video, cancel } = createVideo();
let currentVideo: HTMLVideoElement | null = video;
const controller = new VideoMediaPlayerController(
createLitElement(),
() => currentVideo,
);
const unsubscribe = controller.subscribeLiveness(vi.fn());
currentVideo = null;
unsubscribe();
expect(cancel).not.toHaveBeenCalled();
});
});
});
@@ -220,4 +220,51 @@ describe('VisibilityObserver', () => {
);
});
});
describe('emitInitial', () => {
it('should emit the initial visible value on the baseline callback', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange, { emitInitial: true });
observer.setRoot(createParent());
await callIntersectionHandler(true);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(true);
});
it('should emit the initial hidden value on the baseline callback', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange, { emitInitial: true });
observer.setRoot(createParent());
await callIntersectionHandler(false);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(false);
});
it('should not re-emit an unchanged state after the initial emit', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange, { emitInitial: true });
observer.setRoot(createParent());
await callIntersectionHandler(true);
await callIntersectionHandler(true);
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should still emit subsequent transitions after the initial emit', async () => {
const onChange = vi.fn();
const observer = new VisibilityObserver(onChange, { emitInitial: true });
observer.setRoot(createParent());
await callIntersectionHandler(true);
await callIntersectionHandler(false);
expect(onChange).toHaveBeenNthCalledWith(1, true);
expect(onChange).toHaveBeenNthCalledWith(2, false);
});
});
});