feat: Surface the provider's error cause in the media_unavailable notification (#2599)

- Closes: #2592
This commit is contained in:
Dermot Duffy
2026-07-22 20:45:49 -07:00
committed by GitHub
parent 5a549c0326
commit 448fa2d9f1
18 changed files with 255 additions and 90 deletions
@@ -1,4 +1,4 @@
import type { MediaUnavailableIssueReason } from '../../../../card-controller/issues/issues/media-unavailable';
import type { LiveError } from '../../utils/dispatch-live-error';
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
@@ -39,9 +39,7 @@ export class ProviderErrorDetector implements LivenessDetector {
return this._verdict;
}
private _handler = (
ev: CustomEvent<MediaUnavailableIssueReason | undefined>,
): void => {
private _handler = (ev: CustomEvent<LiveError>): void => {
ev.stopPropagation();
if (this._verdict.state !== 'not_live') {
// Authoritative: an explicit provider error overrides even direct frame
@@ -50,7 +48,8 @@ export class ProviderErrorDetector implements LivenessDetector {
this._verdict = {
state: 'not_live',
authority: 'hard',
reason: ev.detail ?? 'playback_error',
reason: ev.detail.reason ?? 'playback_error',
description: ev.detail.detail,
};
this._onChange();
}
@@ -35,6 +35,10 @@ export type LivenessVerdict =
authority: LivenessAuthority;
reason: MediaUnavailableIssueReason;
// Free text naming the specific failure, when the detector's source knew
// it. Omitted when only the categorical reason is known.
description?: string;
// Whether the wrapper should replace the provider with a reconnecting
// placeholder (a silent freeze, e.g. an unavailable camera). Omitted when
// the provider renders its own error and should stay mounted.
@@ -50,6 +54,9 @@ export type LivenessVerdict =
interface StreamFailure {
reason: MediaUnavailableIssueReason;
// Free text naming the specific failure, when known.
description?: string;
// Whether the wrapper should replace the provider with a reconnecting
// placeholder (a silent freeze, e.g. an unavailable camera). False when the
// provider renders its own error and should stay mounted.
@@ -133,7 +140,11 @@ export class StreamLivenessController implements ReactiveController {
public getFailure(): StreamFailure | null {
const verdict = this._getVerdict();
return verdict.state === 'not_live'
? { reason: verdict.reason, renderPlaceholder: !!verdict.renderPlaceholder }
? {
reason: verdict.reason,
description: verdict.description,
renderPlaceholder: !!verdict.renderPlaceholder,
}
: null;
}
@@ -170,14 +181,17 @@ export class StreamLivenessController implements ReactiveController {
private _onDetectorChange(): void {
const verdict = this._getVerdict();
if (verdict.state === 'not_live') {
this._triggerMediaUnavailableIssue(verdict.reason);
this._triggerMediaUnavailableIssue(verdict.reason, verdict.description);
}
this._host.requestUpdate();
}
// Tell the issue framework this target's media is not loaded, surfacing the
// media_unavailable issue (status bar + retry) and its throttled reload.
private _triggerMediaUnavailableIssue(reason: MediaUnavailableIssueReason): void {
private _triggerMediaUnavailableIssue(
reason: MediaUnavailableIssueReason,
description?: string,
): void {
const targetID = this._config.getTargetID();
if (!targetID) {
return;
@@ -186,6 +200,7 @@ export class StreamLivenessController implements ReactiveController {
key: 'media_unavailable',
targetID,
reason,
description,
});
}
}
@@ -1,20 +1,24 @@
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable';
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
// What a provider knows about its own failure.
export interface LiveError {
// The cause, drawn from the fixed set the card can describe and illustrate.
// Absent when the provider cannot narrow it down, in which case the liveness
// detector falls back to a generic playback error.
reason?: MediaUnavailableIssueReason;
// Free text naming the specific failure (e.g. "Failed to start WebRTC stream:
// ..."). Absent when the provider has none.
detail?: string;
}
declare global {
interface HTMLElementEventMap {
'advanced-camera-card:live:error': CustomEvent<
MediaUnavailableIssueReason | undefined
>;
'advanced-camera-card:live:error': CustomEvent<LiveError>;
}
}
// The optional reason lets a provider that knows why it failed drive a specific
// media-unavailable message; absent, the liveness detector falls back to a
// generic playback error.
export function dispatchLiveErrorEvent(
element: EventTarget,
reason?: MediaUnavailableIssueReason,
): void {
fireAdvancedCameraCardEvent(element, 'live:error', reason);
export function dispatchLiveErrorEvent(element: EventTarget, error?: LiveError): void {
fireAdvancedCameraCardEvent(element, 'live:error', error ?? {});
}