feat: Add live.microphone.auto_mute / auto_unmute (#2482)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 9eb503deff
commit a68b665f03
16 changed files with 874 additions and 48 deletions
+1
View File
@@ -424,6 +424,7 @@ class AdvancedCameraCard extends LitElement {
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
.configManager=${this._controller.getConfigManager()}
.hide=${!!fullCardIssue}
.microphoneManager=${this._controller.getMicrophoneManager()}
.microphoneState=${this._controller.getMicrophoneManager().getState()}
.conditionStateManager=${this._controller.getConditionStateManager()}
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
@@ -0,0 +1,97 @@
import { MicrophoneManager } from '../../card-controller/microphone-manager.js';
import {
MicrophoneAutoMuteCondition,
MicrophoneAutoUnmuteCondition,
} from '../../config/schema/common/media-actions.js';
import { VisibilityObserver } from '../visibility-observer.js';
interface MicrophoneActionsControllerOptions {
microphoneManager?: MicrophoneManager;
autoMuteConditions?: readonly MicrophoneAutoMuteCondition[];
autoUnmuteConditions?: readonly MicrophoneAutoUnmuteCondition[];
}
/**
* Owns microphone auto-mute/unmute rules at the live-view level.
*
* The microphone is a global singleton so cannot be controlled by
* MediaActionsController without clashes between different controllers for
* different cameras. This gives:
* - deterministic ordering on selection change (this single owner sequences
* 'unselected' for the leaver and 'selected' for the arriver),
* - a single intersection observer scoped to the live root (correct
* 'visible'/'hidden' semantics for the whole live view, not per-cell), and
* - a single document.visibilitychange listener.
*/
export class MicrophoneActionsController {
private _options: MicrophoneActionsControllerOptions | null = null;
private _selectedCamera: string | null = null;
private _visibilityObserver: VisibilityObserver;
constructor() {
this._visibilityObserver = new VisibilityObserver((visible) =>
this._changeVisibility(visible),
);
}
public setOptions(options: MicrophoneActionsControllerOptions): void {
this._options = options;
}
public setRoot(root: HTMLElement): void {
this._visibilityObserver.setRoot(root);
}
public destroy(): void {
this._selectedCamera = null;
this._visibilityObserver.destroy();
}
/**
* Notifies the controller of the currently selected camera. Called from the
* live root whenever view.camera changes. Fires 'unselected' for the previous
* camera (if any) and 'selected' for the new camera (if any), in that order.
*/
public async setSelectedCamera(camera: string | null): Promise<void> {
if (this._selectedCamera === camera) {
return;
}
const previous = this._selectedCamera;
this._selectedCamera = camera;
if (previous !== null) {
this._muteIfConfigured('unselected');
}
if (camera !== null) {
await this._unmuteIfConfigured('selected');
}
}
private _changeVisibility = async (visible: boolean): Promise<void> => {
if (visible) {
await this._unmuteIfConfigured('visible');
} else {
this._muteIfConfigured('hidden');
}
};
private async _unmuteIfConfigured(
condition: MicrophoneAutoUnmuteCondition,
): Promise<void> {
if (
this._options?.microphoneManager &&
this._options.autoUnmuteConditions?.includes(condition)
) {
await this._options.microphoneManager.unmute();
}
}
private _muteIfConfigured(condition: MicrophoneAutoMuteCondition): void {
if (
this._options?.microphoneManager &&
this._options.autoMuteConditions?.includes(condition)
) {
this._options.microphoneManager.mute();
}
}
}
+15 -30
View File
@@ -7,6 +7,7 @@ import {
} from '../config/schema/common/media-actions.js';
import { MediaPlayerElement } from '../types.js';
import { Timer } from '../utils/timer.js';
import { VisibilityObserver } from './visibility-observer.js';
export interface MediaActionsControllerOptions {
playerSelector: string;
@@ -36,7 +37,6 @@ type MediaActionsTarget = {
export class MediaActionsController {
private _options: MediaActionsControllerOptions | null = null;
private _viewportIntersecting: boolean | null = null;
private _microphoneMuteTimer = new Timer();
private _root: RenderRoot | null = null;
@@ -44,12 +44,12 @@ export class MediaActionsController {
private _children: MediaPlayerElement[] = [];
private _target: MediaActionsTarget | null = null;
private _mutationObserver = new MutationObserver(this._mutationHandler.bind(this));
private _intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
private _visibilityObserver: VisibilityObserver;
constructor() {
document.addEventListener('visibilitychange', this._visibilityHandler);
this._visibilityObserver = new VisibilityObserver((visible) =>
this._changeVisibility(visible),
);
}
public setOptions(options: MediaActionsControllerOptions): void {
@@ -68,15 +68,13 @@ export class MediaActionsController {
}
public destroy(): void {
this._viewportIntersecting = null;
this._microphoneMuteTimer.stop();
this._root = null;
this._removeChildHandlers();
this._children = [];
this._target = null;
this._mutationObserver.disconnect();
this._intersectionObserver.disconnect();
document.removeEventListener('visibilitychange', this._visibilityHandler);
this._visibilityObserver.destroy();
}
public async setTarget(index: number, selected: boolean): Promise<void> {
@@ -185,8 +183,14 @@ export class MediaActionsController {
if (this._target?.index !== index) {
return;
}
await this._unmuteTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
await this._playTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
// Re-assert audio mute/play here because the media element may not have
// been ready when setTarget originally fired. The microphone manager has
// no such constraint, so it is intentionally not re-asserted here -- doing
// so would clobber any manual user mute made between target selection and
// media load.
const condition = this._target.selected ? 'selected' : 'visible';
await this._unmuteTargetIfConfigured(condition);
await this._playTargetIfConfigured(condition);
};
private _removeChildHandlers(): void {
@@ -205,8 +209,7 @@ export class MediaActionsController {
this._root = root;
this._initializeRoot();
this._intersectionObserver.disconnect();
this._intersectionObserver.observe(this._root);
this._visibilityObserver.setRoot(this._root);
this._mutationObserver.disconnect();
this._mutationObserver.observe(this._root, { childList: true, subtree: true });
@@ -231,24 +234,6 @@ export class MediaActionsController {
}
}
private async _intersectionHandler(
entries: IntersectionObserverEntry[],
): Promise<void> {
const wasIntersecting = this._viewportIntersecting;
this._viewportIntersecting = entries.some((entry) => entry.isIntersecting);
if (wasIntersecting !== null && wasIntersecting !== this._viewportIntersecting) {
// If the live view is preloaded (i.e. in the background) we may need to
// take media actions, e.g. muting a live stream that is now running in
// the background, so we act even if the new state is hidden.
await this._changeVisibility(this._viewportIntersecting);
}
}
private _visibilityHandler = async (): Promise<void> => {
await this._changeVisibility(document.visibilityState === 'visible');
};
private _changeVisibility = async (visible: boolean): Promise<void> => {
if (visible) {
await this._unmuteTargetIfConfigured('visible');
+89
View File
@@ -0,0 +1,89 @@
type VisibilityChangeHandler = (visible: boolean) => Promise<void> | void;
/**
* Observes a root element's visibility, emitting a single boolean whenever it
* transitions. The element is "visible" only when both:
*
* - The document's tab is visible (`document.visibilityState === 'visible'`).
* - The element is intersecting the viewport.
*
* Tab visibility alone is not sufficient: a preloaded element can remain in the
* DOM but hidden (e.g. `display: none`) while the user is on a different view.
* In that case the tab can become visible without the element being shown, and
* consumers (e.g. microphone auto-unmute) must not act on it.
*
* Emission rules: the first IntersectionObserver callback establishes the
* baseline and does not emit. Tab visibility events arriving before that first
* callback are no-ops (we don't yet know whether the element is in viewport).
* Once baseline is established, any subsequent change emits.
*/
export class VisibilityObserver {
private _root: HTMLElement | null = null;
private _intersecting: boolean | null = null;
private _lastEmitted: boolean | null = null;
private _intersectionObserver = new IntersectionObserver(
this._handleIntersection.bind(this),
);
private _onChange: VisibilityChangeHandler;
constructor(onChange: VisibilityChangeHandler) {
this._onChange = onChange;
document.addEventListener('visibilitychange', this._handleVisibility);
}
public setRoot(root: HTMLElement): void {
if (root === this._root) {
return;
}
this._root = root;
// Reset so the first callback for the new root is the new baseline
// (not compared against the previous root's state).
this._intersecting = null;
this._lastEmitted = null;
this._intersectionObserver.disconnect();
this._intersectionObserver.observe(root);
}
public destroy(): void {
this._root = null;
this._intersecting = null;
this._lastEmitted = null;
this._intersectionObserver.disconnect();
document.removeEventListener('visibilitychange', this._handleVisibility);
}
private async _handleIntersection(
entries: IntersectionObserverEntry[],
): Promise<void> {
this._intersecting = entries.some((entry) => entry.isIntersecting);
await this._evaluate();
}
private _handleVisibility = async (): Promise<void> => {
await this._evaluate();
};
private async _evaluate(): Promise<void> {
// Wait for the IntersectionObserver to fire at least once before emitting
// anything. Otherwise tab visibility events arriving between observer
// construction and `setRoot` would emit with no real knowledge of whether
// the element is shown to the user.
if (this._intersecting === null) {
return;
}
const visible = document.visibilityState === 'visible' && this._intersecting;
if (this._lastEmitted === null) {
// First time we have an intersection value: this is the baseline. Record
// and return without emitting.
this._lastEmitted = visible;
return;
}
if (visible === this._lastEmitted) {
return;
}
this._lastEmitted = visible;
await this._onChange(visible);
}
}
+47 -1
View File
@@ -1,8 +1,17 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { MicrophoneManager } from '../../card-controller/microphone-manager.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MicrophoneActionsController } from '../../components-lib/live/microphone-actions-controller.js';
import '../../components-lib/live/types.js';
import { LiveConfig } from '../../config/schema/live.js';
import { CardWideConfig } from '../../config/schema/types.js';
@@ -28,12 +37,49 @@ export class AdvancedCameraCardLive extends LitElement {
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public microphoneManager?: MicrophoneManager;
@property({ attribute: false })
public microphoneState?: MicrophoneState;
@property({ attribute: false, hasChanged: contentsChanged })
public triggeredCameraIDs?: Set<string>;
private _microphoneActionsController = new MicrophoneActionsController();
public connectedCallback(): void {
super.connectedCallback();
this._microphoneActionsController.setRoot(this);
}
public disconnectedCallback(): void {
this._microphoneActionsController.destroy();
super.disconnectedCallback();
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('liveConfig') || changedProps.has('microphoneManager')) {
this._microphoneActionsController.setOptions({
microphoneManager: this.microphoneManager,
autoMuteConditions: this.liveConfig?.microphone.auto_mute,
autoUnmuteConditions: this.liveConfig?.microphone.auto_unmute,
});
}
if (changedProps.has('viewManagerEpoch')) {
// The live element is also rendered (and this willUpdate runs) when
// `live.preload` is true even while the user is on gallery/viewer/
// timeline. `view.camera` is set across all views, so without this gate
// `auto_unmute: ['selected']` would prompt for / open the microphone
// from a hidden live view. Treat the live view as having no selected
// camera unless it is the active view.
const view = this.viewManagerEpoch?.manager.getView();
this._microphoneActionsController.setSelectedCamera(
view?.is('live') ? view.camera ?? null : null,
);
}
}
protected render(): TemplateResult | void {
if (!this.hass || !this.cameraManager) {
return;
+5
View File
@@ -11,6 +11,7 @@ import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { FoldersManager } from '../card-controller/folders/manager.js';
import { IssuePresence } from '../card-controller/issues/types.js';
import { MicrophoneManager } from '../card-controller/microphone-manager.js';
import { MicrophoneState } from '../card-controller/types.js';
import { ViewItemManager } from '../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
@@ -60,6 +61,9 @@ export class AdvancedCameraCardViews extends LitElement {
@property({ attribute: false })
public hide?: boolean;
@property({ attribute: false })
public microphoneManager?: MicrophoneManager;
@property({ attribute: false })
public microphoneState?: MicrophoneState;
@@ -230,6 +234,7 @@ export class AdvancedCameraCardViews extends LitElement {
.liveConfig=${this.config.live}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.microphoneManager=${this.microphoneManager}
.microphoneState=${this.microphoneState}
.triggeredCameraIDs=${this.triggeredCameraIDs}
class="${classMap(liveClasses)}"
@@ -11,9 +11,16 @@ export const MEDIA_UNMUTE_CONDITIONS = [
'microphone',
] as const;
export const MICROPHONE_MUTE_CONDITIONS = MEDIA_ACTION_NEGATIVE_CONDITIONS;
export const MICROPHONE_UNMUTE_CONDITIONS = MEDIA_ACTION_POSITIVE_CONDITIONS;
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
export type AutoMuteCondition = (typeof MEDIA_MUTE_CONDITIONS)[number];
export type AutoUnmuteCondition = (typeof MEDIA_UNMUTE_CONDITIONS)[number];
export type MicrophoneAutoMuteCondition = (typeof MICROPHONE_MUTE_CONDITIONS)[number];
export type MicrophoneAutoUnmuteCondition =
(typeof MICROPHONE_UNMUTE_CONDITIONS)[number];
export type LazyUnloadCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
+6
View File
@@ -16,11 +16,15 @@ import {
MEDIA_ACTION_POSITIVE_CONDITIONS,
MEDIA_MUTE_CONDITIONS,
MEDIA_UNMUTE_CONDITIONS,
MICROPHONE_MUTE_CONDITIONS,
MICROPHONE_UNMUTE_CONDITIONS,
} from './common/media-actions';
import { transitionEffectConfigSchema } from './common/transition-effect';
const microphoneConfigDefault = {
always_connected: false,
auto_mute: [],
auto_unmute: [],
disconnect_seconds: 90,
mute_after_microphone_mute_seconds: 60,
};
@@ -28,6 +32,8 @@ const microphoneConfigDefault = {
const microphoneConfigSchema = z
.object({
always_connected: z.boolean().default(microphoneConfigDefault.always_connected),
auto_mute: z.enum(MICROPHONE_MUTE_CONDITIONS).array().default([]),
auto_unmute: z.enum(MICROPHONE_UNMUTE_CONDITIONS).array().default([]),
disconnect_seconds: z
.number()
.min(0)
+6 -2
View File
@@ -355,12 +355,16 @@ export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const;
export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
`${CONF_LIVE}.show_image_during_load` as const;
export const CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED =
`${CONF_LIVE}.microphone.always_connected` as const;
export const CONF_LIVE_MICROPHONE_AUTO_MUTE =
`${CONF_LIVE}.microphone.auto_mute` as const;
export const CONF_LIVE_MICROPHONE_AUTO_UNMUTE =
`${CONF_LIVE}.microphone.auto_unmute` as const;
export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS =
`${CONF_LIVE}.microphone.disconnect_seconds` as const;
export const CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS =
`${CONF_LIVE}.microphone.mute_after_microphone_mute_seconds` as const;
export const CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED =
`${CONF_LIVE}.microphone.always_connected` as const;
export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const;
const CONF_IMAGE = 'image' as const;
+16
View File
@@ -156,6 +156,8 @@ import {
CONF_LIVE_LAZY_LOAD,
CONF_LIVE_LAZY_UNLOAD,
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
CONF_LIVE_MICROPHONE_AUTO_MUTE,
CONF_LIVE_MICROPHONE_AUTO_UNMUTE,
CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS,
CONF_LIVE_PRELOAD,
@@ -3350,6 +3352,20 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
this._defaults.live.microphone.always_connected,
)}
${this._renderOptionSelector(
CONF_LIVE_MICROPHONE_AUTO_MUTE,
this._mediaActionNegativeConditions,
{
multiple: true,
},
)}
${this._renderOptionSelector(
CONF_LIVE_MICROPHONE_AUTO_UNMUTE,
this._mediaActionPositiveConditions,
{
multiple: true,
},
)}
${this._renderNumberInput(
CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS,
)}
+2
View File
@@ -423,6 +423,8 @@
"lazy_unload": "Live cameras are lazily unloaded",
"microphone": {
"always_connected": "Always keep the microphone connected",
"auto_mute": "Automatically mute the microphone",
"auto_unmute": "Automatically unmute the microphone",
"disconnect_seconds": "Seconds before disconnecting microphone (0=never)",
"editor_label": "Microphone",
"mute_after_microphone_mute_seconds": "Seconds after microphone mute before muting inbound audio"