fix: Clear stale media unavailable errors when a camera recovers (#2627)
- Closes: #2576
This commit is contained in:
@@ -74,7 +74,7 @@ export class CallManager {
|
|||||||
.getCameraIDsWithCapability('live')
|
.getCameraIDsWithCapability('live')
|
||||||
.has(parentID)
|
.has(parentID)
|
||||||
) {
|
) {
|
||||||
this._notifyError('error.call_invalid_target', inbound);
|
this._notifyError('error.call_invalid_target', { inbound });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +261,22 @@ export class CallManager {
|
|||||||
return this.end();
|
return this.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The microphone could not be used for the call, so it is connected but the
|
||||||
|
// user cannot be heard. `description` is what the reporting layer knows about
|
||||||
|
// the failure, when it knows anything.
|
||||||
|
public reportCallMicrophoneError(targetID: string, description?: string): void {
|
||||||
|
const call = this._call;
|
||||||
|
|
||||||
|
// A report that no longer matches the call in progress describes an attempt
|
||||||
|
// the user has already moved past, e.g. the call ended before the provider
|
||||||
|
// finished reporting.
|
||||||
|
if (!call || !call.answered || call.cameraID !== targetID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._notifyError('error.call_microphone_failed', { context: description });
|
||||||
|
}
|
||||||
|
|
||||||
// Tears down everything `initialize()` set up: stops any in-flight ringtone
|
// Tears down everything `initialize()` set up: stops any in-flight ringtone
|
||||||
// and unanswered timer, drops the active call session, clears the call
|
// and unanswered timer, drops the active call session, clears the call
|
||||||
// condition state, and de-registers the condition-state listener. Driven by
|
// condition state, and de-registers the condition-state listener. Driven by
|
||||||
@@ -367,14 +383,21 @@ export class CallManager {
|
|||||||
// Helpers
|
// Helpers
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private _notifyError(messageKey: string, inbound: boolean): void {
|
// `context` is a diagnostic the user can quote when reporting the problem.
|
||||||
if (inbound) {
|
private _notifyError(
|
||||||
// Don't show errors on inbound calls.
|
messageKey: string,
|
||||||
|
options?: { inbound?: boolean; context?: string },
|
||||||
|
): void {
|
||||||
|
// An inbound call the user has not answered yet is not something they have
|
||||||
|
// asked for, so a failure to place it is not worth interrupting them with.
|
||||||
|
if (options?.inbound) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const context = options?.context;
|
||||||
this._api.getNotificationManager().setNotification(
|
this._api.getNotificationManager().setNotification(
|
||||||
createNotificationFromText(localize(messageKey), {
|
createNotificationFromText(localize(messageKey), {
|
||||||
heading: { text: localize('error.call_unavailable_heading') },
|
heading: { text: localize('error.call_unavailable_heading') },
|
||||||
|
...(context && { context }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -385,7 +408,7 @@ export class CallManager {
|
|||||||
const microphoneManager = this._api.getMicrophoneManager();
|
const microphoneManager = this._api.getMicrophoneManager();
|
||||||
|
|
||||||
if (!microphoneManager.isSupported()) {
|
if (!microphoneManager.isSupported()) {
|
||||||
this._notifyError('error.call_microphone_unsupported', inbound);
|
this._notifyError('error.call_microphone_unsupported', { inbound });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +417,7 @@ export class CallManager {
|
|||||||
// there clears the denial, and failing there reports it. An outbound call
|
// there clears the denial, and failing there reports it. An outbound call
|
||||||
// needs the microphone immediately, so a known denial ends it here.
|
// needs the microphone immediately, so a known denial ends it here.
|
||||||
if (!inbound && microphoneManager.isForbidden()) {
|
if (!inbound && microphoneManager.isForbidden()) {
|
||||||
this._notifyError('error.call_microphone_forbidden', inbound);
|
this._notifyError('error.call_microphone_forbidden', { inbound });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +451,7 @@ export class CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
this._notifyError('error.call_microphone_forbidden', false);
|
this._notifyError('error.call_microphone_forbidden');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -453,7 +476,7 @@ export class CallManager {
|
|||||||
.getStore()
|
.getStore()
|
||||||
.getAllDependentCameras(cameraID, '2-way-audio');
|
.getAllDependentCameras(cameraID, '2-way-audio');
|
||||||
if (!eligibleCameraIDs.has(streamID)) {
|
if (!eligibleCameraIDs.has(streamID)) {
|
||||||
this._notifyError('error.call_invalid_target', inbound);
|
this._notifyError('error.call_invalid_target', { inbound });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return streamID;
|
return streamID;
|
||||||
@@ -480,7 +503,7 @@ export class CallManager {
|
|||||||
.getAllDependentCameras(parentID, '2-way-audio'),
|
.getAllDependentCameras(parentID, '2-way-audio'),
|
||||||
];
|
];
|
||||||
if (!candidates.length) {
|
if (!candidates.length) {
|
||||||
this._notifyError('error.call_no_two_way_audio', inbound);
|
this._notifyError('error.call_no_two_way_audio', { inbound });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return candidates[0];
|
return candidates[0];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IssueTriggerContext } from 'issue';
|
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||||
|
|
||||||
import type { ConditionStateChange } from '../../condition-trigger/conditions/types';
|
import type { ConditionStateChange } from '../../condition-trigger/conditions/types';
|
||||||
import { contentsChanged, ignoreFunctionIdentity } from '../../utils/basic';
|
import { contentsChanged, ignoreFunctionIdentity } from '../../utils/basic';
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
IssueKey,
|
IssueKey,
|
||||||
IssuePresence,
|
IssuePresence,
|
||||||
IssueReadOnlyState,
|
IssueReadOnlyState,
|
||||||
|
IssueResolveContextKey,
|
||||||
IssueTriggerContextKey,
|
IssueTriggerContextKey,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -71,6 +72,16 @@ export class IssueManager {
|
|||||||
this.evaluate();
|
this.evaluate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called by components that observe a problem recovering directly (e.g. a
|
||||||
|
// stream that is proven to be delivering media again).
|
||||||
|
public resolve<K extends IssueResolveContextKey>(
|
||||||
|
key: K,
|
||||||
|
context: IssueResolveContext[K],
|
||||||
|
): void {
|
||||||
|
this._stateManager.resolve(key, context);
|
||||||
|
this.evaluate();
|
||||||
|
}
|
||||||
|
|
||||||
// Evaluate all dynamic issues against current state, re-render the card if
|
// Evaluate all dynamic issues against current state, re-render the card if
|
||||||
// the issue presence changed, and schedule retries.
|
// the issue presence changed, and schedule retries.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IssueTriggerContext } from 'issue';
|
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||||
|
|
||||||
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
|
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -23,7 +23,6 @@ export type MediaUnavailableIssueReason =
|
|||||||
| 'playback_error'
|
| 'playback_error'
|
||||||
| 'server_error'
|
| 'server_error'
|
||||||
| 'stalled'
|
| 'stalled'
|
||||||
| 'two_way_audio_error'
|
|
||||||
| 'unsupported';
|
| 'unsupported';
|
||||||
|
|
||||||
declare module 'issue' {
|
declare module 'issue' {
|
||||||
@@ -37,6 +36,12 @@ declare module 'issue' {
|
|||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IssueResolveContext {
|
||||||
|
media_unavailable: {
|
||||||
|
targetID: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What is known about one target's failure.
|
// What is known about one target's failure.
|
||||||
@@ -74,10 +79,6 @@ export const MEDIA_UNAVAILABLE_REASONS: Record<
|
|||||||
localizationKey: 'issues.media_unavailable.reasons.stalled',
|
localizationKey: 'issues.media_unavailable.reasons.stalled',
|
||||||
icon: 'mdi:motion-pause',
|
icon: 'mdi:motion-pause',
|
||||||
},
|
},
|
||||||
two_way_audio_error: {
|
|
||||||
localizationKey: 'issues.media_unavailable.reasons.two_way_audio_error',
|
|
||||||
icon: 'mdi:microphone-off',
|
|
||||||
},
|
|
||||||
unsupported: {
|
unsupported: {
|
||||||
localizationKey: 'issues.media_unavailable.reasons.unsupported',
|
localizationKey: 'issues.media_unavailable.reasons.unsupported',
|
||||||
icon: 'mdi:video-off-outline',
|
icon: 'mdi:video-off-outline',
|
||||||
@@ -102,21 +103,20 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
this._api = api;
|
this._api = api;
|
||||||
this._onChange = onChange ?? null;
|
this._onChange = onChange ?? null;
|
||||||
|
|
||||||
// Clear a target's error on a genuine media (re)load.
|
// React to a target's media loading; unload / select changes are
|
||||||
|
// irrelevant here.
|
||||||
this._unsubscribeCallback = this._api
|
this._unsubscribeCallback = this._api
|
||||||
.getMediaLoadedInfoManager()
|
.getMediaLoadedInfoManager()
|
||||||
.subscribe((change) => {
|
.subscribe((change) => {
|
||||||
// A reconnect replay (`cached`) did not actually reload the media, and
|
if (change.type === 'load') {
|
||||||
// unload / select changes are irrelevant here; only a genuine load
|
|
||||||
// clears the error.
|
|
||||||
if (change.type === 'load' && !change.cached) {
|
|
||||||
this._onMediaLoad(change.targetID);
|
this._onMediaLoad(change.targetID);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Explicit trigger -- called when a component fires an issue:trigger event.
|
// Explicit trigger and resolve -- called when a component fires an
|
||||||
|
// issue:trigger or issue:resolve event.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public trigger(context: IssueTriggerContext['media_unavailable']): void {
|
public trigger(context: IssueTriggerContext['media_unavailable']): void {
|
||||||
@@ -126,6 +126,14 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A target is proven to be delivering media again. Stronger evidence than a
|
||||||
|
// media load, which only says a player attached, so it clears any recorded
|
||||||
|
// error.
|
||||||
|
public resolve(context: IssueResolveContext['media_unavailable']): void {
|
||||||
|
this._erroredTargets.delete(context.targetID);
|
||||||
|
this._cancelPendingTimer(context.targetID);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Detection -- called by the manager on every state change.
|
// Detection -- called by the manager on every state change.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -139,8 +147,8 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
// A known error for the current target activates immediately, even if its
|
// A known error for the current target activates immediately, even if its
|
||||||
// (frozen) media still reads as loaded (it might be loaded but then
|
// (frozen) media still reads as loaded (it might be loaded but then
|
||||||
// reported a playback error that stops playback but leaves the player
|
// reported a playback error that stops playback but leaves the player
|
||||||
// attached). Errors are cleared out-of-band by `_onMediaLoad` on a genuine
|
// attached). Errors are cleared out-of-band, by `resolve` or by
|
||||||
// reload.
|
// `_onMediaLoad`.
|
||||||
if (this._hasError(state)) {
|
if (this._hasError(state)) {
|
||||||
this._activate();
|
this._activate();
|
||||||
return;
|
return;
|
||||||
@@ -155,9 +163,16 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
this._handlePendingLoad(state);
|
this._handlePendingLoad(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A genuine media (re)load for a target clears its error.
|
// A load proves media attached for the target. That ends any wait on it, and
|
||||||
|
// refutes a `not_loading` error. It is no evidence of recovery for any other
|
||||||
|
// reason, so those clear only via `resolve`.
|
||||||
private _onMediaLoad(targetID: string): void {
|
private _onMediaLoad(targetID: string): void {
|
||||||
if (this._erroredTargets.delete(targetID)) {
|
let changed = this._cancelPendingTimer(targetID);
|
||||||
|
if (this._erroredTargets.get(targetID)?.reason === 'not_loading') {
|
||||||
|
this._erroredTargets.delete(targetID);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
this._onChange?.();
|
this._onChange?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,6 +198,7 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
|
|
||||||
public getNotification(): Notification {
|
public getNotification(): Notification {
|
||||||
const targets = new Map(this._erroredTargets);
|
const targets = new Map(this._erroredTargets);
|
||||||
|
|
||||||
// The pending-load timer's target is a slow initial load that has not yet
|
// The pending-load timer's target is a slow initial load that has not yet
|
||||||
// errored. Gate on the timer still running: once it is stopped (a hard error
|
// errored. Gate on the timer still running: once it is stopped (a hard error
|
||||||
// on another target took over, or the view moved on), _timerTargetID lingers
|
// on another target took over, or the view moved on), _timerTargetID lingers
|
||||||
@@ -258,10 +274,12 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
|
|
||||||
public retry(): boolean {
|
public retry(): boolean {
|
||||||
// Build the set of targets to retry: all errored targets plus the
|
// Build the set of targets to retry: all errored targets plus the
|
||||||
// target the pending timer was tracking (so a user-initiated retry
|
// target the pending timer is tracking (so a user-initiated retry
|
||||||
// works even before the timeout fires).
|
// works even before the timeout fires). A stopped timer leaves
|
||||||
|
// _timerTargetID behind, so gate on it still running: that target may
|
||||||
|
// since have loaded.
|
||||||
const retryTargets = new Set(this._erroredTargets.keys());
|
const retryTargets = new Set(this._erroredTargets.keys());
|
||||||
if (this._timerTargetID) {
|
if (this._timerTargetID && this._timer.isRunning()) {
|
||||||
retryTargets.add(this._timerTargetID);
|
retryTargets.add(this._timerTargetID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +287,8 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bumping a target's mediaEpoch remounts its provider, which is the card's
|
||||||
|
// only way to rebuild a stream from scratch.
|
||||||
const view = this._api.getViewManager().getView();
|
const view = this._api.getViewManager().getView();
|
||||||
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
|
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
|
||||||
for (const id of retryTargets) {
|
for (const id of retryTargets) {
|
||||||
@@ -276,11 +296,11 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Intentionally keep _issueActive, _erroredTargets, and the pending
|
// Intentionally keep _issueActive, _erroredTargets, and the pending
|
||||||
// timer in place. The issue stays visible while the provider
|
// timer in place. The issue stays visible while the provider re-attempts
|
||||||
// re-attempts loading underneath. If the retry succeeds, the fresh media
|
// loading underneath. If the retry succeeds, the fresh load clears a
|
||||||
// load clears everything (_onMediaLoad drops the errored target). If it
|
// not-loading error and the rebuilt provider's liveness observation
|
||||||
// fails silently (e.g. bogus stream name), the error stays visible
|
// resolves a stream error. If it fails silently (e.g. bogus stream name),
|
||||||
// immediately -- no new 10s grace period.
|
// the error stays visible immediately -- no new 10s grace period.
|
||||||
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
|
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -314,6 +334,17 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
// Private helpers.
|
// Private helpers.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
|
// Stop waiting on a target's load, if it is the one being waited on. Returns
|
||||||
|
// whether it was.
|
||||||
|
private _cancelPendingTimer(targetID: string): boolean {
|
||||||
|
if (this._timerTargetID !== targetID) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._timer.stop();
|
||||||
|
this._timerTargetID = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Media not yet loaded and no known error: start (or keep) a timeout to catch
|
// Media not yet loaded and no known error: start (or keep) a timeout to catch
|
||||||
// a slow or failed initial load. No targetID means no provider is rendering
|
// a slow or failed initial load. No targetID means no provider is rendering
|
||||||
// media (e.g. the viewer shows "No media to display"), so there's nothing to
|
// media (e.g. the viewer shows "No media to display"), so there's nothing to
|
||||||
@@ -337,7 +368,7 @@ export class MediaUnavailableIssue implements Issue {
|
|||||||
this._timerTargetID = targetID;
|
this._timerTargetID = targetID;
|
||||||
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
||||||
// Record the error on timeout so retry() knows which epoch to bump.
|
// Record the error on timeout so retry() knows which epoch to bump.
|
||||||
this._erroredTargets.set(targetID, { reason: 'not_loading' });
|
this.trigger({ targetID, reason: 'not_loading' });
|
||||||
this._activate();
|
this._activate();
|
||||||
this._onChange?.();
|
this._onChange?.();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IssueTriggerContext } from 'issue';
|
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||||
|
|
||||||
import { summarizeNotification } from '../../components-lib/notification/summarize';
|
import { summarizeNotification } from '../../components-lib/notification/summarize';
|
||||||
import type { ConditionState } from '../../condition-trigger/conditions/types';
|
import type { ConditionState } from '../../condition-trigger/conditions/types';
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
IssueKey,
|
IssueKey,
|
||||||
IssuePresence,
|
IssuePresence,
|
||||||
IssueReadOnlyState,
|
IssueReadOnlyState,
|
||||||
|
IssueResolveContextKey,
|
||||||
IssueTriggerContextKey,
|
IssueTriggerContextKey,
|
||||||
KeyedIssueDescription,
|
KeyedIssueDescription,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -28,7 +29,8 @@ export class IssueStateManager implements IssueReadOnlyState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Detection -- static (one-shot on init) and dynamic (on every state change).
|
// Detection -- static (one-shot on init), dynamic (on every state change) and
|
||||||
|
// explicit (triggered or resolved by a component).
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public async detectStatic(hass: HomeAssistant): Promise<void> {
|
public async detectStatic(hass: HomeAssistant): Promise<void> {
|
||||||
@@ -56,6 +58,18 @@ export class IssueStateManager implements IssueReadOnlyState {
|
|||||||
this._logIfNew(issue);
|
this._logIfNew(issue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public resolve<K extends IssueResolveContextKey>(
|
||||||
|
key: K,
|
||||||
|
context: IssueResolveContext[K],
|
||||||
|
): void {
|
||||||
|
const issue = this._issues.get(key);
|
||||||
|
if (!issue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
issue.resolve?.(context);
|
||||||
|
this._logIfNew(issue);
|
||||||
|
}
|
||||||
|
|
||||||
public detectDynamic(context: ConditionState): void {
|
public detectDynamic(context: ConditionState): void {
|
||||||
for (const issue of this._issues.values()) {
|
for (const issue of this._issues.values()) {
|
||||||
issue.detectDynamic?.(context);
|
issue.detectDynamic?.(context);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IssueTriggerContext } from 'issue';
|
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||||
|
|
||||||
import type { ConditionState } from '../../condition-trigger/conditions/types';
|
import type { ConditionState } from '../../condition-trigger/conditions/types';
|
||||||
import type { Notification } from '../../config/schema/actions/types';
|
import type { Notification } from '../../config/schema/actions/types';
|
||||||
@@ -47,6 +47,11 @@ export type IssueTriggerEventData = {
|
|||||||
[K in IssueTriggerContextKey]: { key: K } & IssueTriggerContext[K];
|
[K in IssueTriggerContextKey]: { key: K } & IssueTriggerContext[K];
|
||||||
}[IssueTriggerContextKey];
|
}[IssueTriggerContextKey];
|
||||||
|
|
||||||
|
export type IssueResolveContextKey = keyof IssueResolveContext;
|
||||||
|
export type IssueResolveEventData = {
|
||||||
|
[K in IssueResolveContextKey]: { key: K } & IssueResolveContext[K];
|
||||||
|
}[IssueResolveContextKey];
|
||||||
|
|
||||||
export interface Issue {
|
export interface Issue {
|
||||||
readonly key: IssueKey;
|
readonly key: IssueKey;
|
||||||
|
|
||||||
@@ -59,6 +64,11 @@ export interface Issue {
|
|||||||
// Explicitly trigger this issue with key-specific context.
|
// Explicitly trigger this issue with key-specific context.
|
||||||
trigger?(context: IssueTriggerContext[IssueTriggerContextKey]): void;
|
trigger?(context: IssueTriggerContext[IssueTriggerContextKey]): void;
|
||||||
|
|
||||||
|
// The inverse of `trigger`: the context names what recovered, and only that
|
||||||
|
// part of the issue's state is dropped. Contrast `reset`, which discards
|
||||||
|
// everything the issue is holding.
|
||||||
|
resolve?(context: IssueResolveContext[IssueResolveContextKey]): void;
|
||||||
|
|
||||||
hasIssue(): boolean;
|
hasIssue(): boolean;
|
||||||
getIssue(): IssueDescription | null;
|
getIssue(): IssueDescription | null;
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ interface ActiveEntry {
|
|||||||
// A change delivered to subscribers: a target's active media loading or
|
// A change delivered to subscribers: a target's active media loading or
|
||||||
// unloading, or the selected target changing.
|
// unloading, or the selected target changing.
|
||||||
export type MediaLoadedInfoChange =
|
export type MediaLoadedInfoChange =
|
||||||
// A target's media (re)loaded. `cached` marks a replay (a reconnect
|
// A target's media (re)loaded, or a reconnect re-dispatched its last load.
|
||||||
// re-dispatch, not an actual reload) rather than a genuine load.
|
| { type: 'load'; targetID: string; info: MediaLoadedInfo }
|
||||||
| { type: 'load'; targetID: string; info: MediaLoadedInfo; cached: boolean }
|
|
||||||
|
|
||||||
// A target's media was retired.
|
// A target's media was retired.
|
||||||
| { type: 'unload'; targetID: string }
|
| { type: 'unload'; targetID: string }
|
||||||
@@ -68,11 +67,7 @@ export class MediaLoadedInfoManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public set(
|
public set(mediaLoadedInfo: MediaLoadedInfo, owner: MediaLoadedInfoOwner): void {
|
||||||
mediaLoadedInfo: MediaLoadedInfo,
|
|
||||||
owner: MediaLoadedInfoOwner,
|
|
||||||
cached?: boolean,
|
|
||||||
): void {
|
|
||||||
if (!isValidMediaLoadedInfo(mediaLoadedInfo) || !mediaLoadedInfo.targetID) {
|
if (!isValidMediaLoadedInfo(mediaLoadedInfo) || !mediaLoadedInfo.targetID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -93,12 +88,7 @@ export class MediaLoadedInfoManager {
|
|||||||
|
|
||||||
// Notify for every target, not just the selected one (e.g. a background
|
// Notify for every target, not just the selected one (e.g. a background
|
||||||
// grid camera).
|
// grid camera).
|
||||||
this._notify({
|
this._notify({ type: 'load', targetID, info: mediaLoadedInfo });
|
||||||
type: 'load',
|
|
||||||
targetID,
|
|
||||||
info: mediaLoadedInfo,
|
|
||||||
cached: cached ?? false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public setSelected(targetID: string | null): void {
|
public setSelected(targetID: string | null): void {
|
||||||
@@ -132,7 +122,7 @@ export class MediaLoadedInfoManager {
|
|||||||
if (!(owner instanceof HTMLElement) || !targetID) {
|
if (!(owner instanceof HTMLElement) || !targetID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.set(ev.detail.info, owner, ev.detail.cached);
|
this.set(ev.detail.info, owner);
|
||||||
onAbort(ev.detail.signal, () => this._clearTarget(targetID, owner));
|
onAbort(ev.detail.signal, () => this._clearTarget(targetID, owner));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -16,8 +16,13 @@ import 'web-dialog';
|
|||||||
import { actionHandler } from './action-handler-directive.js';
|
import { actionHandler } from './action-handler-directive.js';
|
||||||
import { ConfigManager } from './card-controller/config/config-manager';
|
import { ConfigManager } from './card-controller/config/config-manager';
|
||||||
import { CardController } from './card-controller/controller';
|
import { CardController } from './card-controller/controller';
|
||||||
import type { IssueKey, IssueTriggerEventData } from './card-controller/issues/types.js';
|
import type {
|
||||||
|
IssueKey,
|
||||||
|
IssueResolveEventData,
|
||||||
|
IssueTriggerEventData,
|
||||||
|
} from './card-controller/issues/types.js';
|
||||||
import { resolveAutoHideState, type AutoHideState } from './components-lib/auto-hide.js';
|
import { resolveAutoHideState, type AutoHideState } from './components-lib/auto-hide.js';
|
||||||
|
import type { MicrophoneError } from './components-lib/live/utils/dispatch-microphone-error.js';
|
||||||
import { MenuButtonController } from './components-lib/menu-button-controller';
|
import { MenuButtonController } from './components-lib/menu-button-controller';
|
||||||
|
|
||||||
import './components/effects/effects';
|
import './components/effects/effects';
|
||||||
@@ -427,6 +432,16 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
detail: { key, ...context },
|
detail: { key, ...context },
|
||||||
}: CustomEvent<IssueTriggerEventData>) =>
|
}: CustomEvent<IssueTriggerEventData>) =>
|
||||||
this._controller.getIssueManager().trigger(key, context)}
|
this._controller.getIssueManager().trigger(key, context)}
|
||||||
|
@advanced-camera-card:issue:resolve=${({
|
||||||
|
detail: { key, ...context },
|
||||||
|
}: CustomEvent<IssueResolveEventData>) =>
|
||||||
|
this._controller.getIssueManager().resolve(key, context)}
|
||||||
|
@advanced-camera-card:microphone:error=${({
|
||||||
|
detail,
|
||||||
|
}: CustomEvent<MicrophoneError>) =>
|
||||||
|
this._controller
|
||||||
|
.getCallManager()
|
||||||
|
.reportCallMicrophoneError(detail.targetID, detail.description)}
|
||||||
@advanced-camera-card:media:loaded=${(
|
@advanced-camera-card:media:loaded=${(
|
||||||
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
ev: CustomEvent<MediaLoadedInfoEventDetail>,
|
||||||
) => this._controller.getMediaLoadedInfoManager().handleLoadEvent(ev)}
|
) => this._controller.getMediaLoadedInfoManager().handleLoadEvent(ev)}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export class EntityAvailabilityDetector implements LivenessDetector {
|
|||||||
|
|
||||||
public subscribe(): void {
|
public subscribe(): void {
|
||||||
this._active = true;
|
this._active = true;
|
||||||
this._watch();
|
this._subscribeOrUnsubscribeFromCameraEntity();
|
||||||
|
this._check();
|
||||||
}
|
}
|
||||||
|
|
||||||
public unsubscribe(): void {
|
public unsubscribe(): void {
|
||||||
@@ -56,33 +57,35 @@ export class EntityAvailabilityDetector implements LivenessDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
// Re-point the subscription at the (possibly different) camera entity and
|
|
||||||
// start fresh.
|
|
||||||
this._verdict = { state: 'unknown' };
|
this._verdict = { state: 'unknown' };
|
||||||
this._timer.stop();
|
this._timer.stop();
|
||||||
this._watch();
|
|
||||||
|
// A reset is not a stop: watching continues, only what was learned is
|
||||||
|
// forgotten. `getCameraEntity` may now name a different entity, so re-point
|
||||||
|
// the subscription and read that entity now.
|
||||||
|
this._subscribeOrUnsubscribeFromCameraEntity();
|
||||||
|
this._check();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getVerdict(): LivenessVerdict {
|
public getVerdict(): LivenessVerdict {
|
||||||
return this._verdict;
|
return this._verdict;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Point the subscription at the current camera entity and re-check its state.
|
private _subscribeOrUnsubscribeFromCameraEntity(): void {
|
||||||
private _watch(): void {
|
|
||||||
if (!this._active) {
|
if (!this._active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stateWatcher = this._config.getStateWatcher();
|
const stateWatcher = this._config.getStateWatcher();
|
||||||
const entityID = this._config.getCameraEntity();
|
const entityID = this._config.getCameraEntity();
|
||||||
if (entityID !== this._watchedEntity) {
|
if (entityID === this._watchedEntity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
stateWatcher?.unsubscribe(this._onEntityStateChange);
|
stateWatcher?.unsubscribe(this._onEntityStateChange);
|
||||||
this._watchedEntity = entityID;
|
this._watchedEntity = entityID;
|
||||||
if (entityID) {
|
if (entityID) {
|
||||||
stateWatcher?.subscribe(this._onEntityStateChange, [entityID]);
|
stateWatcher?.subscribe(this._onEntityStateChange, [entityID]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._check();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onEntityStateChange = (difference: HassStateDifference): void =>
|
private _onEntityStateChange = (difference: HassStateDifference): void =>
|
||||||
// Trap: Evaluate the state carried by the event, not `getHASS()`: the
|
// Trap: Evaluate the state carried by the event, not `getHASS()`: the
|
||||||
@@ -93,6 +96,9 @@ export class EntityAvailabilityDetector implements LivenessDetector {
|
|||||||
this._evaluate(difference.newState.state);
|
this._evaluate(difference.newState.state);
|
||||||
|
|
||||||
private _check(): void {
|
private _check(): void {
|
||||||
|
if (!this._active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const stateObj = this._watchedEntity
|
const stateObj = this._watchedEntity
|
||||||
? this._config.getHASS()?.states[this._watchedEntity]
|
? this._config.getHASS()?.states[this._watchedEntity]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ export class MediaPlayerLivenessDetector implements LivenessDetector {
|
|||||||
this._watchedPlayer = target;
|
this._watchedPlayer = target;
|
||||||
|
|
||||||
if (player?.subscribeLiveness) {
|
if (player?.subscribeLiveness) {
|
||||||
|
// A `live` verdict left over from the last watch means media was flowing
|
||||||
|
// then, not now. Drop it, so `live` always means something seen during
|
||||||
|
// this watch. The `not_live` hold below is kept on purpose.
|
||||||
|
if (this._verdict.state === 'live') {
|
||||||
|
this._setVerdict({ state: 'unknown' });
|
||||||
|
}
|
||||||
|
|
||||||
// Start (or resume) watching; the verdict stays `unknown` until a real
|
// Start (or resume) watching; the verdict stays `unknown` until a real
|
||||||
// frame or a stall is observed.
|
// frame or a stall is observed.
|
||||||
this._unsubscribeLiveness = player.subscribeLiveness((isLive) =>
|
this._unsubscribeLiveness = player.subscribeLiveness((isLive) =>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class ProviderErrorDetector implements LivenessDetector {
|
|||||||
state: 'not_live',
|
state: 'not_live',
|
||||||
authority: 'hard',
|
authority: 'hard',
|
||||||
reason: ev.detail.reason ?? 'playback_error',
|
reason: ev.detail.reason ?? 'playback_error',
|
||||||
description: ev.detail.detail,
|
description: ev.detail.description,
|
||||||
};
|
};
|
||||||
this._onChange();
|
this._onChange();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|||||||
import type { Camera } from '../../../camera-manager/camera';
|
import type { Camera } from '../../../camera-manager/camera';
|
||||||
import type { StateWatcherSubscriptionInterface } from '../../../card-controller/hass/state-watcher';
|
import type { StateWatcherSubscriptionInterface } from '../../../card-controller/hass/state-watcher';
|
||||||
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable';
|
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable';
|
||||||
import type { IssueTriggerEventData } from '../../../card-controller/issues/types';
|
import type {
|
||||||
|
IssueResolveEventData,
|
||||||
|
IssueTriggerEventData,
|
||||||
|
} from '../../../card-controller/issues/types';
|
||||||
import type { CameraConfig } from '../../../config/schema/cameras';
|
import type { CameraConfig } from '../../../config/schema/cameras';
|
||||||
import type { HomeAssistant } from '../../../ha/types';
|
import type { HomeAssistant } from '../../../ha/types';
|
||||||
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
|
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
|
||||||
@@ -96,6 +99,7 @@ export class StreamLivenessController implements ReactiveController {
|
|||||||
private _host: ReactiveControllerHost & HTMLElement;
|
private _host: ReactiveControllerHost & HTMLElement;
|
||||||
private _config: StreamLivenessControllerConfig;
|
private _config: StreamLivenessControllerConfig;
|
||||||
private _detectors: LivenessDetector[];
|
private _detectors: LivenessDetector[];
|
||||||
|
private _resetting = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
host: ReactiveControllerHost & HTMLElement,
|
host: ReactiveControllerHost & HTMLElement,
|
||||||
@@ -150,7 +154,20 @@ export class StreamLivenessController implements ReactiveController {
|
|||||||
|
|
||||||
// Discard detector state on a stream change (e.g. a stream switch).
|
// Discard detector state on a stream change (e.g. a stream switch).
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
|
// The detectors are cleared one at a time, and clearing one could cause it
|
||||||
|
// report a change (e.g. an entity might be marked as having an unknown
|
||||||
|
// state). Part-way through, some are cleared and some are not, so what they
|
||||||
|
// add up to is meaningless and this controller needs to not take action
|
||||||
|
// during this time. Ignore anything detectors say until the reset is
|
||||||
|
// complete.
|
||||||
|
this._resetting = true;
|
||||||
|
try {
|
||||||
this._detectors.forEach((detector) => detector.reset?.());
|
this._detectors.forEach((detector) => detector.reset?.());
|
||||||
|
} finally {
|
||||||
|
this._resetting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._onDetectorChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reduce the detectors to a single verdict. Direct evidence from the media
|
// Reduce the detectors to a single verdict. Direct evidence from the media
|
||||||
@@ -179,9 +196,15 @@ export class StreamLivenessController implements ReactiveController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _onDetectorChange(): void {
|
private _onDetectorChange(): void {
|
||||||
|
if (this._resetting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const verdict = this._getVerdict();
|
const verdict = this._getVerdict();
|
||||||
if (verdict.state === 'not_live') {
|
if (verdict.state === 'not_live') {
|
||||||
this._triggerMediaUnavailableIssue(verdict.reason, verdict.description);
|
this._triggerMediaUnavailableIssue(verdict.reason, verdict.description);
|
||||||
|
} else if (verdict.state === 'live') {
|
||||||
|
this._resolveMediaUnavailableIssue();
|
||||||
}
|
}
|
||||||
this._host.requestUpdate();
|
this._host.requestUpdate();
|
||||||
}
|
}
|
||||||
@@ -203,4 +226,15 @@ export class StreamLivenessController implements ReactiveController {
|
|||||||
description,
|
description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _resolveMediaUnavailableIssue(): void {
|
||||||
|
const targetID = this._config.getTargetID();
|
||||||
|
if (!targetID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fireAdvancedCameraCardEvent<IssueResolveEventData>(this._host, 'issue:resolve', {
|
||||||
|
key: 'media_unavailable',
|
||||||
|
targetID,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import { OffscreenImage } from './offscreen-image';
|
|||||||
import type { ImageSurface } from './session-controller';
|
import type { ImageSurface } from './session-controller';
|
||||||
|
|
||||||
// Liveness: while frames are expected, a gap beyond the window is a stall.
|
// Liveness: while frames are expected, a gap beyond the window is a stall.
|
||||||
// Omitted -> the surface reports no liveness. `stallWindowSeconds` defaults to
|
// Omitted -> the surface reports no liveness. `getStallAfterSeconds` defaults to
|
||||||
// the standard frame-stall window.
|
// the standard frame-stall window.
|
||||||
interface ImageSurfaceLivenessOptions {
|
interface ImageSurfaceLivenessOptions {
|
||||||
isFrameExpected: () => boolean;
|
isFrameExpected: () => boolean;
|
||||||
stallWindowSeconds?: number;
|
getStallAfterSeconds?: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImageSurfaceOptions {
|
interface ImageSurfaceOptions {
|
||||||
|
|||||||
@@ -96,7 +96,12 @@ interface Go2RTCSessionCallbacks {
|
|||||||
// stream; a higher level should take over (e.g. the card's media-load retry).
|
// stream; a higher level should take over (e.g. the card's media-load retry).
|
||||||
// The reason is the most recent source failure, or null when there is none
|
// The reason is the most recent source failure, or null when there is none
|
||||||
// (e.g. the socket dropped with no source having reported a cause).
|
// (e.g. the socket dropped with no source having reported a cause).
|
||||||
errorCallback: (reason: StreamSourceFailureReason | null) => void;
|
streamErrorCallback: (reason: StreamSourceFailureReason | null) => void;
|
||||||
|
|
||||||
|
// The outbound microphone could not be used, so the camera cannot be talked
|
||||||
|
// to. The inbound video is unaffected. `error` is what the source knows about
|
||||||
|
// the failure, when it knows anything.
|
||||||
|
microphoneErrorCallback: (error?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Injectable platform and factory seams for tests. Every field defaults to
|
// Injectable platform and factory seams for tests. Every field defaults to
|
||||||
@@ -184,7 +189,7 @@ export class Go2RTCSessionController {
|
|||||||
// The most recent source failure on this connection, handed to the error
|
// The most recent source failure on this connection, handed to the error
|
||||||
// callback when the session finally gives up so the card can name the cause.
|
// callback when the session finally gives up so the card can name the cause.
|
||||||
// Null before any failure and after a healthy commit.
|
// Null before any failure and after a healthy commit.
|
||||||
private _lastFailureReason: StreamSourceFailureReason | null = null;
|
private _lastStreamFailureReason: StreamSourceFailureReason | null = null;
|
||||||
|
|
||||||
private _retryTimer = new RetryTimer(RECONNECT_INTERVAL_SECONDS);
|
private _retryTimer = new RetryTimer(RECONNECT_INTERVAL_SECONDS);
|
||||||
|
|
||||||
@@ -227,7 +232,7 @@ export class Go2RTCSessionController {
|
|||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this._retryTimer.reset();
|
this._retryTimer.reset();
|
||||||
this._lastFailureReason = null;
|
this._lastStreamFailureReason = null;
|
||||||
|
|
||||||
this._teardownLanes();
|
this._teardownLanes();
|
||||||
this._channel?.close();
|
this._channel?.close();
|
||||||
@@ -359,7 +364,7 @@ export class Go2RTCSessionController {
|
|||||||
},
|
},
|
||||||
failedCallback: (reason: StreamSourceFailureReason) => {
|
failedCallback: (reason: StreamSourceFailureReason) => {
|
||||||
if (source) {
|
if (source) {
|
||||||
this._lastFailureReason = reason;
|
this._lastStreamFailureReason = reason;
|
||||||
this._logSourceFailure('binary', reason, mode);
|
this._logSourceFailure('binary', reason, mode);
|
||||||
this._handleBinaryFailed(context, source);
|
this._handleBinaryFailed(context, source);
|
||||||
}
|
}
|
||||||
@@ -448,7 +453,7 @@ export class Go2RTCSessionController {
|
|||||||
},
|
},
|
||||||
failedCallback: (reason) => {
|
failedCallback: (reason) => {
|
||||||
if (source) {
|
if (source) {
|
||||||
this._lastFailureReason = reason;
|
this._lastStreamFailureReason = reason;
|
||||||
this._logSourceFailure('webrtc', reason);
|
this._logSourceFailure('webrtc', reason);
|
||||||
this._handleWebRTCFailed(context, source);
|
this._handleWebRTCFailed(context, source);
|
||||||
}
|
}
|
||||||
@@ -458,6 +463,7 @@ export class Go2RTCSessionController {
|
|||||||
|
|
||||||
source = (this._options?.createWebRTCSource ?? createWebRTCSource)(sourceContext, {
|
source = (this._options?.createWebRTCSource ?? createWebRTCSource)(sourceContext, {
|
||||||
microphoneStream: this._microphoneStream,
|
microphoneStream: this._microphoneStream,
|
||||||
|
microphoneErrorCallback: (error) => this._callbacks.microphoneErrorCallback(error),
|
||||||
createPeerConnection: this._options?.createPeerConnection,
|
createPeerConnection: this._options?.createPeerConnection,
|
||||||
createMediaStream: this._options?.createMediaStream,
|
createMediaStream: this._options?.createMediaStream,
|
||||||
});
|
});
|
||||||
@@ -628,7 +634,7 @@ export class Go2RTCSessionController {
|
|||||||
|
|
||||||
private _reconnectOrEscalateError(context: ConnectionContext): void {
|
private _reconnectOrEscalateError(context: ConnectionContext): void {
|
||||||
if (this._retryTimer.getAttempts() >= RECONNECT_MAX_ATTEMPTS) {
|
if (this._retryTimer.getAttempts() >= RECONNECT_MAX_ATTEMPTS) {
|
||||||
this._callbacks.errorCallback(this._lastFailureReason);
|
this._callbacks.streamErrorCallback(this._lastStreamFailureReason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._retryTimer.schedule(() => this._connectChannel(context.url, context.surfaces));
|
this._retryTimer.schedule(() => this._connectChannel(context.url, context.surfaces));
|
||||||
@@ -660,7 +666,7 @@ export class Go2RTCSessionController {
|
|||||||
this._committedSource = source;
|
this._committedSource = source;
|
||||||
|
|
||||||
this._retryTimer.reset();
|
this._retryTimer.reset();
|
||||||
this._lastFailureReason = null;
|
this._lastStreamFailureReason = null;
|
||||||
|
|
||||||
if (this._committedSurface && this._committedSurface !== surface) {
|
if (this._committedSurface && this._committedSurface !== surface) {
|
||||||
this._resetSurface(context, this._committedSurface);
|
this._resetSurface(context, this._committedSurface);
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export interface CreateWebRTCSourceOptions {
|
|||||||
createPeerConnection?: PeerConnectionFactory;
|
createPeerConnection?: PeerConnectionFactory;
|
||||||
createMediaStream?: MediaStreamFactory;
|
createMediaStream?: MediaStreamFactory;
|
||||||
microphoneStream?: MediaStream | null;
|
microphoneStream?: MediaStream | null;
|
||||||
|
microphoneErrorCallback?: (error?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebRTCSourceFactory = (
|
export type WebRTCSourceFactory = (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
UnsubscribeCallback,
|
UnsubscribeCallback,
|
||||||
} from '../../../../../types';
|
} from '../../../../../types';
|
||||||
import { has2WayAudio, hasAudio } from '../../../../../utils/audio';
|
import { has2WayAudio, hasAudio } from '../../../../../utils/audio';
|
||||||
|
import { isRecord } from '../../../../../utils/basic';
|
||||||
import { Timer } from '../../../../../utils/timer';
|
import { Timer } from '../../../../../utils/timer';
|
||||||
import {
|
import {
|
||||||
createBrowserPeerConnection,
|
createBrowserPeerConnection,
|
||||||
@@ -38,10 +39,32 @@ const WEBRTC_CONNECT_TIMEOUT_SECONDS = 5;
|
|||||||
|
|
||||||
export type MediaStreamFactory = (tracks: MediaStreamTrack[]) => MediaStream;
|
export type MediaStreamFactory = (tracks: MediaStreamTrack[]) => MediaStream;
|
||||||
|
|
||||||
|
// What a thrown value has to say for itself, preferring the browser's sentence
|
||||||
|
// ("The peer connection is closed") over the bare type name
|
||||||
|
// ("InvalidStateError"), which means nothing to the person reading it.
|
||||||
|
//
|
||||||
|
// DOMException may not inherit from Error, and catch blocks may be handed
|
||||||
|
// anything, so extract details structurally rather than using `instanceof
|
||||||
|
// Error`.
|
||||||
|
const getErrorDescription = (error: unknown): string | null => {
|
||||||
|
if (!isRecord(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const message = typeof error.message === 'string' ? error.message : '';
|
||||||
|
const name = typeof error.name === 'string' ? error.name : '';
|
||||||
|
return message || name || null;
|
||||||
|
};
|
||||||
|
|
||||||
interface WebRTCStreamSourceOptions {
|
interface WebRTCStreamSourceOptions {
|
||||||
createPeerConnection?: PeerConnectionFactory;
|
createPeerConnection?: PeerConnectionFactory;
|
||||||
createMediaStream?: MediaStreamFactory;
|
createMediaStream?: MediaStreamFactory;
|
||||||
microphoneStream?: MediaStream | null;
|
microphoneStream?: MediaStream | null;
|
||||||
|
|
||||||
|
// The outbound microphone track could not be attached. Separate from the
|
||||||
|
// stream-source failure channel: a microphone that cannot attach says nothing
|
||||||
|
// about the inbound video which keeps playing. `error` is what the browser
|
||||||
|
// said went wrong, when it said anything.
|
||||||
|
microphoneErrorCallback?: (error?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebRTCStreamSource implements StreamSource {
|
export class WebRTCStreamSource implements StreamSource {
|
||||||
@@ -52,6 +75,7 @@ export class WebRTCStreamSource implements StreamSource {
|
|||||||
private _createPeerConnection: PeerConnectionFactory;
|
private _createPeerConnection: PeerConnectionFactory;
|
||||||
private _createMediaStream: MediaStreamFactory;
|
private _createMediaStream: MediaStreamFactory;
|
||||||
private _microphoneStream: MediaStream | null;
|
private _microphoneStream: MediaStream | null;
|
||||||
|
private _microphoneErrorCallback: ((error?: string) => void) | null;
|
||||||
|
|
||||||
private _microphoneTransceiver: RTCRtpTransceiver | null = null;
|
private _microphoneTransceiver: RTCRtpTransceiver | null = null;
|
||||||
|
|
||||||
@@ -75,6 +99,7 @@ export class WebRTCStreamSource implements StreamSource {
|
|||||||
options?.createMediaStream ?? ((tracks) => new MediaStream(tracks));
|
options?.createMediaStream ?? ((tracks) => new MediaStream(tracks));
|
||||||
|
|
||||||
this._microphoneStream = options?.microphoneStream ?? null;
|
this._microphoneStream = options?.microphoneStream ?? null;
|
||||||
|
this._microphoneErrorCallback = options?.microphoneErrorCallback ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
@@ -183,9 +208,7 @@ export class WebRTCStreamSource implements StreamSource {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swap the outbound microphone track without renegotiating. Guards against a
|
// Swap the outbound microphone track without renegotiating.
|
||||||
// late rejection from a superseded call (a newer stream, or teardown)
|
|
||||||
// bringing a retired connection back or overwriting a fresher request.
|
|
||||||
public async setMicrophoneStream(stream: MediaStream | null): Promise<void> {
|
public async setMicrophoneStream(stream: MediaStream | null): Promise<void> {
|
||||||
if (this._microphoneStream === stream) {
|
if (this._microphoneStream === stream) {
|
||||||
return;
|
return;
|
||||||
@@ -199,17 +222,27 @@ export class WebRTCStreamSource implements StreamSource {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the awaited microphone request is still the one in effect: a newer
|
||||||
|
// stream, or teardown, retires it, and reporting a retired outcome would
|
||||||
|
// describe something that is no longer being attempted.
|
||||||
|
const isCurrentRequest = (
|
||||||
|
transceiver: RTCRtpTransceiver,
|
||||||
|
stream: MediaStream | null,
|
||||||
|
): boolean =>
|
||||||
|
transceiver === this._microphoneTransceiver &&
|
||||||
|
this._microphoneStream === stream &&
|
||||||
|
this._pc !== null;
|
||||||
|
|
||||||
// A microphone stream carries a single audio track; null detaches the sender.
|
// A microphone stream carries a single audio track; null detaches the sender.
|
||||||
const desiredTrack = stream?.getAudioTracks()[0] ?? null;
|
const desiredTrack = stream?.getAudioTracks()[0] ?? null;
|
||||||
try {
|
try {
|
||||||
await transceiver.sender.replaceTrack(desiredTrack);
|
await transceiver.sender.replaceTrack(desiredTrack);
|
||||||
} catch {
|
} catch (error) {
|
||||||
const stillCurrent =
|
// Only a failed attach is reported. A failed detach leaves nothing for
|
||||||
transceiver === this._microphoneTransceiver &&
|
// the user to act on: the track stops being transmitted when the peer
|
||||||
this._microphoneStream === stream &&
|
// connection closes.
|
||||||
this._pc !== null;
|
if (desiredTrack && isCurrentRequest(transceiver, stream)) {
|
||||||
if (stillCurrent) {
|
this._microphoneErrorCallback?.(getErrorDescription(error) ?? undefined);
|
||||||
this._context.callbacks.failedCallback('two_way_audio_error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export interface StreamProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type StreamSourceFailureReason =
|
export type StreamSourceFailureReason =
|
||||||
| 'two_way_audio_error'
|
|
||||||
| 'buffer_overflow'
|
| 'buffer_overflow'
|
||||||
| 'connect_timeout'
|
| 'connect_timeout'
|
||||||
| 'media_error'
|
| 'media_error'
|
||||||
|
|||||||
+5
-6
@@ -4,13 +4,12 @@ import type { StreamSourceFailureReason } from '../types';
|
|||||||
// The card's media-unavailable causes are user-facing; a source's failure
|
// The card's media-unavailable causes are user-facing; a source's failure
|
||||||
// reasons are technical. Only a media error and a buffer overflow have no
|
// reasons are technical. Only a media error and a buffer overflow have no
|
||||||
// distinct user story, so they map to a generic playback error; the rest each
|
// distinct user story, so they map to a generic playback error; the rest each
|
||||||
// keep a meaningful cause -- a server rejection, an unsupported stream, a failed
|
// keep a meaningful cause -- a server rejection, an unsupported stream, or a
|
||||||
// two-way-audio call, or a timeout that means the stream never got going.
|
// timeout that means the stream never got going.
|
||||||
const FAILURE_TO_ISSUE_REASON: Record<
|
const STREAM_FAILURE_TO_ISSUE_REASON: Record<
|
||||||
StreamSourceFailureReason,
|
StreamSourceFailureReason,
|
||||||
MediaUnavailableIssueReason
|
MediaUnavailableIssueReason
|
||||||
> = {
|
> = {
|
||||||
two_way_audio_error: 'two_way_audio_error',
|
|
||||||
buffer_overflow: 'playback_error',
|
buffer_overflow: 'playback_error',
|
||||||
connect_timeout: 'not_loading',
|
connect_timeout: 'not_loading',
|
||||||
media_error: 'playback_error',
|
media_error: 'playback_error',
|
||||||
@@ -21,7 +20,7 @@ const FAILURE_TO_ISSUE_REASON: Record<
|
|||||||
|
|
||||||
// A null reason is a connection-level failure with no source detail (e.g. the
|
// A null reason is a connection-level failure with no source detail (e.g. the
|
||||||
// socket dropped), which reads as a generic playback error.
|
// socket dropped), which reads as a generic playback error.
|
||||||
export const mapFailureReasonToIssueReason = (
|
export const mapStreamFailureReasonToIssueReason = (
|
||||||
reason: StreamSourceFailureReason | null,
|
reason: StreamSourceFailureReason | null,
|
||||||
): MediaUnavailableIssueReason =>
|
): MediaUnavailableIssueReason =>
|
||||||
reason === null ? 'playback_error' : FAILURE_TO_ISSUE_REASON[reason];
|
reason === null ? 'playback_error' : STREAM_FAILURE_TO_ISSUE_REASON[reason];
|
||||||
@@ -10,7 +10,7 @@ export interface LiveError {
|
|||||||
|
|
||||||
// Free text naming the specific failure (e.g. "Failed to start WebRTC stream:
|
// Free text naming the specific failure (e.g. "Failed to start WebRTC stream:
|
||||||
// ..."). Absent when the provider has none.
|
// ..."). Absent when the provider has none.
|
||||||
detail?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
|
||||||
|
|
||||||
|
// What a provider knows about its own microphone related failure. Stream
|
||||||
|
// otherwise not impacted (contrast with `live:error`: which marks the whole
|
||||||
|
// stream not live).
|
||||||
|
export interface MicrophoneError {
|
||||||
|
// The base camera the provider is rendering. Carried because this event is
|
||||||
|
// handled once for the whole card, unlike `live:error` which is caught and
|
||||||
|
// stopped on the camera's own provider wrapper and so needs no camera named.
|
||||||
|
targetID: string;
|
||||||
|
|
||||||
|
// Free text naming the specific failure, when the provider has one.
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementEventMap {
|
||||||
|
'advanced-camera-card:microphone:error': CustomEvent<MicrophoneError>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchMicrophoneErrorEvent(
|
||||||
|
element: EventTarget,
|
||||||
|
error: MicrophoneError,
|
||||||
|
): void {
|
||||||
|
fireAdvancedCameraCardEvent<MicrophoneError>(element, 'microphone:error', error);
|
||||||
|
}
|
||||||
@@ -38,6 +38,13 @@ type TargetedMediaLoadedInfo = MediaLoadedInfo & { targetID: string };
|
|||||||
* without needing the underlying media to re-fire a load (e.g., HaHlsPlayer
|
* without needing the underlying media to re-fire a load (e.g., HaHlsPlayer
|
||||||
* keeps the same `<video>`).
|
* keeps the same `<video>`).
|
||||||
*
|
*
|
||||||
|
* That replay assumes the media outlived the detach. A host that instead
|
||||||
|
* destroys its media -- whenever it does so, on disconnect or otherwise -- must
|
||||||
|
* say so with `clear()`, or the replay would describe a player that no longer
|
||||||
|
* exists and consumers will be told media is loaded when none is. `set` and
|
||||||
|
* `clear` are the two halves of the same obligation: report what is really
|
||||||
|
* there.
|
||||||
|
*
|
||||||
* Aggregator parents (e.g., `ha-camera-stream`) sit on the bubble path; they
|
* Aggregator parents (e.g., `ha-camera-stream`) sit on the bubble path; they
|
||||||
* can `stopPropagation` on inner-leaf events and dispatch their own via their
|
* can `stopPropagation` on inner-leaf events and dispatch their own via their
|
||||||
* own source controller, so consumers above the boundary only see the
|
* own source controller, so consumers above the boundary only see the
|
||||||
@@ -68,9 +75,9 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
|
|||||||
|
|
||||||
public hostConnected(): void {
|
public hostConnected(): void {
|
||||||
// Two early-returns:
|
// Two early-returns:
|
||||||
// - `!_lastSet`: nothing to replay -- either the host has never seen a
|
// - `!_lastSet`: nothing to replay -- the host has never seen a media
|
||||||
// media load or the cache was discarded as stale on a prior reconnect
|
// load, or destroyed the media it had (`clear`), or the cache was
|
||||||
// (see below).
|
// discarded as stale on a prior reconnect (see below).
|
||||||
// - `_abort` non-null: a dispatch is already live, meaning we're already
|
// - `_abort` non-null: a dispatch is already live, meaning we're already
|
||||||
// registered with consumers. Re-firing would orphan the prior
|
// registered with consumers. Re-firing would orphan the prior
|
||||||
// `AbortController` (no one would ever abort it) and emit a duplicate
|
// `AbortController` (no one would ever abort it) and emit a duplicate
|
||||||
@@ -84,8 +91,7 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
|
|||||||
// flipped while we were disconnected. Replaying the cached info under a
|
// flipped while we were disconnected. Replaying the cached info under a
|
||||||
// stale targetID would misregister with the manager.
|
// stale targetID would misregister with the manager.
|
||||||
if (this._lastSet.targetID === this._config.getTargetID()) {
|
if (this._lastSet.targetID === this._config.getTargetID()) {
|
||||||
// A reconnect replay of the last load, not a fresh media load.
|
this._dispatchLoad(this._lastSet);
|
||||||
this._dispatchLoad(this._lastSet, true);
|
|
||||||
} else {
|
} else {
|
||||||
this._lastSet = null;
|
this._lastSet = null;
|
||||||
}
|
}
|
||||||
@@ -131,16 +137,24 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
|
|||||||
this._dispatchLoad(validated);
|
this._dispatchLoad(validated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The media this source announced is gone. Retires the registration through
|
||||||
|
// the same signal a disconnect uses, and forgets the load so nothing is
|
||||||
|
// replayed on a later reconnect: the host must `set` again when it has media
|
||||||
|
// to announce.
|
||||||
|
public clear(): void {
|
||||||
|
this._unload();
|
||||||
|
this._lastSet = null;
|
||||||
|
}
|
||||||
|
|
||||||
private _unload(): void {
|
private _unload(): void {
|
||||||
this._abort?.abort();
|
this._abort?.abort();
|
||||||
this._abort = null;
|
this._abort = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _dispatchLoad(info: TargetedMediaLoadedInfo, cached?: boolean): void {
|
private _dispatchLoad(info: TargetedMediaLoadedInfo): void {
|
||||||
this._abort = new AbortController();
|
this._abort = new AbortController();
|
||||||
fireAdvancedCameraCardEvent<MediaLoadedInfoEventDetail>(this._host, 'media:loaded', {
|
fireAdvancedCameraCardEvent<MediaLoadedInfoEventDetail>(this._host, 'media:loaded', {
|
||||||
info,
|
info,
|
||||||
cached,
|
|
||||||
signal: this._abort.signal,
|
signal: this._abort.signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ export interface FrameStallWatchdogConfig {
|
|||||||
|
|
||||||
// Seconds without a frame (while playback is expected) before a stall is
|
// Seconds without a frame (while playback is expected) before a stall is
|
||||||
// reported. Defaults to FRAME_STALL_SECONDS; a slow source (e.g. a snapshot
|
// reported. Defaults to FRAME_STALL_SECONDS; a slow source (e.g. a snapshot
|
||||||
// that refreshes every N seconds) needs a window at least as long as N.
|
// that refreshes every N seconds) needs a window at least as long as N. Read
|
||||||
stallAfterSeconds?: number;
|
// each time the timer is armed, so a source whose pace the user can change
|
||||||
|
// gets the window it has now.
|
||||||
|
getStallAfterSeconds?: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +46,6 @@ export interface FrameStallWatchdogConfig {
|
|||||||
*/
|
*/
|
||||||
export class FrameStallWatchdog {
|
export class FrameStallWatchdog {
|
||||||
private _config: FrameStallWatchdogConfig;
|
private _config: FrameStallWatchdogConfig;
|
||||||
private _stallAfterSeconds: number;
|
|
||||||
|
|
||||||
private _timer = new Timer();
|
private _timer = new Timer();
|
||||||
private _callbacks = new Set<LivenessCallback>();
|
private _callbacks = new Set<LivenessCallback>();
|
||||||
@@ -57,7 +58,6 @@ export class FrameStallWatchdog {
|
|||||||
|
|
||||||
constructor(config: FrameStallWatchdogConfig) {
|
constructor(config: FrameStallWatchdogConfig) {
|
||||||
this._config = config;
|
this._config = config;
|
||||||
this._stallAfterSeconds = config.stallAfterSeconds ?? FRAME_STALL_SECONDS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public subscribe(callback: LivenessCallback): UnsubscribeCallback {
|
public subscribe(callback: LivenessCallback): UnsubscribeCallback {
|
||||||
@@ -65,6 +65,11 @@ export class FrameStallWatchdog {
|
|||||||
this._callbacks.add(callback);
|
this._callbacks.add(callback);
|
||||||
if (hadNoSubscribers) {
|
if (hadNoSubscribers) {
|
||||||
this._start();
|
this._start();
|
||||||
|
} else if (this._isLive !== null) {
|
||||||
|
// Someone else is already watching and a frame or a stall has been seen,
|
||||||
|
// so tell the newcomer what that was rather than leaving it waiting for
|
||||||
|
// the next one. Nothing went unwatched in between, so it is not stale.
|
||||||
|
callback(this._isLive);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
@@ -81,7 +86,7 @@ export class FrameStallWatchdog {
|
|||||||
if (!this._sourceActive) {
|
if (!this._sourceActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._timer.start(this._stallAfterSeconds, () => this._onStall());
|
this._startStallTimer();
|
||||||
|
|
||||||
// Notify last: if this recovery notification prompts the final subscriber
|
// Notify last: if this recovery notification prompts the final subscriber
|
||||||
// to unsubscribe, `_stop` then clears the timer just armed instead of
|
// to unsubscribe, `_stop` then clears the timer just armed instead of
|
||||||
@@ -97,10 +102,16 @@ export class FrameStallWatchdog {
|
|||||||
// watching begins is still detected. With no source there is nothing to
|
// watching begins is still detected. With no source there is nothing to
|
||||||
// arm, so nothing is ever reported.
|
// arm, so nothing is ever reported.
|
||||||
if (this._sourceActive) {
|
if (this._sourceActive) {
|
||||||
this._timer.start(this._stallAfterSeconds, () => this._onStall());
|
this._startStallTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _startStallTimer(): void {
|
||||||
|
this._timer.start(this._config.getStallAfterSeconds?.() ?? FRAME_STALL_SECONDS, () =>
|
||||||
|
this._onStall(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private _stop(): void {
|
private _stop(): void {
|
||||||
this._timer.stop();
|
this._timer.stop();
|
||||||
if (this._sourceActive) {
|
if (this._sourceActive) {
|
||||||
@@ -120,7 +131,7 @@ export class FrameStallWatchdog {
|
|||||||
// Legitimately idle (paused / seeking / ended). Re-arm rather than stop: a
|
// Legitimately idle (paused / seeking / ended). Re-arm rather than stop: a
|
||||||
// source that later resumes already frozen delivers no frame to kick the
|
// source that later resumes already frozen delivers no frame to kick the
|
||||||
// timer, so a freeze that only becomes actionable later is still caught.
|
// timer, so a freeze that only becomes actionable later is still caught.
|
||||||
this._timer.start(this._stallAfterSeconds, () => this._onStall());
|
this._startStallTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setLive(isLive: boolean): void {
|
private _setLive(isLive: boolean): void {
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ export interface ImageUpdateControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Liveness for an image stream: each <img> `load` is a frame; a gap longer than
|
// Liveness for an image stream: each <img> `load` is a frame; a gap longer than
|
||||||
// the window while frames are expected is a stall. `stallWindowSeconds`
|
// the window while frames are expected is a stall. `getStallAfterSeconds`
|
||||||
// defaults to the standard frame-stall window (suits a push-fed stream); a
|
// defaults to the standard frame-stall window (suits a push-fed stream); a
|
||||||
// timer-refreshed image may set a different one, should be at least its refresh
|
// timer-refreshed image may set a different one, should be at least its refresh
|
||||||
// interval.
|
// interval.
|
||||||
interface ImageLivenessOptions {
|
interface ImageLivenessOptions {
|
||||||
isFrameExpected: () => boolean;
|
isFrameExpected: () => boolean;
|
||||||
stallWindowSeconds?: number;
|
getStallAfterSeconds?: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtaining a screenshot. Defaults to drawing the current <img>; an image
|
// Obtaining a screenshot. Defaults to drawing the current <img>; an image
|
||||||
@@ -81,7 +81,7 @@ export class ImageMediaPlayerController implements MediaPlayerController {
|
|||||||
if (livenessOptions) {
|
if (livenessOptions) {
|
||||||
const stallWatchdog = new FrameStallWatchdog({
|
const stallWatchdog = new FrameStallWatchdog({
|
||||||
isPlaybackExpected: livenessOptions.isFrameExpected,
|
isPlaybackExpected: livenessOptions.isFrameExpected,
|
||||||
stallAfterSeconds: livenessOptions.stallWindowSeconds,
|
getStallAfterSeconds: livenessOptions.getStallAfterSeconds,
|
||||||
startSource: () => this._startFrameSource(),
|
startSource: () => this._startFrameSource(),
|
||||||
stopSource: () => this._stopFrameSource(),
|
stopSource: () => this._stopFrameSource(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Notification,
|
Notification,
|
||||||
|
NotificationContextItem,
|
||||||
NotificationDetail,
|
NotificationDetail,
|
||||||
} from '../../config/schema/actions/types.js';
|
} from '../../config/schema/actions/types.js';
|
||||||
import type { Link } from '../../config/schema/common/link.js';
|
import type { Link } from '../../config/schema/common/link.js';
|
||||||
@@ -12,7 +13,7 @@ export interface NotificationOptions {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
link?: Link;
|
link?: Link;
|
||||||
metadata?: NotificationDetail[];
|
metadata?: NotificationDetail[];
|
||||||
context?: object;
|
context?: NotificationContextItem;
|
||||||
in_progress?: boolean;
|
in_progress?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { MediaUnavailableIssueReason } from '../card-controller/issues/issu
|
|||||||
import type { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
import type { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
||||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
||||||
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
|
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
|
||||||
|
import { FRAME_STALL_SECONDS } from '../components-lib/media-player/frame-stall-watchdog.js';
|
||||||
import { ImageMediaPlayerController } from '../components-lib/media-player/image.js';
|
import { ImageMediaPlayerController } from '../components-lib/media-player/image.js';
|
||||||
import { createMediaNotification } from '../components-lib/notification/media.js';
|
import { createMediaNotification } from '../components-lib/notification/media.js';
|
||||||
import {
|
import {
|
||||||
@@ -176,6 +177,17 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
isRunning: () => this._cachedValueController.hasTimer(),
|
isRunning: () => this._cachedValueController.hasTimer(),
|
||||||
},
|
},
|
||||||
screenshotProvider: async () => this._cachedValueController.getValue(),
|
screenshotProvider: async () => this._cachedValueController.getValue(),
|
||||||
|
livenessOptions: {
|
||||||
|
// Frames are only due while the refresh timer runs. A snapshot with
|
||||||
|
// refreshing switched off shows one picture forever, which is the
|
||||||
|
// configured behaviour and never a stall.
|
||||||
|
isFrameExpected: () => this._cachedValueController.hasTimer(),
|
||||||
|
|
||||||
|
// Allow a whole refresh interval to pass, plus the standard window, so
|
||||||
|
// one slow fetch is not mistaken for a stopped camera.
|
||||||
|
getStallAfterSeconds: () =>
|
||||||
|
(this._getEffectiveRefreshSeconds() ?? 0) + FRAME_STALL_SECONDS,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ import {
|
|||||||
type VideoSurface,
|
type VideoSurface,
|
||||||
} from '../../../../components-lib/live/providers/go2rtc-experimental/session-controller.js';
|
} from '../../../../components-lib/live/providers/go2rtc-experimental/session-controller.js';
|
||||||
import type { SurfaceKind } from '../../../../components-lib/live/providers/go2rtc-experimental/types.js';
|
import type { SurfaceKind } from '../../../../components-lib/live/providers/go2rtc-experimental/types.js';
|
||||||
import { mapFailureReasonToIssueReason } from '../../../../components-lib/live/providers/go2rtc-experimental/utils/failure-reason.js';
|
import { mapStreamFailureReasonToIssueReason } from '../../../../components-lib/live/providers/go2rtc-experimental/utils/stream-failure-reason.js';
|
||||||
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
||||||
|
import { dispatchMicrophoneErrorEvent } from '../../../../components-lib/live/utils/dispatch-microphone-error.js';
|
||||||
import { MediaLoadedInfoSourceController } from '../../../../components-lib/media-loaded-info-source-controller.js';
|
import { MediaLoadedInfoSourceController } from '../../../../components-lib/media-loaded-info-source-controller.js';
|
||||||
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
|
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
|
||||||
import {
|
import {
|
||||||
@@ -163,12 +164,24 @@ export class AdvancedCameraCardGo2RTCExperimental
|
|||||||
// failure's user-facing cause) so the card's media-load retry (reconnecting
|
// failure's user-facing cause) so the card's media-load retry (reconnecting
|
||||||
// indicator, backoff, give-up) runs and can name why. The provider renders
|
// indicator, backoff, give-up) runs and can name why. The provider renders
|
||||||
// the error itself (below); the event drives the liveness verdict + retry.
|
// the error itself (below); the event drives the liveness verdict + retry.
|
||||||
errorCallback: (reason) => {
|
streamErrorCallback: (reason) => {
|
||||||
this._streamError = mapFailureReasonToIssueReason(reason);
|
this._streamError = mapStreamFailureReasonToIssueReason(reason);
|
||||||
dispatchLiveErrorEvent(this, { reason: this._streamError });
|
dispatchLiveErrorEvent(this, { reason: this._streamError });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
microphoneErrorCallback: (error) => this._reportMicrophoneError(error),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private _reportMicrophoneError(error?: string): void {
|
||||||
|
if (!this.targetID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatchMicrophoneErrorEvent(this, {
|
||||||
|
targetID: this.targetID,
|
||||||
|
description: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||||
return this._activeSurface === 'image'
|
return this._activeSurface === 'image'
|
||||||
? this._imageSurface.getMediaPlayer()
|
? this._imageSurface.getMediaPlayer()
|
||||||
@@ -186,9 +199,18 @@ export class AdvancedCameraCardGo2RTCExperimental
|
|||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
// Tear down synchronously so streams (e.g. 2-way audio backchannels)
|
// Tear down synchronously so streams (e.g. 2-way audio backchannels)
|
||||||
// release immediately.
|
// release immediately.
|
||||||
|
this._destroyMedia();
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the session and everything it was playing on. The surfaces keep their
|
||||||
|
// elements but the session has emptied them, so the media that was announced
|
||||||
|
// no longer exists and must stop being claimed -- otherwise a later reconnect
|
||||||
|
// would replay it and the card would believe a dead camera was loaded.
|
||||||
|
private _destroyMedia(): void {
|
||||||
this._session.reset();
|
this._session.reset();
|
||||||
this._activeSurface = null;
|
this._activeSurface = null;
|
||||||
super.disconnectedCallback();
|
this._mediaLoadedInfoSourceController.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
@@ -196,8 +218,7 @@ export class AdvancedCameraCardGo2RTCExperimental
|
|||||||
// The session is re-established by `updated()` once the new camera's
|
// The session is re-established by `updated()` once the new camera's
|
||||||
// signed URL resolves; the next commit picks the live surface. Blank the
|
// signed URL resolves; the next commit picks the live surface. Blank the
|
||||||
// view meanwhile so the previous camera's last frame is not shown.
|
// view meanwhile so the previous camera's last frame is not shown.
|
||||||
this._session.reset();
|
this._destroyMedia();
|
||||||
this._activeSurface = null;
|
|
||||||
this._streamError = null;
|
this._streamError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +258,7 @@ export class AdvancedCameraCardGo2RTCExperimental
|
|||||||
// drop the session -- otherwise a later URL, even an identical unsigned
|
// drop the session -- otherwise a later URL, even an identical unsigned
|
||||||
// endpoint, would be skipped by connect()'s identity check and leave the
|
// endpoint, would be skipped by connect()'s identity check and leave the
|
||||||
// session bound to the removed elements.
|
// session bound to the removed elements.
|
||||||
this._session.reset();
|
this._destroyMedia();
|
||||||
this._activeSurface = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,12 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
|||||||
this._jsmpegCanvasElement.remove();
|
this._jsmpegCanvasElement.remove();
|
||||||
this._jsmpegCanvasElement = undefined;
|
this._jsmpegCanvasElement = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The player and the canvas it drew on are gone, so the media announced
|
||||||
|
// from them no longer exists and must stop being claimed -- otherwise a
|
||||||
|
// later reconnect would replay it and the card would believe a dead camera
|
||||||
|
// was loaded.
|
||||||
|
this._mediaLoadedInfoSourceController.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
|
|||||||
@@ -100,6 +100,13 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
|||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this._videoRTC = null;
|
this._videoRTC = null;
|
||||||
this._notification = null;
|
this._notification = null;
|
||||||
|
|
||||||
|
// A reconnect builds a brand new WebRTC element, so the video that was
|
||||||
|
// announced is gone and must stop being claimed -- otherwise the card would
|
||||||
|
// be told media is loaded during the window where there is no element at
|
||||||
|
// all, and would keep believing it if the replacement never loads.
|
||||||
|
this._mediaLoadedInfoSourceController.clear();
|
||||||
|
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export type NotificationControl = z.infer<typeof notificationControlSchema>;
|
|||||||
// A context item is a preformatted string or a structured object that is
|
// A context item is a preformatted string or a structured object that is
|
||||||
// YAML-dumped at render time (see NotificationContextController).
|
// YAML-dumped at render time (see NotificationContextController).
|
||||||
const notificationContextItemSchema = z.union([z.string(), z.custom<object>(isRecord)]);
|
const notificationContextItemSchema = z.union([z.string(), z.custom<object>(isRecord)]);
|
||||||
|
export type NotificationContextItem = z.infer<typeof notificationContextItemSchema>;
|
||||||
|
|
||||||
const notificationSchema = z.object({
|
const notificationSchema = z.object({
|
||||||
heading: notificationDetailSchema.optional(),
|
heading: notificationDetailSchema.optional(),
|
||||||
|
|||||||
Vendored
+2
@@ -11,6 +11,8 @@ declare module 'view' {
|
|||||||
declare module 'issue' {
|
declare module 'issue' {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
interface IssueTriggerContext {}
|
interface IssueTriggerContext {}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
interface IssueResolveContext {}
|
||||||
}
|
}
|
||||||
declare module 'action' {
|
declare module 'action' {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
|||||||
@@ -807,6 +807,7 @@
|
|||||||
"awaiting_live": "Waiting for live stream to load...",
|
"awaiting_live": "Waiting for live stream to load...",
|
||||||
"awaiting_media": "Waiting for media to load",
|
"awaiting_media": "Waiting for media to load",
|
||||||
"call_invalid_target": "The requested camera or stream is not available to call.",
|
"call_invalid_target": "The requested camera or stream is not available to call.",
|
||||||
|
"call_microphone_failed": "Your microphone could not be connected.",
|
||||||
"call_microphone_forbidden": "Microphone access has been denied for this page. Update your browser permissions and try again.",
|
"call_microphone_forbidden": "Microphone access has been denied for this page. Update your browser permissions and try again.",
|
||||||
"call_microphone_unsupported": "Microphone access is not available in this browser (e.g. requires HTTPS).",
|
"call_microphone_unsupported": "Microphone access is not available in this browser (e.g. requires HTTPS).",
|
||||||
"call_no_two_way_audio": "This camera does not support two-way audio.",
|
"call_no_two_way_audio": "This camera does not support two-way audio.",
|
||||||
@@ -897,7 +898,6 @@
|
|||||||
"playback_error": "Playback error",
|
"playback_error": "Playback error",
|
||||||
"server_error": "Streaming server error",
|
"server_error": "Streaming server error",
|
||||||
"stalled": "Stream stalled",
|
"stalled": "Stream stalled",
|
||||||
"two_way_audio_error": "Two-way audio error",
|
|
||||||
"unsupported": "Stream not supported"
|
"unsupported": "Stream not supported"
|
||||||
},
|
},
|
||||||
"text": "The media is not currently available. This can happen for several reasons, for example a camera becoming unavailable, a stream stalling or failing, or media that has not finished loading. The card keeps retrying automatically. For live views, a still image may be shown while a stream is loading (if configured, and by default)"
|
"text": "The media is not currently available. This can happen for several reasons, for example a camera becoming unavailable, a stream stalling or failing, or media that has not finished loading. The card keeps retrying automatically. For live views, a still image may be shown while a stream is loading (if configured, and by default)"
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ void customElements.whenDefined('ha-hls-player').then(() => {
|
|||||||
// and are only logged (see render()).
|
// and are only logged (see render()).
|
||||||
const errored = !!this._error && this._errorIsFatal;
|
const errored = !!this._error && this._errorIsFatal;
|
||||||
if (errored && !this._lastErrored) {
|
if (errored && !this._lastErrored) {
|
||||||
dispatchLiveErrorEvent(this, { detail: this._error });
|
dispatchLiveErrorEvent(this, { description: this._error });
|
||||||
}
|
}
|
||||||
this._lastErrored = errored;
|
this._lastErrored = errored;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,12 +59,6 @@ export type MediaLoadedInfoOwner = HTMLElement;
|
|||||||
export interface MediaLoadedInfoEventDetail {
|
export interface MediaLoadedInfoEventDetail {
|
||||||
info: MediaLoadedInfo;
|
info: MediaLoadedInfo;
|
||||||
|
|
||||||
// Absent (the default, a fresh media load) unless `true`, which marks a
|
|
||||||
// replay: the source re-dispatched its last load on reconnect without the
|
|
||||||
// media actually reloading (e.g. preloaded camera in the background when
|
|
||||||
// brought to the foreground).
|
|
||||||
cached?: boolean;
|
|
||||||
|
|
||||||
// Aborts when the source retires this media. The source aborts on host
|
// Aborts when the source retires this media. The source aborts on host
|
||||||
// disconnect, and when a subsequent `set()` arrives under a different
|
// disconnect, and when a subsequent `set()` arrives under a different
|
||||||
// `targetID` (replacing this dispatch). Independent of DOM connectedness, so
|
// `targetID` (replacing this dispatch). Independent of DOM connectedness, so
|
||||||
|
|||||||
@@ -945,6 +945,65 @@ describe('endIf', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reportCallMicrophoneError', () => {
|
||||||
|
it('should report a microphone that could not be attached', async () => {
|
||||||
|
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
expect(await manager.start()).toBe(true);
|
||||||
|
vi.mocked(api.getNotificationManager().setNotification).mockClear();
|
||||||
|
|
||||||
|
manager.reportCallMicrophoneError('camera.office', 'The peer connection is closed');
|
||||||
|
|
||||||
|
expect(api.getNotificationManager().setNotification).toHaveBeenCalledWith({
|
||||||
|
heading: { text: 'Two-way audio unavailable' },
|
||||||
|
body: { text: 'Your microphone could not be connected.' },
|
||||||
|
context: ['The peer connection is closed'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should omit the context when the browser provided none', async () => {
|
||||||
|
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
expect(await manager.start()).toBe(true);
|
||||||
|
vi.mocked(api.getNotificationManager().setNotification).mockClear();
|
||||||
|
|
||||||
|
manager.reportCallMicrophoneError('camera.office');
|
||||||
|
|
||||||
|
expect(api.getNotificationManager().setNotification).toHaveBeenCalledWith(
|
||||||
|
expect.not.objectContaining({ context: expect.anything() }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not report without a call', () => {
|
||||||
|
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||||
|
|
||||||
|
new CallManager(api).reportCallMicrophoneError('camera.office');
|
||||||
|
|
||||||
|
expect(api.getNotificationManager().setNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not report while an inbound call is still ringing', async () => {
|
||||||
|
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
expect(await manager.start({ inbound: true })).toBe(true);
|
||||||
|
|
||||||
|
manager.reportCallMicrophoneError('camera.office');
|
||||||
|
|
||||||
|
expect(api.getNotificationManager().setNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not report for a camera the call is not on', async () => {
|
||||||
|
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||||
|
const manager = new CallManager(api);
|
||||||
|
expect(await manager.start()).toBe(true);
|
||||||
|
vi.mocked(api.getNotificationManager().setNotification).mockClear();
|
||||||
|
|
||||||
|
manager.reportCallMicrophoneError('camera.other');
|
||||||
|
|
||||||
|
expect(api.getNotificationManager().setNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('condition state changes', () => {
|
describe('condition state changes', () => {
|
||||||
it('should end the call when the selected camera changes away', async () => {
|
it('should end the call when the selected camera changes away', async () => {
|
||||||
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
|
||||||
|
|||||||
@@ -265,6 +265,45 @@ describe('IssueManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolve', () => {
|
||||||
|
it('should resolve the issue and update the card once it clears', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new IssueManager(api);
|
||||||
|
|
||||||
|
const issue = createIssue('media_unavailable', {
|
||||||
|
hasIssue: vi.fn().mockReturnValue(true),
|
||||||
|
getIssue: vi.fn().mockReturnValue(createIssueDescription()),
|
||||||
|
resolve: vi.fn(),
|
||||||
|
});
|
||||||
|
manager.addIssue(issue);
|
||||||
|
|
||||||
|
// Establish the issue as present, then let resolving remove it.
|
||||||
|
manager.evaluate();
|
||||||
|
vi.mocked(api.getCardElementManager().update).mockClear();
|
||||||
|
vi.mocked(issue.getIssue).mockReturnValue(null);
|
||||||
|
|
||||||
|
manager.resolve('media_unavailable', { targetID: 'camera-1' });
|
||||||
|
|
||||||
|
expect(issue.resolve).toHaveBeenCalledWith({ targetID: 'camera-1' });
|
||||||
|
expect(api.getCardElementManager().update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop retrying once resolve removes the last retryable problem', () => {
|
||||||
|
const { manager, issue } = createRetriableSetup();
|
||||||
|
assert(issue.resolve);
|
||||||
|
assert(issue.needsRetry);
|
||||||
|
|
||||||
|
// Arm the retry timer while the problem is unresolved.
|
||||||
|
manager.evaluate();
|
||||||
|
|
||||||
|
vi.mocked(issue.needsRetry).mockReturnValue(false);
|
||||||
|
manager.resolve('media_unavailable', { targetID: 'camera-1' });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000 * 10);
|
||||||
|
expect(issue.retry).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('retry', () => {
|
describe('retry', () => {
|
||||||
it('should call retry on the manager and reset the timer', () => {
|
it('should call retry on the manager and reset the timer', () => {
|
||||||
const { manager, issue } = createRetriableSetup();
|
const { manager, issue } = createRetriableSetup();
|
||||||
|
|||||||
@@ -19,18 +19,12 @@ const fireMediaChange = (
|
|||||||
vi.mocked(api.getMediaLoadedInfoManager().subscribe).mock.calls[0]?.[0]?.(change);
|
vi.mocked(api.getMediaLoadedInfoManager().subscribe).mock.calls[0]?.[0]?.(change);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simulate a media (re)load for `targetID`. `cached` marks a reconnect replay
|
// Simulate a media (re)load for `targetID`.
|
||||||
// (a re-dispatch of the last load, not an actual reload).
|
const fireMediaLoad = (api: ReturnType<typeof createAPI>, targetID: string): void => {
|
||||||
const fireMediaLoad = (
|
|
||||||
api: ReturnType<typeof createAPI>,
|
|
||||||
targetID: string,
|
|
||||||
cached = false,
|
|
||||||
): void => {
|
|
||||||
fireMediaChange(api, {
|
fireMediaChange(api, {
|
||||||
type: 'load',
|
type: 'load',
|
||||||
targetID,
|
targetID,
|
||||||
info: createMediaLoadedInfo({ targetID }),
|
info: createMediaLoadedInfo({ targetID }),
|
||||||
cached,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,15 +299,15 @@ describe('MediaUnavailableIssue', () => {
|
|||||||
expect(issue.hasIssue()).toBe(false);
|
expect(issue.hasIssue()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear a target error on a genuine media load', () => {
|
it('should clear a not-loading error on a media load', () => {
|
||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
const issue = new MediaUnavailableIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
|
||||||
// A genuine (re)load for the target clears the error.
|
// An attached player disproves "media not loading", whoever recorded it.
|
||||||
fireMediaLoad(api, 'camera-1');
|
fireMediaLoad(api, 'camera-1');
|
||||||
|
|
||||||
// The error is gone, so this unloaded state falls back to the timer
|
// The error is gone, so this unloaded state falls back to the timer
|
||||||
@@ -322,6 +316,21 @@ describe('MediaUnavailableIssue', () => {
|
|||||||
expect(issue.hasIssue()).toBe(false);
|
expect(issue.hasIssue()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not clear a stream error on a media load', () => {
|
||||||
|
const api = createAPI();
|
||||||
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
|
|
||||||
|
// A load only says a player attached -- for a stream that loaded and then
|
||||||
|
// froze, that includes a reconnect replay of the frozen player. Stream
|
||||||
|
// errors clear only via resolve, on real evidence of media flowing.
|
||||||
|
fireMediaLoad(api, 'camera-1');
|
||||||
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
|
|
||||||
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should keep an errored target active while its media still reads as loaded', () => {
|
it('should keep an errored target active while its media still reads as loaded', () => {
|
||||||
const issue = new MediaUnavailableIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
@@ -343,7 +352,7 @@ describe('MediaUnavailableIssue', () => {
|
|||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
const issue = new MediaUnavailableIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||||
|
|
||||||
// A load for a different target must not clear camera-1's error.
|
// A load for a different target must not clear camera-1's error.
|
||||||
fireMediaLoad(api, 'camera-2');
|
fireMediaLoad(api, 'camera-2');
|
||||||
@@ -352,32 +361,13 @@ describe('MediaUnavailableIssue', () => {
|
|||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not clear a target error on a reconnect replay', () => {
|
|
||||||
const api = createAPI();
|
|
||||||
const issue = new MediaUnavailableIssue(api);
|
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
|
||||||
|
|
||||||
fireMediaLoad(
|
|
||||||
api,
|
|
||||||
'camera-1',
|
|
||||||
|
|
||||||
// A cached replay (reconnect re-dispatch) did not actually reload the
|
|
||||||
// media, so it must not clear the error.
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
|
||||||
|
|
||||||
expect(issue.hasIssue()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not clear a target error on unload or select changes', () => {
|
it('should not clear a target error on unload or select changes', () => {
|
||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
const issue = new MediaUnavailableIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||||
|
|
||||||
// Only a genuine load clears; unload / select changes are irrelevant.
|
// Only a load clears; unload / select changes are irrelevant.
|
||||||
fireMediaChange(api, { type: 'unload', targetID: 'camera-1' });
|
fireMediaChange(api, { type: 'unload', targetID: 'camera-1' });
|
||||||
fireMediaChange(api, { type: 'select', targetID: 'camera-1' });
|
fireMediaChange(api, { type: 'select', targetID: 'camera-1' });
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
@@ -399,6 +389,78 @@ describe('MediaUnavailableIssue', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolve', () => {
|
||||||
|
it('should clear an errored target', () => {
|
||||||
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
|
issue.trigger({ targetID: 'camera.office', reason: 'stalled' });
|
||||||
|
expect(issue.getNotification().metadata).toEqual([
|
||||||
|
expect.objectContaining({ text: 'camera.office: Stream stalled' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
issue.resolve({ targetID: 'camera.office' });
|
||||||
|
|
||||||
|
expect(issue.getNotification().metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deactivate a target that is proven to be delivering media again', () => {
|
||||||
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
|
issue.detectDynamic({
|
||||||
|
targetID: 'camera-1',
|
||||||
|
view: 'live',
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }),
|
||||||
|
});
|
||||||
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
|
||||||
|
issue.resolve({ targetID: 'camera-1' });
|
||||||
|
issue.detectDynamic({
|
||||||
|
targetID: 'camera-1',
|
||||||
|
view: 'live',
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issue.hasIssue()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave other targets errored', () => {
|
||||||
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
|
issue.trigger({ targetID: 'camera.office', reason: 'stalled' });
|
||||||
|
|
||||||
|
issue.resolve({ targetID: 'camera.garden' });
|
||||||
|
|
||||||
|
expect(issue.getNotification().metadata).toEqual([
|
||||||
|
expect.objectContaining({ text: 'camera.office: Stream stalled' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel the pending-load timer for its target', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||||
|
|
||||||
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
|
|
||||||
|
issue.resolve({ targetID: 'camera-1' });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
expect(issue.hasIssue()).toBe(false);
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave the pending-load timer alone for a different target', () => {
|
||||||
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
|
|
||||||
|
issue.resolve({ targetID: 'camera-2' });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getNotification', () => {
|
describe('getNotification', () => {
|
||||||
it('should return notification regardless of active state', () => {
|
it('should return notification regardless of active state', () => {
|
||||||
const issue = new MediaUnavailableIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
@@ -673,6 +735,28 @@ describe('MediaUnavailableIssue', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not retry a stale pending-timer target once its timer has stopped', () => {
|
||||||
|
const api = createAPI();
|
||||||
|
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
||||||
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
|
// A slow load arms the pending timer for camera.garden.
|
||||||
|
issue.detectDynamic({ targetID: 'camera.garden', view: 'live' });
|
||||||
|
|
||||||
|
// The view moves to a target that already has a hard error. That path
|
||||||
|
// activates immediately and stops the timer, but the stale
|
||||||
|
// _timerTargetID (camera.garden) lingers -- and that target may since
|
||||||
|
// have loaded, so reloading it would be gratuitous.
|
||||||
|
issue.trigger({ targetID: 'camera.office', reason: 'playback_error' });
|
||||||
|
issue.detectDynamic({ targetID: 'camera.office', view: 'live' });
|
||||||
|
|
||||||
|
issue.retry();
|
||||||
|
|
||||||
|
expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({
|
||||||
|
mediaEpoch: { 'camera.office': 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should keep errored targets and issue state after retry', () => {
|
it('should keep errored targets and issue state after retry', () => {
|
||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
||||||
@@ -685,8 +769,8 @@ describe('MediaUnavailableIssue', () => {
|
|||||||
issue.retry();
|
issue.retry();
|
||||||
|
|
||||||
// After retry, the issue stays active and the errored target is preserved
|
// After retry, the issue stays active and the errored target is preserved
|
||||||
// -- no new 10s grace period. A genuine media load would clear everything
|
// -- no new 10s grace period. Recovery clears it: a load for a
|
||||||
// (_onMediaLoad drops the errored target).
|
// not-loading error, a resolve for a stream error.
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
@@ -714,12 +798,63 @@ describe('MediaUnavailableIssue', () => {
|
|||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const issue = new MediaUnavailableIssue(api, onChange);
|
const issue = new MediaUnavailableIssue(api, onChange);
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||||
fireMediaLoad(api, 'camera-1');
|
fireMediaLoad(api, 'camera-1');
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalled();
|
expect(onChange).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not notify onChange when a load changes nothing', () => {
|
||||||
|
const api = createAPI();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const issue = new MediaUnavailableIssue(api, onChange);
|
||||||
|
|
||||||
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
|
fireMediaLoad(api, 'camera-1');
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear a timer-recorded error when the media later loads', () => {
|
||||||
|
const api = createAPI();
|
||||||
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
|
// A viewer target has no liveness observer, so a load is the only
|
||||||
|
// recovery signal it will ever produce.
|
||||||
|
issue.detectDynamic({ targetID: 'media-1', view: 'clip' });
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
expect(issue.getNotification().metadata).toEqual([
|
||||||
|
expect.objectContaining({ text: 'media-1: Media not loading' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
fireMediaLoad(api, 'media-1');
|
||||||
|
|
||||||
|
expect(issue.getNotification().metadata).toBeUndefined();
|
||||||
|
issue.detectDynamic({
|
||||||
|
targetID: 'media-1',
|
||||||
|
view: 'clip',
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'media-1' }),
|
||||||
|
});
|
||||||
|
expect(issue.hasIssue()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel the pending-load timer when its target genuinely loads', () => {
|
||||||
|
const api = createAPI();
|
||||||
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
|
// A target starts loading, arming the pending-load timer.
|
||||||
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
|
|
||||||
|
// It loads in the background, so no further detection pass runs for it
|
||||||
|
// (detection only ever covers the current target).
|
||||||
|
fireMediaLoad(api, 'camera-1');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
|
||||||
|
expect(issue.hasIssue()).toBe(false);
|
||||||
|
expect(issue.getNotification().metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should unsubscribe from media loads on destroy', () => {
|
it('should unsubscribe from media loads on destroy', () => {
|
||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
const unsubscribe = vi.fn();
|
const unsubscribe = vi.fn();
|
||||||
|
|||||||
@@ -116,6 +116,46 @@ describe('IssueStateManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolve', () => {
|
||||||
|
it('should call resolve on the matching issue', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
manager.resolve('media_unavailable', { targetID: 'cam1' });
|
||||||
|
|
||||||
|
assert(mockMediaLoad.resolve);
|
||||||
|
expect(mockMediaLoad.resolve).toHaveBeenCalledWith({ targetID: 'cam1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing for unknown key', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
manager.resolve('unknown' as never, {} as never);
|
||||||
|
|
||||||
|
assert(mockMediaLoad.resolve);
|
||||||
|
expect(mockMediaLoad.resolve).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log the issue again after resolving cleared it', () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockReturnValue(undefined);
|
||||||
|
const manager = createManager([mockMediaLoad]);
|
||||||
|
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(createIssueDescription());
|
||||||
|
|
||||||
|
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Resolving clears the issue, which releases the dedupe so the next
|
||||||
|
// failure is logged as a new episode rather than silently swallowed.
|
||||||
|
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(null);
|
||||||
|
manager.resolve('media_unavailable', { targetID: 'cam1' });
|
||||||
|
|
||||||
|
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(createIssueDescription());
|
||||||
|
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('detectDynamic', () => {
|
describe('detectDynamic', () => {
|
||||||
it('should call detectDynamic on issues with the given state', () => {
|
it('should call detectDynamic on issues with the given state', () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
|
|||||||
@@ -441,25 +441,6 @@ describe('MediaLoadedInfoManager', () => {
|
|||||||
type: 'load',
|
type: 'load',
|
||||||
targetID: 'target-1',
|
targetID: 'target-1',
|
||||||
info,
|
info,
|
||||||
cached: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should mark a cached load', () => {
|
|
||||||
const api = createCardAPI();
|
|
||||||
const manager = new MediaLoadedInfoManager(api);
|
|
||||||
const owner = document.createElement('div');
|
|
||||||
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
|
||||||
const listener = vi.fn();
|
|
||||||
|
|
||||||
manager.subscribe(listener);
|
|
||||||
manager.set(info, owner, true);
|
|
||||||
|
|
||||||
expect(listener).toHaveBeenCalledWith({
|
|
||||||
type: 'load',
|
|
||||||
targetID: 'target-1',
|
|
||||||
info,
|
|
||||||
cached: true,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,22 @@ describe('EntityAvailabilityDetector', () => {
|
|||||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should re-check the entity when reset', () => {
|
||||||
|
// always_error makes the re-check produce a verdict immediately rather than
|
||||||
|
// waiting out the grace window.
|
||||||
|
const { detector, setEntityState } = setup({ alwaysError: true });
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
// An entity that is already unavailable never fires a state change, so
|
||||||
|
// resetting must read it rather than wait to be told.
|
||||||
|
setEntityState('unavailable');
|
||||||
|
detector.reset();
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual(
|
||||||
|
expect.objectContaining({ state: 'not_live', authority: 'hard' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should do nothing on reset before subscribe', () => {
|
it('should do nothing on reset before subscribe', () => {
|
||||||
const { detector, stateWatcher } = setup();
|
const { detector, stateWatcher } = setup();
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,26 @@ describe('MediaPlayerLivenessDetector', () => {
|
|||||||
expect(onChange).toHaveBeenCalledTimes(1);
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should discard a retained live verdict when watching resumes', async () => {
|
||||||
|
const { detector, onChange, loadMedia } = setup();
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(player);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
|
||||||
|
// Away and back with nothing observed in between. The retained `live`
|
||||||
|
// describes the previous watch, so it must not survive into this one.
|
||||||
|
detector.unsubscribe();
|
||||||
|
onChange.mockClear();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(player);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should not watch a player without the liveness capability', async () => {
|
it('should not watch a player without the liveness capability', async () => {
|
||||||
const { detector, onChange, loadMedia } = setup();
|
const { detector, onChange, loadMedia } = setup();
|
||||||
const player = mock<MediaPlayerController>();
|
const player = mock<MediaPlayerController>();
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ const createHostInDocument = (): HTMLElement => {
|
|||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
describe('ProviderErrorDetector', () => {
|
describe('ProviderErrorDetector', () => {
|
||||||
|
it('should not report a change when reset', () => {
|
||||||
|
const host = createHostInDocument();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const detector = new ProviderErrorDetector(host, onChange);
|
||||||
|
detector.subscribe();
|
||||||
|
dispatchLiveErrorEvent(host);
|
||||||
|
onChange.mockClear();
|
||||||
|
|
||||||
|
detector.reset();
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should start unknown', () => {
|
it('should start unknown', () => {
|
||||||
const detector = new ProviderErrorDetector(document.createElement('div'), vi.fn());
|
const detector = new ProviderErrorDetector(document.createElement('div'), vi.fn());
|
||||||
|
|
||||||
@@ -43,7 +56,7 @@ describe('ProviderErrorDetector', () => {
|
|||||||
|
|
||||||
dispatchLiveErrorEvent(host, {
|
dispatchLiveErrorEvent(host, {
|
||||||
reason: 'unsupported',
|
reason: 'unsupported',
|
||||||
detail: 'Codec not supported',
|
description: 'Codec not supported',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(detector.getVerdict()).toEqual({
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '../../../test-utils';
|
} from '../../../test-utils';
|
||||||
|
|
||||||
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
|
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
|
||||||
|
const ISSUE_RESOLVE_EVENT = 'advanced-camera-card:issue:resolve';
|
||||||
|
|
||||||
const setup = (options?: { targetID?: string | null }) => {
|
const setup = (options?: { targetID?: string | null }) => {
|
||||||
const host = createLitElement();
|
const host = createLitElement();
|
||||||
@@ -41,11 +42,16 @@ const setup = (options?: { targetID?: string | null }) => {
|
|||||||
issueTriggers.push((ev as CustomEvent).detail),
|
issueTriggers.push((ev as CustomEvent).detail),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const issueResolves: unknown[] = [];
|
||||||
|
host.addEventListener(ISSUE_RESOLVE_EVENT, (ev) =>
|
||||||
|
issueResolves.push((ev as CustomEvent).detail),
|
||||||
|
);
|
||||||
|
|
||||||
const failViaProviderError = (error?: LiveError): void => {
|
const failViaProviderError = (error?: LiveError): void => {
|
||||||
dispatchLiveErrorEvent(host, error);
|
dispatchLiveErrorEvent(host, error);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { host, controller, issueTriggers, failViaProviderError };
|
return { host, controller, issueTriggers, issueResolves, failViaProviderError };
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPlayer = (): {
|
const createPlayer = (): {
|
||||||
@@ -139,7 +145,9 @@ describe('StreamLivenessController', () => {
|
|||||||
const { controller, issueTriggers, failViaProviderError } = setup();
|
const { controller, issueTriggers, failViaProviderError } = setup();
|
||||||
controller.hostConnected();
|
controller.hostConnected();
|
||||||
|
|
||||||
failViaProviderError({ detail: 'Failed to start WebRTC stream: no candidates' });
|
failViaProviderError({
|
||||||
|
description: 'Failed to start WebRTC stream: no candidates',
|
||||||
|
});
|
||||||
|
|
||||||
expect(controller.getFailure()).toEqual({
|
expect(controller.getFailure()).toEqual({
|
||||||
reason: 'playback_error',
|
reason: 'playback_error',
|
||||||
@@ -338,6 +346,129 @@ describe('StreamLivenessController', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolving the issue', () => {
|
||||||
|
const setupWithLiveStream = async () => {
|
||||||
|
const result = setup();
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
|
||||||
|
result.controller.hostConnected();
|
||||||
|
result.host.dispatchEvent(
|
||||||
|
createMediaLoadedInfoEvent({
|
||||||
|
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
return { ...result, fireMediaPlayerLiveness };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should resolve when frames confirm the stream is flowing', async () => {
|
||||||
|
const { issueResolves, fireMediaPlayerLiveness } = await setupWithLiveStream();
|
||||||
|
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
|
||||||
|
expect(issueResolves).toEqual([
|
||||||
|
{ key: 'media_unavailable', targetID: 'camera.office' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not resolve while a hard failure exists', async () => {
|
||||||
|
const { issueResolves, failViaProviderError, fireMediaPlayerLiveness } =
|
||||||
|
await setupWithLiveStream();
|
||||||
|
|
||||||
|
// A provider has authoritatively condemned the stream. Frames continuing
|
||||||
|
// to arrive must not talk the card out of it.
|
||||||
|
failViaProviderError();
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
|
||||||
|
expect(issueResolves).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not resolve without a target', async () => {
|
||||||
|
const result = setup({ targetID: null });
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
|
||||||
|
result.controller.hostConnected();
|
||||||
|
result.host.dispatchEvent(
|
||||||
|
createMediaLoadedInfoEvent({
|
||||||
|
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
|
||||||
|
expect(result.issueResolves).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not resolve on reconnect without fresh evidence', async () => {
|
||||||
|
const { controller, issueResolves, fireMediaPlayerLiveness } =
|
||||||
|
await setupWithLiveStream();
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
issueResolves.length = 0;
|
||||||
|
|
||||||
|
// Away and back with nothing observed in between: the previous `live` is
|
||||||
|
// a memory of the old watch, not evidence about the new one.
|
||||||
|
controller.hostDisconnected();
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
expect(issueResolves).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not announce recovery for a stream that was reset away', async () => {
|
||||||
|
const { controller, issueResolves, fireMediaPlayerLiveness } =
|
||||||
|
await setupWithLiveStream();
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
issueResolves.length = 0;
|
||||||
|
|
||||||
|
// The stream this `live` describes is being torn down, so resetting must
|
||||||
|
// not report it as recovered.
|
||||||
|
controller.reset();
|
||||||
|
|
||||||
|
expect(issueResolves).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not mix a stale live verdict with a freshly reset one', async () => {
|
||||||
|
const { controller, host, issueResolves, issueTriggers, failViaProviderError } =
|
||||||
|
setup();
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
controller.hostConnected();
|
||||||
|
host.dispatchEvent(
|
||||||
|
createMediaLoadedInfoEvent({
|
||||||
|
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
// Frames say live, then a provider error condemns the stream. Both
|
||||||
|
// verdicts are held at once, by different detectors.
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
failViaProviderError();
|
||||||
|
issueResolves.length = 0;
|
||||||
|
issueTriggers.length = 0;
|
||||||
|
|
||||||
|
// Resetting clears them in turn. If any detector announced part-way
|
||||||
|
// through, the cleared provider error would leave the stale `live`
|
||||||
|
// unopposed and the card would report a recovery that never happened.
|
||||||
|
controller.reset();
|
||||||
|
|
||||||
|
expect(issueResolves).toEqual([]);
|
||||||
|
expect(issueTriggers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-read the detectors once they have all been reset', async () => {
|
||||||
|
const { controller, host, fireMediaPlayerLiveness } = await setupWithLiveStream();
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
vi.mocked(host.requestUpdate).mockClear();
|
||||||
|
|
||||||
|
controller.reset();
|
||||||
|
|
||||||
|
// Anything the detectors say while being reset is ignored, so this is the
|
||||||
|
// single read the controller makes once they are all done.
|
||||||
|
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.isLive()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should report not live despite confirmed frames when always_error overrides', async () => {
|
it('should report not live despite confirmed frames when always_error overrides', async () => {
|
||||||
const host = createLitElement();
|
const host = createLitElement();
|
||||||
document.body.append(host);
|
document.body.append(host);
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ describe('ImageSurfaceController', () => {
|
|||||||
|
|
||||||
it('should have liveness when given liveness options', () => {
|
it('should have liveness when given liveness options', () => {
|
||||||
const controller = new ImageSurfaceController(createLitElement(), () => null, {
|
const controller = new ImageSurfaceController(createLitElement(), () => null, {
|
||||||
livenessOptions: { isFrameExpected: () => true, stallWindowSeconds: 10 },
|
livenessOptions: { isFrameExpected: () => true, getStallAfterSeconds: () => 10 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(controller.getMediaPlayer().subscribeLiveness).toBeDefined();
|
expect(controller.getMediaPlayer().subscribeLiveness).toBeDefined();
|
||||||
|
|||||||
+51
-16
@@ -178,7 +178,8 @@ describe('Go2RTCSessionController', () => {
|
|||||||
const getControls = vi.fn(() => options?.controls ?? false);
|
const getControls = vi.fn(() => options?.controls ?? false);
|
||||||
const mediaLoadedCallback = vi.fn();
|
const mediaLoadedCallback = vi.fn();
|
||||||
const surfaceCommittedCallback = vi.fn();
|
const surfaceCommittedCallback = vi.fn();
|
||||||
const errorCallback = vi.fn();
|
const streamErrorCallback = vi.fn();
|
||||||
|
const microphoneErrorCallback = vi.fn();
|
||||||
|
|
||||||
const session = new Go2RTCSessionController(
|
const session = new Go2RTCSessionController(
|
||||||
{
|
{
|
||||||
@@ -186,7 +187,8 @@ describe('Go2RTCSessionController', () => {
|
|||||||
getCardWideConfig: () => options?.cardWideConfig ?? null,
|
getCardWideConfig: () => options?.cardWideConfig ?? null,
|
||||||
mediaLoadedCallback,
|
mediaLoadedCallback,
|
||||||
surfaceCommittedCallback,
|
surfaceCommittedCallback,
|
||||||
errorCallback,
|
streamErrorCallback,
|
||||||
|
microphoneErrorCallback,
|
||||||
},
|
},
|
||||||
{ createWebSocket, createBinarySource, createWebRTCSource, createVideoElement },
|
{ createWebSocket, createBinarySource, createWebRTCSource, createVideoElement },
|
||||||
);
|
);
|
||||||
@@ -201,7 +203,8 @@ describe('Go2RTCSessionController', () => {
|
|||||||
createBinarySource,
|
createBinarySource,
|
||||||
createWebRTCSource,
|
createWebRTCSource,
|
||||||
createWebSocket,
|
createWebSocket,
|
||||||
errorCallback,
|
streamErrorCallback,
|
||||||
|
microphoneErrorCallback,
|
||||||
mediaLoadedCallback,
|
mediaLoadedCallback,
|
||||||
offscreenVideos,
|
offscreenVideos,
|
||||||
session,
|
session,
|
||||||
@@ -281,7 +284,8 @@ describe('Go2RTCSessionController', () => {
|
|||||||
surfaceCommittedCallback: vi.fn(),
|
surfaceCommittedCallback: vi.fn(),
|
||||||
getCardWideConfig: () => null,
|
getCardWideConfig: () => null,
|
||||||
mediaLoadedCallback: vi.fn(),
|
mediaLoadedCallback: vi.fn(),
|
||||||
errorCallback: vi.fn(),
|
streamErrorCallback: vi.fn(),
|
||||||
|
microphoneErrorCallback: vi.fn(),
|
||||||
});
|
});
|
||||||
session.connect('ws://localhost:1/api/ws', createSurfaces().surfaces, ['mse']);
|
session.connect('ws://localhost:1/api/ws', createSurfaces().surfaces, ['mse']);
|
||||||
session.reset();
|
session.reset();
|
||||||
@@ -536,6 +540,30 @@ describe('Go2RTCSessionController', () => {
|
|||||||
expect(webRTCOptions[0]?.microphoneStream).toBe(micStream);
|
expect(webRTCOptions[0]?.microphoneStream).toBe(micStream);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should report a microphone error without disturbing the stream', () => {
|
||||||
|
const {
|
||||||
|
session,
|
||||||
|
surfaces,
|
||||||
|
websockets,
|
||||||
|
webRTCOptions,
|
||||||
|
webRTCSources,
|
||||||
|
microphoneErrorCallback,
|
||||||
|
streamErrorCallback,
|
||||||
|
} = setup();
|
||||||
|
session.connect('http://host/api/ws?src=camera', surfaces, ['webrtc']);
|
||||||
|
websockets[0].fireOpen();
|
||||||
|
|
||||||
|
webRTCOptions[0]?.microphoneErrorCallback?.('InvalidStateError');
|
||||||
|
|
||||||
|
expect(microphoneErrorCallback).toHaveBeenCalledWith('InvalidStateError');
|
||||||
|
|
||||||
|
// The inbound video is unaffected by an outbound audio failure, so the
|
||||||
|
// source keeps running and the session neither escalates nor reconnects.
|
||||||
|
expect(streamErrorCallback).not.toHaveBeenCalled();
|
||||||
|
expect(webRTCSources[0].stop).not.toHaveBeenCalled();
|
||||||
|
expect(websockets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should re-dispatch loaded media on an audio mute transition', () => {
|
it('should re-dispatch loaded media on an audio mute transition', () => {
|
||||||
const peerConnection = new FakeRTCPeerConnection();
|
const peerConnection = new FakeRTCPeerConnection();
|
||||||
const audioTransceiver = peerConnection.addTransceiver('audio', {
|
const audioTransceiver = peerConnection.addTransceiver('audio', {
|
||||||
@@ -863,7 +891,8 @@ describe('Go2RTCSessionController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should escalate via the error callback after exhausting reconnect attempts', () => {
|
it('should escalate via the error callback after exhausting reconnect attempts', () => {
|
||||||
const { session, surfaces, websockets, createWebSocket, errorCallback } = setup();
|
const { session, surfaces, websockets, createWebSocket, streamErrorCallback } =
|
||||||
|
setup();
|
||||||
session.connect('http://host/api/ws?src=camera', surfaces, ['mse']);
|
session.connect('http://host/api/ws?src=camera', surfaces, ['mse']);
|
||||||
|
|
||||||
// Each fresh connection closes before loading, consuming one reconnect
|
// Each fresh connection closes before loading, consuming one reconnect
|
||||||
@@ -878,16 +907,17 @@ describe('Go2RTCSessionController', () => {
|
|||||||
websockets[3].fireClose();
|
websockets[3].fireClose();
|
||||||
|
|
||||||
expect(createWebSocket).toHaveBeenCalledTimes(4);
|
expect(createWebSocket).toHaveBeenCalledTimes(4);
|
||||||
expect(errorCallback).toHaveBeenCalledTimes(1);
|
expect(streamErrorCallback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// The socket dropped with no source reporting a cause.
|
// The socket dropped with no source reporting a cause.
|
||||||
expect(errorCallback).toHaveBeenCalledWith(null);
|
expect(streamErrorCallback).toHaveBeenCalledWith(null);
|
||||||
vi.advanceTimersByTime(2 * 1000);
|
vi.advanceTimersByTime(2 * 1000);
|
||||||
expect(createWebSocket).toHaveBeenCalledTimes(4);
|
expect(createWebSocket).toHaveBeenCalledTimes(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should escalate with the most recent source failure reason', () => {
|
it('should escalate with the most recent source failure reason', () => {
|
||||||
const { session, surfaces, websockets, binaryContexts, errorCallback } = setup();
|
const { session, surfaces, websockets, binaryContexts, streamErrorCallback } =
|
||||||
|
setup();
|
||||||
session.connect('http://host/api/ws?src=camera', surfaces, ['mse']);
|
session.connect('http://host/api/ws?src=camera', surfaces, ['mse']);
|
||||||
|
|
||||||
// Each attempt: the single binary source fails, which drains the mode
|
// Each attempt: the single binary source fails, which drains the mode
|
||||||
@@ -901,7 +931,7 @@ describe('Go2RTCSessionController', () => {
|
|||||||
websockets[3].fireOpen();
|
websockets[3].fireOpen();
|
||||||
binaryContexts[3].callbacks.failedCallback('unsupported');
|
binaryContexts[3].callbacks.failedCallback('unsupported');
|
||||||
|
|
||||||
expect(errorCallback).toHaveBeenCalledWith('unsupported');
|
expect(streamErrorCallback).toHaveBeenCalledWith('unsupported');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reset the reconnect budget after a successful media load', () => {
|
it('should reset the reconnect budget after a successful media load', () => {
|
||||||
@@ -911,7 +941,7 @@ describe('Go2RTCSessionController', () => {
|
|||||||
websockets,
|
websockets,
|
||||||
binaryContexts,
|
binaryContexts,
|
||||||
createWebSocket,
|
createWebSocket,
|
||||||
errorCallback,
|
streamErrorCallback,
|
||||||
} = setup();
|
} = setup();
|
||||||
session.connect('http://host/api/ws?src=camera', surfaces, ['mse']);
|
session.connect('http://host/api/ws?src=camera', surfaces, ['mse']);
|
||||||
|
|
||||||
@@ -933,7 +963,7 @@ describe('Go2RTCSessionController', () => {
|
|||||||
websockets[attempt + 1].fireOpen();
|
websockets[attempt + 1].fireOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(errorCallback).not.toHaveBeenCalled();
|
expect(streamErrorCallback).not.toHaveBeenCalled();
|
||||||
expect(createWebSocket).toHaveBeenCalledTimes(6);
|
expect(createWebSocket).toHaveBeenCalledTimes(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1081,7 +1111,8 @@ describe('Go2RTCSessionController', () => {
|
|||||||
surfaceCommittedCallback: vi.fn(),
|
surfaceCommittedCallback: vi.fn(),
|
||||||
getCardWideConfig: () => null,
|
getCardWideConfig: () => null,
|
||||||
mediaLoadedCallback,
|
mediaLoadedCallback,
|
||||||
errorCallback: vi.fn(),
|
streamErrorCallback: vi.fn(),
|
||||||
|
microphoneErrorCallback: vi.fn(),
|
||||||
},
|
},
|
||||||
{ createWebSocket, createBinarySource },
|
{ createWebSocket, createBinarySource },
|
||||||
);
|
);
|
||||||
@@ -1115,7 +1146,8 @@ describe('Go2RTCSessionController', () => {
|
|||||||
surfaceCommittedCallback: vi.fn(),
|
surfaceCommittedCallback: vi.fn(),
|
||||||
getCardWideConfig: () => null,
|
getCardWideConfig: () => null,
|
||||||
mediaLoadedCallback,
|
mediaLoadedCallback,
|
||||||
errorCallback: vi.fn(),
|
streamErrorCallback: vi.fn(),
|
||||||
|
microphoneErrorCallback: vi.fn(),
|
||||||
},
|
},
|
||||||
{ createWebSocket, createWebRTCSource },
|
{ createWebSocket, createWebRTCSource },
|
||||||
);
|
);
|
||||||
@@ -1141,7 +1173,8 @@ describe('Go2RTCSessionController', () => {
|
|||||||
surfaceCommittedCallback: vi.fn(),
|
surfaceCommittedCallback: vi.fn(),
|
||||||
getCardWideConfig: () => null,
|
getCardWideConfig: () => null,
|
||||||
mediaLoadedCallback: vi.fn(),
|
mediaLoadedCallback: vi.fn(),
|
||||||
errorCallback: vi.fn(),
|
streamErrorCallback: vi.fn(),
|
||||||
|
microphoneErrorCallback: vi.fn(),
|
||||||
},
|
},
|
||||||
{ createWebSocket },
|
{ createWebSocket },
|
||||||
);
|
);
|
||||||
@@ -1172,7 +1205,8 @@ describe('Go2RTCSessionController', () => {
|
|||||||
surfaceCommittedCallback: vi.fn(),
|
surfaceCommittedCallback: vi.fn(),
|
||||||
getCardWideConfig: () => null,
|
getCardWideConfig: () => null,
|
||||||
mediaLoadedCallback: vi.fn(),
|
mediaLoadedCallback: vi.fn(),
|
||||||
errorCallback: vi.fn(),
|
streamErrorCallback: vi.fn(),
|
||||||
|
microphoneErrorCallback: vi.fn(),
|
||||||
},
|
},
|
||||||
{ createWebSocket },
|
{ createWebSocket },
|
||||||
);
|
);
|
||||||
@@ -1212,7 +1246,8 @@ describe('Go2RTCSessionController', () => {
|
|||||||
surfaceCommittedCallback: vi.fn(),
|
surfaceCommittedCallback: vi.fn(),
|
||||||
getCardWideConfig: () => null,
|
getCardWideConfig: () => null,
|
||||||
mediaLoadedCallback: vi.fn(),
|
mediaLoadedCallback: vi.fn(),
|
||||||
errorCallback: vi.fn(),
|
streamErrorCallback: vi.fn(),
|
||||||
|
microphoneErrorCallback: vi.fn(),
|
||||||
},
|
},
|
||||||
{ createWebSocket, createBinarySource, createWebRTCSource },
|
{ createWebSocket, createBinarySource, createWebRTCSource },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ describe('WebRTCStreamSource', () => {
|
|||||||
|
|
||||||
const pc = new FakeRTCPeerConnection();
|
const pc = new FakeRTCPeerConnection();
|
||||||
const createPeerConnection = vi.fn(() => pc.asPeerConnection());
|
const createPeerConnection = vi.fn(() => pc.asPeerConnection());
|
||||||
|
const microphoneErrorCallback = vi.fn();
|
||||||
const source = new WebRTCStreamSource(context, {
|
const source = new WebRTCStreamSource(context, {
|
||||||
createPeerConnection,
|
createPeerConnection,
|
||||||
createMediaStream: (tracks) =>
|
createMediaStream: (tracks) =>
|
||||||
new FakeMediaStream(tracks as unknown as FakeMediaStreamTrack[]).asMediaStream(),
|
new FakeMediaStream(tracks as unknown as FakeMediaStreamTrack[]).asMediaStream(),
|
||||||
microphoneStream: options?.microphoneStream?.asMediaStream() ?? null,
|
microphoneStream: options?.microphoneStream?.asMediaStream() ?? null,
|
||||||
|
microphoneErrorCallback,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -42,6 +44,7 @@ describe('WebRTCStreamSource', () => {
|
|||||||
createPeerConnection,
|
createPeerConnection,
|
||||||
failedCallback,
|
failedCallback,
|
||||||
loadedCallback,
|
loadedCallback,
|
||||||
|
microphoneErrorCallback,
|
||||||
pc,
|
pc,
|
||||||
source,
|
source,
|
||||||
video,
|
video,
|
||||||
@@ -506,29 +509,77 @@ describe('WebRTCStreamSource', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should do nothing before there is a peer connection', async () => {
|
it('should do nothing before there is a peer connection', async () => {
|
||||||
const { source, failedCallback } = setup();
|
const { source, microphoneErrorCallback } = setup();
|
||||||
await source.setMicrophoneStream(
|
await source.setMicrophoneStream(
|
||||||
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
|
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(failedCallback).not.toHaveBeenCalled();
|
expect(microphoneErrorCallback).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail when a current replaceTrack rejects', async () => {
|
it.each([
|
||||||
|
[
|
||||||
|
'what the browser said when the rejection has a message',
|
||||||
|
new DOMException('The peer connection is closed', 'InvalidStateError'),
|
||||||
|
'The peer connection is closed',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'the rejection type when there is no message to quote',
|
||||||
|
new DOMException('', 'InvalidStateError'),
|
||||||
|
'InvalidStateError',
|
||||||
|
],
|
||||||
|
['nothing when the rejection is not an object', 'nope', undefined],
|
||||||
|
[
|
||||||
|
'nothing when the rejection describes itself with neither',
|
||||||
|
{ message: 5, name: 7 },
|
||||||
|
undefined,
|
||||||
|
],
|
||||||
|
] as const)(
|
||||||
|
'should report %s when a current replaceTrack rejects',
|
||||||
|
async (_summary, rejection, expected) => {
|
||||||
|
const { source, pc, microphoneErrorCallback } = setup();
|
||||||
|
source.start();
|
||||||
|
pc.getMicrophoneTransceiver().sender.replaceTrack.mockRejectedValue(rejection);
|
||||||
|
await source.setMicrophoneStream(
|
||||||
|
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(microphoneErrorCallback).toHaveBeenCalledWith(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should not fail the stream source when the microphone cannot attach', async () => {
|
||||||
const { source, pc, failedCallback } = setup();
|
const { source, pc, failedCallback } = setup();
|
||||||
source.start();
|
source.start();
|
||||||
pc.getMicrophoneTransceiver().sender.replaceTrack.mockRejectedValue(
|
pc.getMicrophoneTransceiver().sender.replaceTrack.mockRejectedValue(
|
||||||
new Error('replace failed'),
|
new DOMException('replace failed', 'InvalidStateError'),
|
||||||
);
|
);
|
||||||
await source.setMicrophoneStream(
|
await source.setMicrophoneStream(
|
||||||
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
|
new FakeMediaStream([new FakeMediaStreamTrack('audio')]).asMediaStream(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(failedCallback).toHaveBeenCalledWith('two_way_audio_error');
|
// The inbound video is unaffected by an outbound audio failure, so the
|
||||||
|
// source must keep running rather than failing over to another one.
|
||||||
|
expect(failedCallback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not report a rejection when detaching the microphone', async () => {
|
||||||
|
const stream = new FakeMediaStream([new FakeMediaStreamTrack('audio')]);
|
||||||
|
const { source, pc, microphoneErrorCallback } = setup({
|
||||||
|
microphoneStream: stream,
|
||||||
|
});
|
||||||
|
source.start();
|
||||||
|
pc.getMicrophoneTransceiver().sender.replaceTrack.mockRejectedValue(
|
||||||
|
new DOMException('The peer connection is closed', 'InvalidStateError'),
|
||||||
|
);
|
||||||
|
await source.setMicrophoneStream(null);
|
||||||
|
|
||||||
|
// Ignore the error, the user is not trying to be heard anyway.
|
||||||
|
expect(microphoneErrorCallback).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore a stale replaceTrack rejection after stop', async () => {
|
it('should ignore a stale replaceTrack rejection after stop', async () => {
|
||||||
const { source, pc, failedCallback } = setup();
|
const { source, pc, microphoneErrorCallback } = setup();
|
||||||
source.start();
|
source.start();
|
||||||
let rejectReplace: (reason: Error) => void = () => {};
|
let rejectReplace: (reason: Error) => void = () => {};
|
||||||
pc.getMicrophoneTransceiver().sender.replaceTrack.mockReturnValue(
|
pc.getMicrophoneTransceiver().sender.replaceTrack.mockReturnValue(
|
||||||
@@ -543,7 +594,7 @@ describe('WebRTCStreamSource', () => {
|
|||||||
rejectReplace(new Error('replace failed'));
|
rejectReplace(new Error('replace failed'));
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
expect(failedCallback).not.toHaveBeenCalled();
|
expect(microphoneErrorCallback).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-5
@@ -1,21 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { mapFailureReasonToIssueReason } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/failure-reason';
|
import { mapStreamFailureReasonToIssueReason } from '../../../../../../src/components-lib/live/providers/go2rtc-experimental/utils/stream-failure-reason';
|
||||||
|
|
||||||
describe('mapFailureReasonToIssueReason', () => {
|
describe('mapStreamFailureReasonToIssueReason', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['connect_timeout', 'not_loading'],
|
['connect_timeout', 'not_loading'],
|
||||||
['negotiation_timeout', 'not_loading'],
|
['negotiation_timeout', 'not_loading'],
|
||||||
['media_error', 'playback_error'],
|
['media_error', 'playback_error'],
|
||||||
['buffer_overflow', 'playback_error'],
|
['buffer_overflow', 'playback_error'],
|
||||||
['two_way_audio_error', 'two_way_audio_error'],
|
|
||||||
['server_error', 'server_error'],
|
['server_error', 'server_error'],
|
||||||
['unsupported', 'unsupported'],
|
['unsupported', 'unsupported'],
|
||||||
] as const)('should map %s to the %s cause', (reason, expected) => {
|
] as const)('should map %s to the %s cause', (reason, expected) => {
|
||||||
expect(mapFailureReasonToIssueReason(reason)).toBe(expected);
|
expect(mapStreamFailureReasonToIssueReason(reason)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map a null reason to a generic playback error', () => {
|
it('should map a null reason to a generic playback error', () => {
|
||||||
expect(mapFailureReasonToIssueReason(null)).toBe('playback_error');
|
expect(mapStreamFailureReasonToIssueReason(null)).toBe('playback_error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -19,11 +19,11 @@ it('should forward the reason and detail as the event detail', () => {
|
|||||||
|
|
||||||
dispatchLiveErrorEvent(element, {
|
dispatchLiveErrorEvent(element, {
|
||||||
reason: 'unsupported',
|
reason: 'unsupported',
|
||||||
detail: 'Codec not supported',
|
description: 'Codec not supported',
|
||||||
});
|
});
|
||||||
expect(handler).toHaveBeenCalledWith(
|
expect(handler).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
detail: { reason: 'unsupported', detail: 'Codec not supported' },
|
detail: { reason: 'unsupported', description: 'Codec not supported' },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,9 +60,6 @@ describe('MediaLoadedInfoSourceController', () => {
|
|||||||
});
|
});
|
||||||
expect(ev.detail.signal).toBeInstanceOf(AbortSignal);
|
expect(ev.detail.signal).toBeInstanceOf(AbortSignal);
|
||||||
expect(ev.detail.signal.aborted).toBe(false);
|
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', () => {
|
it('should dedup structurally-equal info', () => {
|
||||||
@@ -179,10 +176,6 @@ describe('MediaLoadedInfoSourceController', () => {
|
|||||||
expect(firstSignal).not.toBe(secondSignal);
|
expect(firstSignal).not.toBe(secondSignal);
|
||||||
expect(firstSignal.aborted).toBe(true);
|
expect(firstSignal.aborted).toBe(true);
|
||||||
expect(secondSignal.aborted).toBe(false);
|
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', () => {
|
it('should be a no-op when there is nothing to re-dispatch', () => {
|
||||||
@@ -248,6 +241,79 @@ describe('MediaLoadedInfoSourceController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should retire the registration so consumers clean up', () => {
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new MediaLoadedInfoSourceController(host, {
|
||||||
|
getTargetID: () => 'target-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||||
|
|
||||||
|
controller.set(createMediaLoadedInfo());
|
||||||
|
const signal = (handler.mock.calls[0][0] as CustomEvent).detail.signal;
|
||||||
|
const cleanup = vi.fn();
|
||||||
|
signal.addEventListener('abort', cleanup);
|
||||||
|
|
||||||
|
controller.clear();
|
||||||
|
|
||||||
|
expect(cleanup).toHaveBeenCalled();
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave nothing to replay on a later reconnect', () => {
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new MediaLoadedInfoSourceController(host, {
|
||||||
|
getTargetID: () => 'target-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||||
|
|
||||||
|
controller.set(createMediaLoadedInfo());
|
||||||
|
handler.mockClear();
|
||||||
|
|
||||||
|
// The host destroyed its media, so the reconnect has nothing truthful to
|
||||||
|
// announce: replaying would describe a player that no longer exists.
|
||||||
|
controller.clear();
|
||||||
|
controller.hostDisconnected();
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should announce fresh media after a clear', () => {
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new MediaLoadedInfoSourceController(host, {
|
||||||
|
getTargetID: () => 'target-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
host.addEventListener('advanced-camera-card:media:loaded', handler);
|
||||||
|
|
||||||
|
controller.set(createMediaLoadedInfo());
|
||||||
|
controller.clear();
|
||||||
|
handler.mockClear();
|
||||||
|
|
||||||
|
// The same info is no longer a duplicate: the dedup compares against a
|
||||||
|
// load that has been forgotten.
|
||||||
|
controller.set(createMediaLoadedInfo());
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be safe to call when nothing is active', () => {
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new MediaLoadedInfoSourceController(host, {
|
||||||
|
getTargetID: () => 'target-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not throw.
|
||||||
|
controller.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('hostDisconnected', () => {
|
describe('hostDisconnected', () => {
|
||||||
it('should abort the active controller so consumers clean up', () => {
|
it('should abort the active controller so consumers clean up', () => {
|
||||||
const host = createLitElement();
|
const host = createLitElement();
|
||||||
|
|||||||
@@ -28,6 +28,29 @@ describe('FrameStallWatchdog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('source lifecycle', () => {
|
describe('source lifecycle', () => {
|
||||||
|
it('should hand a later subscriber what has already been observed', () => {
|
||||||
|
const watchdog = new FrameStallWatchdog(createConfig());
|
||||||
|
watchdog.subscribe(vi.fn());
|
||||||
|
watchdog.notifyFrame();
|
||||||
|
|
||||||
|
const later = vi.fn();
|
||||||
|
watchdog.subscribe(later);
|
||||||
|
|
||||||
|
// Observation has been continuous, so the frame just seen is current
|
||||||
|
// evidence for the newcomer too.
|
||||||
|
expect(later).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should tell a later subscriber nothing before anything is observed', () => {
|
||||||
|
const watchdog = new FrameStallWatchdog(createConfig());
|
||||||
|
watchdog.subscribe(vi.fn());
|
||||||
|
|
||||||
|
const later = vi.fn();
|
||||||
|
watchdog.subscribe(later);
|
||||||
|
|
||||||
|
expect(later).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should start the source only on the first subscriber', () => {
|
it('should start the source only on the first subscriber', () => {
|
||||||
const config = createConfig();
|
const config = createConfig();
|
||||||
const watchdog = new FrameStallWatchdog(config);
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
@@ -183,6 +206,23 @@ describe('FrameStallWatchdog', () => {
|
|||||||
expect(callback).toHaveBeenCalledTimes(1);
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
expect(callback).toHaveBeenCalledWith(true);
|
expect(callback).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use the stall window in force when the timer is armed', () => {
|
||||||
|
let stallAfterSeconds = 30;
|
||||||
|
const watchdog = new FrameStallWatchdog(
|
||||||
|
createConfig({ getStallAfterSeconds: () => stallAfterSeconds }),
|
||||||
|
);
|
||||||
|
const callback = vi.fn();
|
||||||
|
watchdog.subscribe(callback);
|
||||||
|
|
||||||
|
// The source slows down: the frame that arrives re-arms with the new,
|
||||||
|
// shorter window rather than the one the watchdog started with.
|
||||||
|
stallAfterSeconds = 5;
|
||||||
|
watchdog.notifyFrame();
|
||||||
|
vi.advanceTimersByTime(5 * 1000);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenLastCalledWith(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('no available source', () => {
|
describe('no available source', () => {
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ const STALL_MS = STALL_SECONDS * 1000;
|
|||||||
const createImageMediaPlayerWithLiveness = (
|
const createImageMediaPlayerWithLiveness = (
|
||||||
isFrameExpected: () => boolean,
|
isFrameExpected: () => boolean,
|
||||||
getImageCallback: () => HTMLImageElement | null,
|
getImageCallback: () => HTMLImageElement | null,
|
||||||
stallWindowSeconds = STALL_SECONDS,
|
stallAfterSeconds = STALL_SECONDS,
|
||||||
): ImageMediaPlayerController =>
|
): ImageMediaPlayerController =>
|
||||||
new ImageMediaPlayerController(createLitElement(), getImageCallback, {
|
new ImageMediaPlayerController(createLitElement(), getImageCallback, {
|
||||||
livenessOptions: { isFrameExpected, stallWindowSeconds },
|
livenessOptions: { isFrameExpected, getStallAfterSeconds: () => stallAfterSeconds },
|
||||||
});
|
});
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|||||||
Reference in New Issue
Block a user