diff --git a/src/card-controller/call/manager.ts b/src/card-controller/call/manager.ts index 220a5b18..bb375807 100644 --- a/src/card-controller/call/manager.ts +++ b/src/card-controller/call/manager.ts @@ -74,7 +74,7 @@ export class CallManager { .getCameraIDsWithCapability('live') .has(parentID) ) { - this._notifyError('error.call_invalid_target', inbound); + this._notifyError('error.call_invalid_target', { inbound }); return false; } @@ -261,6 +261,22 @@ export class CallManager { 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 // and unanswered timer, drops the active call session, clears the call // condition state, and de-registers the condition-state listener. Driven by @@ -367,14 +383,21 @@ export class CallManager { // Helpers // ========================================================================= - private _notifyError(messageKey: string, inbound: boolean): void { - if (inbound) { - // Don't show errors on inbound calls. + // `context` is a diagnostic the user can quote when reporting the problem. + private _notifyError( + 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; } + const context = options?.context; this._api.getNotificationManager().setNotification( createNotificationFromText(localize(messageKey), { heading: { text: localize('error.call_unavailable_heading') }, + ...(context && { context }), }), ); } @@ -385,7 +408,7 @@ export class CallManager { const microphoneManager = this._api.getMicrophoneManager(); if (!microphoneManager.isSupported()) { - this._notifyError('error.call_microphone_unsupported', inbound); + this._notifyError('error.call_microphone_unsupported', { inbound }); return false; } @@ -394,7 +417,7 @@ export class CallManager { // there clears the denial, and failing there reports it. An outbound call // needs the microphone immediately, so a known denial ends it here. if (!inbound && microphoneManager.isForbidden()) { - this._notifyError('error.call_microphone_forbidden', inbound); + this._notifyError('error.call_microphone_forbidden', { inbound }); return false; } @@ -428,7 +451,7 @@ export class CallManager { } if (!connected) { - this._notifyError('error.call_microphone_forbidden', false); + this._notifyError('error.call_microphone_forbidden'); return false; } return true; @@ -453,7 +476,7 @@ export class CallManager { .getStore() .getAllDependentCameras(cameraID, '2-way-audio'); if (!eligibleCameraIDs.has(streamID)) { - this._notifyError('error.call_invalid_target', inbound); + this._notifyError('error.call_invalid_target', { inbound }); return null; } return streamID; @@ -480,7 +503,7 @@ export class CallManager { .getAllDependentCameras(parentID, '2-way-audio'), ]; if (!candidates.length) { - this._notifyError('error.call_no_two_way_audio', inbound); + this._notifyError('error.call_no_two_way_audio', { inbound }); return null; } return candidates[0]; diff --git a/src/card-controller/issues/issue-manager.ts b/src/card-controller/issues/issue-manager.ts index e546805a..70b1a80b 100644 --- a/src/card-controller/issues/issue-manager.ts +++ b/src/card-controller/issues/issue-manager.ts @@ -1,4 +1,4 @@ -import type { IssueTriggerContext } from 'issue'; +import type { IssueResolveContext, IssueTriggerContext } from 'issue'; import type { ConditionStateChange } from '../../condition-trigger/conditions/types'; import { contentsChanged, ignoreFunctionIdentity } from '../../utils/basic'; @@ -11,6 +11,7 @@ import type { IssueKey, IssuePresence, IssueReadOnlyState, + IssueResolveContextKey, IssueTriggerContextKey, } from './types'; @@ -71,6 +72,16 @@ export class IssueManager { 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( + 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 // the issue presence changed, and schedule retries. // diff --git a/src/card-controller/issues/issues/media-unavailable.ts b/src/card-controller/issues/issues/media-unavailable.ts index 1b8507ba..4ca9398c 100644 --- a/src/card-controller/issues/issues/media-unavailable.ts +++ b/src/card-controller/issues/issues/media-unavailable.ts @@ -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 { @@ -23,7 +23,6 @@ export type MediaUnavailableIssueReason = | 'playback_error' | 'server_error' | 'stalled' - | 'two_way_audio_error' | 'unsupported'; declare module 'issue' { @@ -37,6 +36,12 @@ declare module 'issue' { description?: string; }; } + + interface IssueResolveContext { + media_unavailable: { + targetID: string; + }; + } } // What is known about one target's failure. @@ -74,10 +79,6 @@ export const MEDIA_UNAVAILABLE_REASONS: Record< localizationKey: 'issues.media_unavailable.reasons.stalled', icon: 'mdi:motion-pause', }, - two_way_audio_error: { - localizationKey: 'issues.media_unavailable.reasons.two_way_audio_error', - icon: 'mdi:microphone-off', - }, unsupported: { localizationKey: 'issues.media_unavailable.reasons.unsupported', icon: 'mdi:video-off-outline', @@ -102,21 +103,20 @@ export class MediaUnavailableIssue implements Issue { this._api = api; 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 .getMediaLoadedInfoManager() .subscribe((change) => { - // A reconnect replay (`cached`) did not actually reload the media, and - // unload / select changes are irrelevant here; only a genuine load - // clears the error. - if (change.type === 'load' && !change.cached) { + if (change.type === 'load') { 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 { @@ -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. // ========================================================================= @@ -139,8 +147,8 @@ export class MediaUnavailableIssue implements Issue { // A known error for the current target activates immediately, even if its // (frozen) media still reads as loaded (it might be loaded but then // reported a playback error that stops playback but leaves the player - // attached). Errors are cleared out-of-band by `_onMediaLoad` on a genuine - // reload. + // attached). Errors are cleared out-of-band, by `resolve` or by + // `_onMediaLoad`. if (this._hasError(state)) { this._activate(); return; @@ -155,9 +163,16 @@ export class MediaUnavailableIssue implements Issue { 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 { - 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?.(); } } @@ -183,6 +198,7 @@ export class MediaUnavailableIssue implements Issue { public getNotification(): Notification { const targets = new Map(this._erroredTargets); + // 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 // on another target took over, or the view moved on), _timerTargetID lingers @@ -258,10 +274,12 @@ export class MediaUnavailableIssue implements Issue { public retry(): boolean { // Build the set of targets to retry: all errored targets plus the - // target the pending timer was tracking (so a user-initiated retry - // works even before the timeout fires). + // target the pending timer is tracking (so a user-initiated retry + // 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()); - if (this._timerTargetID) { + if (this._timerTargetID && this._timer.isRunning()) { retryTargets.add(this._timerTargetID); } @@ -269,6 +287,8 @@ export class MediaUnavailableIssue implements Issue { 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 mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) }; for (const id of retryTargets) { @@ -276,11 +296,11 @@ export class MediaUnavailableIssue implements Issue { } // Intentionally keep _issueActive, _erroredTargets, and the pending - // timer in place. The issue stays visible while the provider - // re-attempts loading underneath. If the retry succeeds, the fresh media - // load clears everything (_onMediaLoad drops the errored target). If it - // fails silently (e.g. bogus stream name), the error stays visible - // immediately -- no new 10s grace period. + // timer in place. The issue stays visible while the provider re-attempts + // loading underneath. If the retry succeeds, the fresh load clears a + // not-loading error and the rebuilt provider's liveness observation + // resolves a stream error. If it fails silently (e.g. bogus stream name), + // the error stays visible immediately -- no new 10s grace period. this._api.getViewManager().setViewWithMergedContext({ mediaEpoch }); return false; } @@ -314,6 +334,17 @@ export class MediaUnavailableIssue implements Issue { // 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 // 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 @@ -337,7 +368,7 @@ export class MediaUnavailableIssue implements Issue { this._timerTargetID = targetID; this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => { // 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._onChange?.(); }); diff --git a/src/card-controller/issues/state-manager.ts b/src/card-controller/issues/state-manager.ts index ea18c0ab..13a2be2c 100644 --- a/src/card-controller/issues/state-manager.ts +++ b/src/card-controller/issues/state-manager.ts @@ -1,4 +1,4 @@ -import type { IssueTriggerContext } from 'issue'; +import type { IssueResolveContext, IssueTriggerContext } from 'issue'; import { summarizeNotification } from '../../components-lib/notification/summarize'; import type { ConditionState } from '../../condition-trigger/conditions/types'; @@ -11,6 +11,7 @@ import type { IssueKey, IssuePresence, IssueReadOnlyState, + IssueResolveContextKey, IssueTriggerContextKey, KeyedIssueDescription, } 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 { @@ -56,6 +58,18 @@ export class IssueStateManager implements IssueReadOnlyState { this._logIfNew(issue); } + public resolve( + 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 { for (const issue of this._issues.values()) { issue.detectDynamic?.(context); diff --git a/src/card-controller/issues/types.ts b/src/card-controller/issues/types.ts index 454ee032..24dee78b 100644 --- a/src/card-controller/issues/types.ts +++ b/src/card-controller/issues/types.ts @@ -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 { Notification } from '../../config/schema/actions/types'; @@ -47,6 +47,11 @@ export type IssueTriggerEventData = { [K in IssueTriggerContextKey]: { key: K } & IssueTriggerContext[K]; }[IssueTriggerContextKey]; +export type IssueResolveContextKey = keyof IssueResolveContext; +export type IssueResolveEventData = { + [K in IssueResolveContextKey]: { key: K } & IssueResolveContext[K]; +}[IssueResolveContextKey]; + export interface Issue { readonly key: IssueKey; @@ -59,6 +64,11 @@ export interface Issue { // Explicitly trigger this issue with key-specific context. 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; getIssue(): IssueDescription | null; diff --git a/src/card-controller/media-info-manager.ts b/src/card-controller/media-info-manager.ts index 44280759..50cf69fc 100644 --- a/src/card-controller/media-info-manager.ts +++ b/src/card-controller/media-info-manager.ts @@ -17,9 +17,8 @@ interface ActiveEntry { // A change delivered to subscribers: a target's active media loading or // unloading, or the selected target changing. export type MediaLoadedInfoChange = - // A target's media (re)loaded. `cached` marks a replay (a reconnect - // re-dispatch, not an actual reload) rather than a genuine load. - | { type: 'load'; targetID: string; info: MediaLoadedInfo; cached: boolean } + // A target's media (re)loaded, or a reconnect re-dispatched its last load. + | { type: 'load'; targetID: string; info: MediaLoadedInfo } // A target's media was retired. | { type: 'unload'; targetID: string } @@ -68,11 +67,7 @@ export class MediaLoadedInfoManager { } } - public set( - mediaLoadedInfo: MediaLoadedInfo, - owner: MediaLoadedInfoOwner, - cached?: boolean, - ): void { + public set(mediaLoadedInfo: MediaLoadedInfo, owner: MediaLoadedInfoOwner): void { if (!isValidMediaLoadedInfo(mediaLoadedInfo) || !mediaLoadedInfo.targetID) { return; } @@ -93,12 +88,7 @@ export class MediaLoadedInfoManager { // Notify for every target, not just the selected one (e.g. a background // grid camera). - this._notify({ - type: 'load', - targetID, - info: mediaLoadedInfo, - cached: cached ?? false, - }); + this._notify({ type: 'load', targetID, info: mediaLoadedInfo }); } public setSelected(targetID: string | null): void { @@ -132,7 +122,7 @@ export class MediaLoadedInfoManager { if (!(owner instanceof HTMLElement) || !targetID) { return; } - this.set(ev.detail.info, owner, ev.detail.cached); + this.set(ev.detail.info, owner); onAbort(ev.detail.signal, () => this._clearTarget(targetID, owner)); } diff --git a/src/card.ts b/src/card.ts index 65d9ef8f..ee273f73 100644 --- a/src/card.ts +++ b/src/card.ts @@ -16,8 +16,13 @@ import 'web-dialog'; import { actionHandler } from './action-handler-directive.js'; import { ConfigManager } from './card-controller/config/config-manager'; 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 type { MicrophoneError } from './components-lib/live/utils/dispatch-microphone-error.js'; import { MenuButtonController } from './components-lib/menu-button-controller'; import './components/effects/effects'; @@ -427,6 +432,16 @@ class AdvancedCameraCard extends LitElement { detail: { key, ...context }, }: CustomEvent) => this._controller.getIssueManager().trigger(key, context)} + @advanced-camera-card:issue:resolve=${({ + detail: { key, ...context }, + }: CustomEvent) => + this._controller.getIssueManager().resolve(key, context)} + @advanced-camera-card:microphone:error=${({ + detail, + }: CustomEvent) => + this._controller + .getCallManager() + .reportCallMicrophoneError(detail.targetID, detail.description)} @advanced-camera-card:media:loaded=${( ev: CustomEvent, ) => this._controller.getMediaLoadedInfoManager().handleLoadEvent(ev)} diff --git a/src/components-lib/live/liveness/detectors/entity-availability.ts b/src/components-lib/live/liveness/detectors/entity-availability.ts index 59b45c5f..3a6e5785 100644 --- a/src/components-lib/live/liveness/detectors/entity-availability.ts +++ b/src/components-lib/live/liveness/detectors/entity-availability.ts @@ -43,7 +43,8 @@ export class EntityAvailabilityDetector implements LivenessDetector { public subscribe(): void { this._active = true; - this._watch(); + this._subscribeOrUnsubscribeFromCameraEntity(); + this._check(); } public unsubscribe(): void { @@ -56,32 +57,34 @@ export class EntityAvailabilityDetector implements LivenessDetector { } public reset(): void { - // Re-point the subscription at the (possibly different) camera entity and - // start fresh. this._verdict = { state: 'unknown' }; this._timer.stop(); - this._watch(); + + // 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 { return this._verdict; } - // Point the subscription at the current camera entity and re-check its state. - private _watch(): void { + private _subscribeOrUnsubscribeFromCameraEntity(): void { if (!this._active) { return; } const stateWatcher = this._config.getStateWatcher(); const entityID = this._config.getCameraEntity(); - if (entityID !== this._watchedEntity) { - stateWatcher?.unsubscribe(this._onEntityStateChange); - this._watchedEntity = entityID; - if (entityID) { - stateWatcher?.subscribe(this._onEntityStateChange, [entityID]); - } + if (entityID === this._watchedEntity) { + return; + } + stateWatcher?.unsubscribe(this._onEntityStateChange); + this._watchedEntity = entityID; + if (entityID) { + stateWatcher?.subscribe(this._onEntityStateChange, [entityID]); } - this._check(); } private _onEntityStateChange = (difference: HassStateDifference): void => @@ -93,6 +96,9 @@ export class EntityAvailabilityDetector implements LivenessDetector { this._evaluate(difference.newState.state); private _check(): void { + if (!this._active) { + return; + } const stateObj = this._watchedEntity ? this._config.getHASS()?.states[this._watchedEntity] : undefined; diff --git a/src/components-lib/live/liveness/detectors/media-player-liveness.ts b/src/components-lib/live/liveness/detectors/media-player-liveness.ts index 77206f60..eb37acba 100644 --- a/src/components-lib/live/liveness/detectors/media-player-liveness.ts +++ b/src/components-lib/live/liveness/detectors/media-player-liveness.ts @@ -114,6 +114,13 @@ export class MediaPlayerLivenessDetector implements LivenessDetector { this._watchedPlayer = target; 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 // frame or a stall is observed. this._unsubscribeLiveness = player.subscribeLiveness((isLive) => diff --git a/src/components-lib/live/liveness/detectors/provider-error.ts b/src/components-lib/live/liveness/detectors/provider-error.ts index a3814c12..ac23080c 100644 --- a/src/components-lib/live/liveness/detectors/provider-error.ts +++ b/src/components-lib/live/liveness/detectors/provider-error.ts @@ -49,7 +49,7 @@ export class ProviderErrorDetector implements LivenessDetector { state: 'not_live', authority: 'hard', reason: ev.detail.reason ?? 'playback_error', - description: ev.detail.detail, + description: ev.detail.description, }; this._onChange(); } diff --git a/src/components-lib/live/liveness/stream-liveness-controller.ts b/src/components-lib/live/liveness/stream-liveness-controller.ts index d6327062..954dc816 100644 --- a/src/components-lib/live/liveness/stream-liveness-controller.ts +++ b/src/components-lib/live/liveness/stream-liveness-controller.ts @@ -3,7 +3,10 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit'; import type { Camera } from '../../../camera-manager/camera'; import type { StateWatcherSubscriptionInterface } from '../../../card-controller/hass/state-watcher'; 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 { HomeAssistant } from '../../../ha/types'; import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event'; @@ -96,6 +99,7 @@ export class StreamLivenessController implements ReactiveController { private _host: ReactiveControllerHost & HTMLElement; private _config: StreamLivenessControllerConfig; private _detectors: LivenessDetector[]; + private _resetting = false; constructor( host: ReactiveControllerHost & HTMLElement, @@ -150,7 +154,20 @@ export class StreamLivenessController implements ReactiveController { // Discard detector state on a stream change (e.g. a stream switch). public reset(): void { - this._detectors.forEach((detector) => detector.reset?.()); + // 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?.()); + } finally { + this._resetting = false; + } + + this._onDetectorChange(); } // Reduce the detectors to a single verdict. Direct evidence from the media @@ -179,9 +196,15 @@ export class StreamLivenessController implements ReactiveController { } private _onDetectorChange(): void { + if (this._resetting) { + return; + } + const verdict = this._getVerdict(); if (verdict.state === 'not_live') { this._triggerMediaUnavailableIssue(verdict.reason, verdict.description); + } else if (verdict.state === 'live') { + this._resolveMediaUnavailableIssue(); } this._host.requestUpdate(); } @@ -203,4 +226,15 @@ export class StreamLivenessController implements ReactiveController { description, }); } + + private _resolveMediaUnavailableIssue(): void { + const targetID = this._config.getTargetID(); + if (!targetID) { + return; + } + fireAdvancedCameraCardEvent(this._host, 'issue:resolve', { + key: 'media_unavailable', + targetID, + }); + } } diff --git a/src/components-lib/live/providers/go2rtc-experimental/image-surface-controller.ts b/src/components-lib/live/providers/go2rtc-experimental/image-surface-controller.ts index 00a4f0b4..c05305b1 100644 --- a/src/components-lib/live/providers/go2rtc-experimental/image-surface-controller.ts +++ b/src/components-lib/live/providers/go2rtc-experimental/image-surface-controller.ts @@ -7,11 +7,11 @@ import { OffscreenImage } from './offscreen-image'; import type { ImageSurface } from './session-controller'; // 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. interface ImageSurfaceLivenessOptions { isFrameExpected: () => boolean; - stallWindowSeconds?: number; + getStallAfterSeconds?: () => number; } interface ImageSurfaceOptions { diff --git a/src/components-lib/live/providers/go2rtc-experimental/session-controller.ts b/src/components-lib/live/providers/go2rtc-experimental/session-controller.ts index 6992ff50..3d839e67 100644 --- a/src/components-lib/live/providers/go2rtc-experimental/session-controller.ts +++ b/src/components-lib/live/providers/go2rtc-experimental/session-controller.ts @@ -96,7 +96,12 @@ interface Go2RTCSessionCallbacks { // 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 // (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 @@ -184,7 +189,7 @@ export class Go2RTCSessionController { // 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. // 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); @@ -227,7 +232,7 @@ export class Go2RTCSessionController { public reset(): void { this._retryTimer.reset(); - this._lastFailureReason = null; + this._lastStreamFailureReason = null; this._teardownLanes(); this._channel?.close(); @@ -359,7 +364,7 @@ export class Go2RTCSessionController { }, failedCallback: (reason: StreamSourceFailureReason) => { if (source) { - this._lastFailureReason = reason; + this._lastStreamFailureReason = reason; this._logSourceFailure('binary', reason, mode); this._handleBinaryFailed(context, source); } @@ -448,7 +453,7 @@ export class Go2RTCSessionController { }, failedCallback: (reason) => { if (source) { - this._lastFailureReason = reason; + this._lastStreamFailureReason = reason; this._logSourceFailure('webrtc', reason); this._handleWebRTCFailed(context, source); } @@ -458,6 +463,7 @@ export class Go2RTCSessionController { source = (this._options?.createWebRTCSource ?? createWebRTCSource)(sourceContext, { microphoneStream: this._microphoneStream, + microphoneErrorCallback: (error) => this._callbacks.microphoneErrorCallback(error), createPeerConnection: this._options?.createPeerConnection, createMediaStream: this._options?.createMediaStream, }); @@ -628,7 +634,7 @@ export class Go2RTCSessionController { private _reconnectOrEscalateError(context: ConnectionContext): void { if (this._retryTimer.getAttempts() >= RECONNECT_MAX_ATTEMPTS) { - this._callbacks.errorCallback(this._lastFailureReason); + this._callbacks.streamErrorCallback(this._lastStreamFailureReason); return; } this._retryTimer.schedule(() => this._connectChannel(context.url, context.surfaces)); @@ -660,7 +666,7 @@ export class Go2RTCSessionController { this._committedSource = source; this._retryTimer.reset(); - this._lastFailureReason = null; + this._lastStreamFailureReason = null; if (this._committedSurface && this._committedSurface !== surface) { this._resetSurface(context, this._committedSurface); diff --git a/src/components-lib/live/providers/go2rtc-experimental/sources/factory.ts b/src/components-lib/live/providers/go2rtc-experimental/sources/factory.ts index fd1f8cf3..6d082216 100644 --- a/src/components-lib/live/providers/go2rtc-experimental/sources/factory.ts +++ b/src/components-lib/live/providers/go2rtc-experimental/sources/factory.ts @@ -89,6 +89,7 @@ export interface CreateWebRTCSourceOptions { createPeerConnection?: PeerConnectionFactory; createMediaStream?: MediaStreamFactory; microphoneStream?: MediaStream | null; + microphoneErrorCallback?: (error?: string) => void; } export type WebRTCSourceFactory = ( diff --git a/src/components-lib/live/providers/go2rtc-experimental/sources/webrtc.ts b/src/components-lib/live/providers/go2rtc-experimental/sources/webrtc.ts index 204b7d22..0cc0ee52 100644 --- a/src/components-lib/live/providers/go2rtc-experimental/sources/webrtc.ts +++ b/src/components-lib/live/providers/go2rtc-experimental/sources/webrtc.ts @@ -4,6 +4,7 @@ import type { UnsubscribeCallback, } from '../../../../../types'; import { has2WayAudio, hasAudio } from '../../../../../utils/audio'; +import { isRecord } from '../../../../../utils/basic'; import { Timer } from '../../../../../utils/timer'; import { createBrowserPeerConnection, @@ -38,10 +39,32 @@ const WEBRTC_CONNECT_TIMEOUT_SECONDS = 5; 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 { createPeerConnection?: PeerConnectionFactory; createMediaStream?: MediaStreamFactory; 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 { @@ -52,6 +75,7 @@ export class WebRTCStreamSource implements StreamSource { private _createPeerConnection: PeerConnectionFactory; private _createMediaStream: MediaStreamFactory; private _microphoneStream: MediaStream | null; + private _microphoneErrorCallback: ((error?: string) => void) | null; private _microphoneTransceiver: RTCRtpTransceiver | null = null; @@ -75,6 +99,7 @@ export class WebRTCStreamSource implements StreamSource { options?.createMediaStream ?? ((tracks) => new MediaStream(tracks)); this._microphoneStream = options?.microphoneStream ?? null; + this._microphoneErrorCallback = options?.microphoneErrorCallback ?? null; } public start(): void { @@ -183,9 +208,7 @@ export class WebRTCStreamSource implements StreamSource { }; } - // Swap the outbound microphone track without renegotiating. Guards against a - // late rejection from a superseded call (a newer stream, or teardown) - // bringing a retired connection back or overwriting a fresher request. + // Swap the outbound microphone track without renegotiating. public async setMicrophoneStream(stream: MediaStream | null): Promise { if (this._microphoneStream === stream) { return; @@ -199,17 +222,27 @@ export class WebRTCStreamSource implements StreamSource { 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. const desiredTrack = stream?.getAudioTracks()[0] ?? null; try { await transceiver.sender.replaceTrack(desiredTrack); - } catch { - const stillCurrent = - transceiver === this._microphoneTransceiver && - this._microphoneStream === stream && - this._pc !== null; - if (stillCurrent) { - this._context.callbacks.failedCallback('two_way_audio_error'); + } catch (error) { + // Only a failed attach is reported. A failed detach leaves nothing for + // the user to act on: the track stops being transmitted when the peer + // connection closes. + if (desiredTrack && isCurrentRequest(transceiver, stream)) { + this._microphoneErrorCallback?.(getErrorDescription(error) ?? undefined); } } } diff --git a/src/components-lib/live/providers/go2rtc-experimental/types.ts b/src/components-lib/live/providers/go2rtc-experimental/types.ts index 83e7a813..e3be13f0 100644 --- a/src/components-lib/live/providers/go2rtc-experimental/types.ts +++ b/src/components-lib/live/providers/go2rtc-experimental/types.ts @@ -95,7 +95,6 @@ export interface StreamProfile { } export type StreamSourceFailureReason = - | 'two_way_audio_error' | 'buffer_overflow' | 'connect_timeout' | 'media_error' diff --git a/src/components-lib/live/providers/go2rtc-experimental/utils/failure-reason.ts b/src/components-lib/live/providers/go2rtc-experimental/utils/stream-failure-reason.ts similarity index 76% rename from src/components-lib/live/providers/go2rtc-experimental/utils/failure-reason.ts rename to src/components-lib/live/providers/go2rtc-experimental/utils/stream-failure-reason.ts index dce2519c..c123cc9c 100644 --- a/src/components-lib/live/providers/go2rtc-experimental/utils/failure-reason.ts +++ b/src/components-lib/live/providers/go2rtc-experimental/utils/stream-failure-reason.ts @@ -4,13 +4,12 @@ import type { StreamSourceFailureReason } from '../types'; // 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 // 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 -// two-way-audio call, or a timeout that means the stream never got going. -const FAILURE_TO_ISSUE_REASON: Record< +// keep a meaningful cause -- a server rejection, an unsupported stream, or a +// timeout that means the stream never got going. +const STREAM_FAILURE_TO_ISSUE_REASON: Record< StreamSourceFailureReason, MediaUnavailableIssueReason > = { - two_way_audio_error: 'two_way_audio_error', buffer_overflow: 'playback_error', connect_timeout: 'not_loading', 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 // socket dropped), which reads as a generic playback error. -export const mapFailureReasonToIssueReason = ( +export const mapStreamFailureReasonToIssueReason = ( reason: StreamSourceFailureReason | null, ): MediaUnavailableIssueReason => - reason === null ? 'playback_error' : FAILURE_TO_ISSUE_REASON[reason]; + reason === null ? 'playback_error' : STREAM_FAILURE_TO_ISSUE_REASON[reason]; diff --git a/src/components-lib/live/utils/dispatch-live-error.ts b/src/components-lib/live/utils/dispatch-live-error.ts index b0616588..38afc487 100644 --- a/src/components-lib/live/utils/dispatch-live-error.ts +++ b/src/components-lib/live/utils/dispatch-live-error.ts @@ -10,7 +10,7 @@ export interface LiveError { // Free text naming the specific failure (e.g. "Failed to start WebRTC stream: // ..."). Absent when the provider has none. - detail?: string; + description?: string; } declare global { diff --git a/src/components-lib/live/utils/dispatch-microphone-error.ts b/src/components-lib/live/utils/dispatch-microphone-error.ts new file mode 100644 index 00000000..4d1f7c02 --- /dev/null +++ b/src/components-lib/live/utils/dispatch-microphone-error.ts @@ -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; + } +} + +export function dispatchMicrophoneErrorEvent( + element: EventTarget, + error: MicrophoneError, +): void { + fireAdvancedCameraCardEvent(element, 'microphone:error', error); +} diff --git a/src/components-lib/media-loaded-info-source-controller.ts b/src/components-lib/media-loaded-info-source-controller.ts index f4879423..85d228c4 100644 --- a/src/components-lib/media-loaded-info-source-controller.ts +++ b/src/components-lib/media-loaded-info-source-controller.ts @@ -38,6 +38,13 @@ type TargetedMediaLoadedInfo = MediaLoadedInfo & { targetID: string }; * without needing the underlying media to re-fire a load (e.g., HaHlsPlayer * keeps the same `