From 349470622529d10d192f3d5c0a506cdda89b0330 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 3 Jul 2026 08:19:11 -0700 Subject: [PATCH] fix: Use auto_unmute as input to stream selection for HA (#2562) - Closes #2479 --- src/components-lib/live/audio-intent.ts | 15 ++ .../live/ha-stream-mute-controller.ts | 129 +++++++++++ src/components/live/provider.ts | 3 + src/components/live/providers/ha.ts | 15 ++ src/patches/ha-camera-stream.ts | 91 +++----- .../components-lib/live/audio-intent.test.ts | 25 +++ .../live/ha-stream-mute-controller.test.ts | 211 ++++++++++++++++++ 7 files changed, 428 insertions(+), 61 deletions(-) create mode 100644 src/components-lib/live/audio-intent.ts create mode 100644 src/components-lib/live/ha-stream-mute-controller.ts create mode 100644 tests/components-lib/live/audio-intent.test.ts create mode 100644 tests/components-lib/live/ha-stream-mute-controller.test.ts diff --git a/src/components-lib/live/audio-intent.ts b/src/components-lib/live/audio-intent.ts new file mode 100644 index 00000000..4cb57e59 --- /dev/null +++ b/src/components-lib/live/audio-intent.ts @@ -0,0 +1,15 @@ +import { + MEDIA_ACTION_POSITIVE_CONDITIONS, + type AutoUnmuteCondition, +} from '../../config/schema/common/media-actions.js'; + +/** + * Whether the configured auto-unmute policy will unmute a stream as it loads + * (i.e. on selection or visibility), rather than only later in response to a + * user action or call event. Used to decide whether to pre-select a camera's + * audio-carrying stream up front instead of switching to it after the fact. + */ +export const isAudioIntendedOnLoad = ( + autoUnmute: readonly AutoUnmuteCondition[], +): boolean => + MEDIA_ACTION_POSITIVE_CONDITIONS.some((condition) => autoUnmute.includes(condition)); diff --git a/src/components-lib/live/ha-stream-mute-controller.ts b/src/components-lib/live/ha-stream-mute-controller.ts new file mode 100644 index 00000000..a71850b0 --- /dev/null +++ b/src/components-lib/live/ha-stream-mute-controller.ts @@ -0,0 +1,129 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +// Dispatched by the ha-camera-stream patch when the VISIBLE leaf's output mute +// changes. The patch resolves the visible leaf synchronously and ships the +// value here, so consumers never have to query it asynchronously. +export const HA_CAMERA_STREAM_MUTE_CHANGE_EVENT = + 'advanced-camera-card:ha-camera-stream:mute-change'; + +interface HACameraStreamMuteChangeDetail { + muted: boolean; +} + +interface HAStreamMuteControllerOptions { + // Effective camera entity id of the currently displayed (possibly substream) + // camera. A change resets the stream selection, since a reused element must + // not inherit the previous camera's selection. + getCameraEntityID: () => string | null; + + // Whether audio is intended on load for this camera (its auto-unmute policy + // fires on selection/visibility). Seeds the selection on a camera change so a + // mixed-capability camera starts on the audio-capable stream. + getPreferAudioStream: () => boolean; +} + +const isMuteChangeEvent = ( + ev: Event, +): ev is CustomEvent => { + if (!(ev instanceof CustomEvent)) { + return false; + } + const detail: unknown = ev.detail; + return ( + typeof detail === 'object' && + detail !== null && + 'muted' in detail && + typeof detail.muted === 'boolean' + ); +}; + +/** + * Owns the HA stream's mute state for `advanced-camera-card-live-ha`, split into + * the two roles HA conflates in `muted`: + * + * - `intendedMute`: a one-way latch feeding ha-camera-stream's `muted` (HA's + * stream chooser). Seeded from the audio intent on a camera change, flipped + * to false the first time the visible leaf is unmuted, and never flipped + * back except on a camera change. Keeps muted views on the low-latency + * stream and prevents an autoplay force-mute from downgrading the stream. + * - `outputMute`: the visible leaf's real output mute, mirrored from the + * patch's mute-change event. The leaf players bind to this (not the latch), + * so a remount restores the real mute instead of the sticky latch value. + * + * See: https://github.com/dermotduffy/advanced-camera-card/issues/2479 + */ +export class HAStreamMuteController implements ReactiveController { + private _host: ReactiveControllerHost & HTMLElement; + private _options: HAStreamMuteControllerOptions; + + private _intendedMute = true; + private _outputMute = true; + + // The camera entity the current state belongs to, to detect a camera change. + private _cameraEntityID: string | null = null; + + constructor( + host: ReactiveControllerHost & HTMLElement, + options: HAStreamMuteControllerOptions, + ) { + this._host = host; + this._options = options; + host.addController(this); + } + + public getIntendedMute(): boolean { + return this._intendedMute; + } + + public getOutputMute(): boolean { + return this._outputMute; + } + + public hostConnected(): void { + this._host.addEventListener( + HA_CAMERA_STREAM_MUTE_CHANGE_EVENT, + this._muteChangeHandler, + ); + } + + public hostDisconnected(): void { + this._host.removeEventListener( + HA_CAMERA_STREAM_MUTE_CHANGE_EVENT, + this._muteChangeHandler, + ); + } + + public hostUpdate(): void { + // Reset only when the displayed camera changes, never on an intent change + // alone: that would clobber a user's runtime unmute. + const cameraEntityID = this._options.getCameraEntityID(); + if (cameraEntityID !== this._cameraEntityID) { + this._cameraEntityID = cameraEntityID; + this._intendedMute = !this._options.getPreferAudioStream(); + this._outputMute = true; + } + } + + private _muteChangeHandler = (ev: Event): void => { + if (!isMuteChangeEvent(ev)) { + return; + } + const muted = ev.detail.muted; + + let changed = false; + if (muted !== this._outputMute) { + this._outputMute = muted; + changed = true; + } + + // Unmuting flips the selection latch; muting never does (one-way). + if (this._intendedMute && !muted) { + this._intendedMute = false; + changed = true; + } + + if (changed) { + this._host.requestUpdate(); + } + }; +} diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 43afd69b..f5c6f04b 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -13,6 +13,7 @@ import { createRef, ref, type Ref } from 'lit/directives/ref.js'; import type { Camera } from '../../camera-manager/camera.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; +import { isAudioIntendedOnLoad } from '../../components-lib/live/audio-intent.js'; import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js'; import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js'; import type { PartialZoomSettings } from '../../components-lib/zoom/types.js'; @@ -335,6 +336,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .hass=${this.hass} .camera=${this.camera} .targetID=${this.targetID} + .preferAudioStream=${this.forceSelected && + isAudioIntendedOnLoad(this.liveConfig?.auto_unmute ?? [])} ?controls=${this._getEffectiveBuiltinControls()} @advanced-camera-card:live:error=${(ev: Event) => this._providerErrorHandler(ev)} diff --git a/src/components/live/providers/ha.ts b/src/components/live/providers/ha.ts index d58d45ab..39708252 100644 --- a/src/components/live/providers/ha.ts +++ b/src/components/live/providers/ha.ts @@ -9,6 +9,7 @@ import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, type Ref } from 'lit/directives/ref.js'; import type { Camera } from '../../../camera-manager/camera.js'; +import { HAStreamMuteController } from '../../../components-lib/live/ha-stream-mute-controller.js'; import type { HomeAssistant } from '../../../ha/types'; import '../../../patches/ha-camera-stream'; @@ -37,8 +38,20 @@ export class AdvancedCameraCardLiveHA extends LitElement implements MediaPlayer @property({ attribute: true, type: Boolean }) public controls = false; + @property({ attribute: false }) + public preferAudioStream = false; + private _playerRef: Ref = createRef(); + // Owns the mute state for the underlying ha-camera-stream: it feeds `muted` + // (which is surprisingly used by HA to select WebRTC vs HLS streams) and + // `outputMute` (the actual player's audio output) into the element, seeded + // from the audio intent. + private _muteController = new HAStreamMuteController(this, { + getCameraEntityID: () => this.camera?.getConfig()?.camera_entity ?? null, + getPreferAudioStream: () => this.preferAudioStream, + }); + public async getMediaPlayerController(): Promise { await this.updateComplete; return (await this._playerRef.value?.getMediaPlayerController()) ?? null; @@ -56,6 +69,8 @@ export class AdvancedCameraCardLiveHA extends LitElement implements MediaPlayer .stateObj=${cameraEntity ? this.hass.states[cameraEntity] : undefined} .controls=${this.controls} .targetID=${this.targetID} + .muted=${this._muteController.getIntendedMute()} + .outputMute=${this._muteController.getOutputMute()} > `; } diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 75efc1ac..ce9cb53c 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -17,8 +17,9 @@ import { type CSSResultGroup, type PropertyValues, } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; +import { HA_CAMERA_STREAM_MUTE_CHANGE_EVENT } from '../components-lib/live/ha-stream-mute-controller.js'; import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js'; import '../components/image-player.js'; @@ -75,77 +76,45 @@ void customElements.whenDefined('ha-camera-stream').then(() => { // The currently-visible stream type, refreshed in `updated()`. private _visibleStreamType: StreamType | null = null; - // -------- Audio / stream selection model (hacking around HA!) -------- - // - // The HA frontend chooses between a camera's streams (e.g. low-latency - // WebRTC vs higher-latency HLS) from `muted`: when unmuted it switches to a - // stream that carries audio if the chosen one has none. HA sets `muted` - // statically per context (i.e. a stock card sets it once); the native - //