From a68b665f0322d1c8ae28e7c1f759fbf7b339cdc0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 9 May 2026 21:09:30 -0700 Subject: [PATCH] feat: Add `live.microphone.auto_mute` / `auto_unmute` (#2482) --- docs/configuration/live.md | 14 +- src/card.ts | 1 + .../live/microphone-actions-controller.ts | 97 +++++ .../media-actions-controller.ts | 45 +-- src/components-lib/visibility-observer.ts | 89 +++++ src/components/live/index.ts | 48 ++- src/components/views.ts | 5 + src/config/schema/common/media-actions.ts | 7 + src/config/schema/live.ts | 6 + src/const.ts | 8 +- src/editor.ts | 16 + src/localize/languages/en.json | 2 + .../microphone-actions-controller.test.ts | 330 ++++++++++++++++++ .../media-actions-controller.test.ts | 30 +- .../visibility-observer.test.ts | 222 ++++++++++++ tests/config/types.test.ts | 2 + 16 files changed, 874 insertions(+), 48 deletions(-) create mode 100644 src/components-lib/live/microphone-actions-controller.ts create mode 100644 src/components-lib/visibility-observer.ts create mode 100644 tests/components-lib/live/microphone-actions-controller.test.ts create mode 100644 tests/components-lib/visibility-observer.test.ts diff --git a/docs/configuration/live.md b/docs/configuration/live.md index c66cbfa9..52e2b7d8 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -177,11 +177,13 @@ live: microphone: ``` -| Option | Default | Description | -| ------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `always_connected` | `false` | Whether or not to keep the microphone stream continually connected while the card is running, or only when microphone is used (default). In the latter case there'll be a connection reset when the microphone is first used -- using this option can avoid that reset. | -| `disconnect_seconds` | `90` | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`. | -| `mute_after_microphone_mute_seconds` | `60` | The number of seconds after the microphone mutes to automatically mute the inbound audio when `live.auto_mute` includes `microphone`. | +| Option | Default | Description | +| ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `always_connected` | `false` | Whether or not to keep the microphone stream continually connected while the card is running, or only when microphone is used (default). In the latter case there'll be a connection reset when the microphone is first used -- using this option can avoid that reset. | +| `auto_mute` | `[]` | A list of conditions in which the microphone is muted. `unselected` will automatically mute the microphone when a camera is unselected in the carousel or grid. `hidden` will automatically mute the microphone when the card becomes hidden (e.g. browser/tab change). Use an empty list (`[]`, the default) to never automatically mute the microphone via these conditions. | +| `auto_unmute` | `[]` | A list of conditions in which the microphone is unmuted. `selected` will automatically unmute the microphone when a camera is selected in the carousel or grid (useful for an always-hot mic on the currently selected camera). `visible` will automatically unmute when the card becomes visible. Use an empty list (`[]`, the default) to never automatically unmute the microphone via these conditions. The browser will still prompt for microphone permission on first unmute. | +| `disconnect_seconds` | `90` | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`. | +| `mute_after_microphone_mute_seconds` | `60` | The number of seconds after the microphone mutes to automatically mute the inbound audio when `live.auto_mute` includes `microphone`. | See [Using 2-way audio](../usage/2-way-audio.md) for more information about the very particular requirements that must be followed for 2-way audio to work. @@ -244,6 +246,8 @@ live: 24h: true microphone: always_connected: false + auto_mute: [] + auto_unmute: [] disconnect_seconds: 90 mute_after_microphone_mute_seconds: 60 display: diff --git a/src/card.ts b/src/card.ts index ef5fe6dd..bfd275c9 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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 diff --git a/src/components-lib/live/microphone-actions-controller.ts b/src/components-lib/live/microphone-actions-controller.ts new file mode 100644 index 00000000..bae5589e --- /dev/null +++ b/src/components-lib/live/microphone-actions-controller.ts @@ -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 { + 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 => { + if (visible) { + await this._unmuteIfConfigured('visible'); + } else { + this._muteIfConfigured('hidden'); + } + }; + + private async _unmuteIfConfigured( + condition: MicrophoneAutoUnmuteCondition, + ): Promise { + 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(); + } + } +} diff --git a/src/components-lib/media-actions-controller.ts b/src/components-lib/media-actions-controller.ts index 2adef37d..60a1c242 100644 --- a/src/components-lib/media-actions-controller.ts +++ b/src/components-lib/media-actions-controller.ts @@ -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 { @@ -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 { - 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 => { - await this._changeVisibility(document.visibilityState === 'visible'); - }; - private _changeVisibility = async (visible: boolean): Promise => { if (visible) { await this._unmuteTargetIfConfigured('visible'); diff --git a/src/components-lib/visibility-observer.ts b/src/components-lib/visibility-observer.ts new file mode 100644 index 00000000..f8b8af4d --- /dev/null +++ b/src/components-lib/visibility-observer.ts @@ -0,0 +1,89 @@ +type VisibilityChangeHandler = (visible: boolean) => Promise | 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 { + this._intersecting = entries.some((entry) => entry.isIntersecting); + await this._evaluate(); + } + + private _handleVisibility = async (): Promise => { + await this._evaluate(); + }; + + private async _evaluate(): Promise { + // 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); + } +} diff --git a/src/components/live/index.ts b/src/components/live/index.ts index 7f90f323..3622146c 100644 --- a/src/components/live/index.ts +++ b/src/components/live/index.ts @@ -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; + 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; diff --git a/src/components/views.ts b/src/components/views.ts index 50d8d7da..cd494119 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -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)}" diff --git a/src/config/schema/common/media-actions.ts b/src/config/schema/common/media-actions.ts index 51bbe8a4..65749ea9 100644 --- a/src/config/schema/common/media-actions.ts +++ b/src/config/schema/common/media-actions.ts @@ -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]; diff --git a/src/config/schema/live.ts b/src/config/schema/live.ts index 8d767301..b5ec9e8c 100644 --- a/src/config/schema/live.ts +++ b/src/config/schema/live.ts @@ -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) diff --git a/src/const.ts b/src/const.ts index 56188118..8fc0bd81 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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; diff --git a/src/editor.ts b/src/editor.ts index 20203e5c..d15cab32 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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, )} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 8c478755..bbf8af7e 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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" diff --git a/tests/components-lib/live/microphone-actions-controller.test.ts b/tests/components-lib/live/microphone-actions-controller.test.ts new file mode 100644 index 00000000..0bd9d9ee --- /dev/null +++ b/tests/components-lib/live/microphone-actions-controller.test.ts @@ -0,0 +1,330 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { MicrophoneManager } from '../../../src/card-controller/microphone-manager'; +import { MicrophoneActionsController } from '../../../src/components-lib/live/microphone-actions-controller'; +import { + IntersectionObserverMock, + callIntersectionHandler, + callVisibilityHandler, + createParent, + getMockIntersectionObserver, +} from '../../test-utils'; + +const createMicrophoneManager = (): MicrophoneManager => { + const microphoneManager = mock(); + vi.mocked(microphoneManager.unmute).mockResolvedValue(undefined); + return microphoneManager; +}; + +// @vitest-environment jsdom +describe('MicrophoneActionsController', () => { + beforeAll(() => { + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); + // callVisibilityHandler reads from this spy. + vi.spyOn(global.document, 'addEventListener'); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + // Reset document.visibilityState so each test starts with the tab + // visible and is not affected by leftover state from a prior + // `callVisibilityHandler(false)`. + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }); + }); + + describe('on selected camera change', () => { + it('should unmute on selected when a camera becomes selected', async () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoUnmuteConditions: ['selected' as const], + }); + + await controller.setSelectedCamera('camera-1'); + + expect(microphoneManager.unmute).toBeCalledTimes(1); + expect(microphoneManager.mute).not.toBeCalled(); + }); + + it('should mute on unselected when transitioning to a new camera', async () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoMuteConditions: ['unselected' as const], + }); + + await controller.setSelectedCamera('camera-1'); + await controller.setSelectedCamera('camera-2'); + + expect(microphoneManager.mute).toBeCalledTimes(1); + }); + + it('should sequence mute-then-unmute deterministically on transition', async () => { + // Why this ordering matters: in grid mode, each camera cell owns its own + // MediaActionsController, but every cell shares the global + // MicrophoneManager. Without a single coordinator, a B->A selection + // change would have cell B fire 'unselected' (mute) and cell A fire + // 'selected' (unmute) independently, with order decided by Lit's sibling + // update sequence -- leaving the final mic state nondeterministic, and + // sometimes ending up muted despite `auto_unmute: ['selected']` being + // configured. This controller is that single coordinator: it always emits + // unselected then selected so the arriver wins. + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + const callOrder: string[] = []; + vi.mocked(microphoneManager.mute).mockImplementation(() => { + callOrder.push('mute'); + }); + vi.mocked(microphoneManager.unmute).mockImplementation(async () => { + callOrder.push('unmute'); + }); + controller.setOptions({ + microphoneManager, + autoMuteConditions: ['unselected' as const], + autoUnmuteConditions: ['selected' as const], + }); + + await controller.setSelectedCamera('camera-A'); + callOrder.length = 0; + + await controller.setSelectedCamera('camera-B'); + + expect(callOrder).toEqual(['mute', 'unmute']); + }); + + it('should not refire when the same camera is set again', async () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoUnmuteConditions: ['selected' as const], + }); + + await controller.setSelectedCamera('camera-1'); + await controller.setSelectedCamera('camera-1'); + + expect(microphoneManager.unmute).toBeCalledTimes(1); + }); + + it('should fire unselected only when transitioning from a camera to none', async () => { + // camera=null path: previous exists, new is null. Only mute fires. + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoMuteConditions: ['unselected' as const], + autoUnmuteConditions: ['selected' as const], + }); + + await controller.setSelectedCamera('camera-1'); + vi.mocked(microphoneManager.unmute).mockClear(); + vi.mocked(microphoneManager.mute).mockClear(); + + await controller.setSelectedCamera(null); + + expect(microphoneManager.mute).toBeCalledTimes(1); + expect(microphoneManager.unmute).not.toBeCalled(); + }); + + it('should not fire unselected on the very first selection (no previous)', async () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoMuteConditions: ['unselected' as const], + autoUnmuteConditions: ['selected' as const], + }); + + await controller.setSelectedCamera('camera-1'); + + expect(microphoneManager.mute).not.toBeCalled(); + expect(microphoneManager.unmute).toBeCalledTimes(1); + }); + + it('should not fire when condition arrays are empty', async () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoMuteConditions: [], + autoUnmuteConditions: [], + }); + + await controller.setSelectedCamera('camera-1'); + await controller.setSelectedCamera('camera-2'); + + expect(microphoneManager.mute).not.toBeCalled(); + expect(microphoneManager.unmute).not.toBeCalled(); + }); + + it('should not crash when conditions configured but no microphone manager is passed', async () => { + // Guards the short-circuit on the helpers: with conditions configured + // but no microphoneManager, .mute()/.unmute() must not be invoked on + // undefined. + const controller = new MicrophoneActionsController(); + controller.setOptions({ + autoMuteConditions: ['unselected' as const], + autoUnmuteConditions: ['selected' as const], + }); + + await expect(controller.setSelectedCamera('camera-1')).resolves.toBeUndefined(); + await expect(controller.setSelectedCamera('camera-2')).resolves.toBeUndefined(); + }); + }); + + describe('on document visibility change', () => { + it('should mute on hidden when the live root is intersecting', async () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoMuteConditions: ['hidden' as const], + }); + controller.setRoot(createParent()); + + // baseline: visible=true + await callIntersectionHandler(true); + + await callVisibilityHandler(false); + + expect(microphoneManager.mute).toBeCalledTimes(1); + }); + + it('should unmute on visible when the live root is intersecting', async () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoUnmuteConditions: ['visible' as const], + }); + controller.setRoot(createParent()); + + // baseline: visible=true + await callIntersectionHandler(true); + + // visible -> hidden + await callVisibilityHandler(false); + vi.mocked(microphoneManager.unmute).mockClear(); + + await callVisibilityHandler(true); + + expect(microphoneManager.unmute).toBeCalledTimes(1); + }); + + it('should not unmute on tab visible when the live root is hidden', async () => { + // With live.preload, the live element stays in DOM but is hidden via + // display:none in non-live views. The intersection observer reports false + // for that, so VisibilityObserver suppresses the unmute even when the tab + // regains focus. Without this gate, tab focus would prompt for / open the + // microphone from a hidden live view. + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoUnmuteConditions: ['visible' as const], + }); + controller.setRoot(createParent()); + await callIntersectionHandler(false); // baseline: element not visible + + await callVisibilityHandler(false); + await callVisibilityHandler(true); + + expect(microphoneManager.unmute).not.toBeCalled(); + }); + }); + + describe('on intersection change', () => { + it('should mute when the live root scrolls out of view', async () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoMuteConditions: ['hidden' as const], + }); + controller.setRoot(createParent()); + + // First intersection callback establishes baseline; only true transitions + // thereafter trigger actions. + await callIntersectionHandler(true); + await callIntersectionHandler(false); + + expect(microphoneManager.mute).toBeCalledTimes(1); + }); + + it('should unmute when the live root scrolls back into view', async () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoUnmuteConditions: ['visible' as const], + }); + controller.setRoot(createParent()); + + await callIntersectionHandler(false); + await callIntersectionHandler(true); + + expect(microphoneManager.unmute).toBeCalledTimes(1); + }); + + it('should ignore the very first intersection callback (baseline)', async () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoMuteConditions: ['hidden' as const], + autoUnmuteConditions: ['visible' as const], + }); + controller.setRoot(createParent()); + + await callIntersectionHandler(false); + + expect(microphoneManager.mute).not.toBeCalled(); + expect(microphoneManager.unmute).not.toBeCalled(); + }); + }); + + describe('lifecycle', () => { + it('should be idempotent on setRoot for the same element', () => { + const controller = new MicrophoneActionsController(); + const parent = createParent(); + + controller.setRoot(parent); + const intersectionObserver = getMockIntersectionObserver(); + expect(intersectionObserver?.observe).toHaveBeenCalledTimes(1); + expect(intersectionObserver?.disconnect).toHaveBeenCalledTimes(1); + + controller.setRoot(parent); + + // Same root: no re-observe, no re-disconnect. + expect(intersectionObserver?.observe).toHaveBeenCalledTimes(1); + expect(intersectionObserver?.disconnect).toHaveBeenCalledTimes(1); + }); + + it('should disconnect the intersection observer and remove the visibility listener on destroy', () => { + const removeEventListenerSpy = vi.spyOn(global.document, 'removeEventListener'); + const controller = new MicrophoneActionsController(); + controller.setRoot(createParent()); + + const intersectionObserver = getMockIntersectionObserver(); + + controller.destroy(); + + expect(intersectionObserver?.disconnect).toHaveBeenCalled(); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'visibilitychange', + expect.any(Function), + ); + }); + }); +}); diff --git a/tests/components-lib/media-actions-controller.test.ts b/tests/components-lib/media-actions-controller.test.ts index 695e6dd0..8c54eac8 100644 --- a/tests/components-lib/media-actions-controller.test.ts +++ b/tests/components-lib/media-actions-controller.test.ts @@ -53,6 +53,15 @@ describe('MediaActionsController', () => { beforeEach(() => { vi.clearAllMocks(); + + // Reset document.visibilityState so each test starts with the tab visible + // and is not affected by leftover state from a prior + // `callVisibilityHandler(false)`. + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }); }); describe('should set root', () => { @@ -451,14 +460,16 @@ describe('MediaActionsController', () => { controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - // Not configured to take action on selection. - expect( - (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], - ).not.toBeCalled(); + // The 'visible' rule fires when the element is both intersecting and + // the tab is visible. Set up element-intersecting + tab-hidden first so + // that the next callVisibilityHandler(true) is a real hidden->visible + // transition. The hidden transition itself only fires pause/mute (not + // play/unmute), so it does not affect the call count of `func` here. + await callIntersectionHandler(true); + await callVisibilityHandler(false); await callVisibilityHandler(true); - // Not configured to take action on selection. expect( (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], ).toBeCalledTimes(called ? 1 : 0); @@ -494,14 +505,13 @@ describe('MediaActionsController', () => { controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - // Not configured to take action on selection. - expect( - (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], - ).not.toBeCalled(); + // The 'hidden' rule fires on a transition from visible to hidden. + // Establish element-intersecting + tab-visible first so that + // callVisibilityHandler(false) is a real visible->hidden transition. + await callIntersectionHandler(true); await callVisibilityHandler(false); - // Not configured to take action on selection. expect( (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func], ).toBeCalledTimes(called ? 1 : 0); diff --git a/tests/components-lib/visibility-observer.test.ts b/tests/components-lib/visibility-observer.test.ts new file mode 100644 index 00000000..8ec1100e --- /dev/null +++ b/tests/components-lib/visibility-observer.test.ts @@ -0,0 +1,222 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { VisibilityObserver } from '../../src/components-lib/visibility-observer'; +import { + IntersectionObserverMock, + callIntersectionHandler, + callVisibilityHandler, + createParent, + getMockIntersectionObserver, +} from '../test-utils'; + +// @vitest-environment jsdom +describe('VisibilityObserver', () => { + beforeAll(() => { + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); + vi.spyOn(global.document, 'addEventListener'); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + + // Reset document.visibilityState so each test starts with the tab + // visible and is not affected by leftover state from a prior + // `callVisibilityHandler(false)`. + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }); + }); + + describe('intersection', () => { + it('should treat the first callback as baseline and not emit', async () => { + const onChange = vi.fn(); + const observer = new VisibilityObserver(onChange); + observer.setRoot(createParent()); + + await callIntersectionHandler(false); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should emit true when the root scrolls into view', async () => { + const onChange = vi.fn(); + const observer = new VisibilityObserver(onChange); + observer.setRoot(createParent()); + + await callIntersectionHandler(false); + await callIntersectionHandler(true); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('should emit false when the root scrolls out of view', async () => { + const onChange = vi.fn(); + const observer = new VisibilityObserver(onChange); + observer.setRoot(createParent()); + + await callIntersectionHandler(true); + await callIntersectionHandler(false); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it('should not emit when intersection state is unchanged', async () => { + const onChange = vi.fn(); + const observer = new VisibilityObserver(onChange); + observer.setRoot(createParent()); + + await callIntersectionHandler(true); + await callIntersectionHandler(true); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should reset the baseline when setRoot accepts a new element', async () => { + // Without the reset, the first callback for the new root would be + // compared against the previous root's last-known intersection state and + // could emit a spurious change. + const onChange = vi.fn(); + const observer = new VisibilityObserver(onChange); + observer.setRoot(createParent()); + + // Establish a non-null intersection state on the first root. + await callIntersectionHandler(false); + await callIntersectionHandler(true); + expect(onChange).toHaveBeenCalledTimes(1); + onChange.mockClear(); + + observer.setRoot(createParent()); + + // First callback after the new setRoot is the new baseline -- no emit. + await callIntersectionHandler(false); + expect(onChange).not.toHaveBeenCalled(); + + // Subsequent transition does emit. + await callIntersectionHandler(true); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true); + }); + }); + + describe('document visibility', () => { + it('should emit false when the tab becomes hidden while the element is intersecting', async () => { + const onChange = vi.fn(); + const observer = new VisibilityObserver(onChange); + observer.setRoot(createParent()); + await callIntersectionHandler(true); // baseline: visible=true + + await callVisibilityHandler(false); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it('should emit true when the tab becomes visible while the element is intersecting', async () => { + const onChange = vi.fn(); + const observer = new VisibilityObserver(onChange); + observer.setRoot(createParent()); + await callIntersectionHandler(true); // baseline: visible=true + await callVisibilityHandler(false); // visible -> hidden + onChange.mockClear(); + + await callVisibilityHandler(true); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('should not emit on tab visibility changes while the element is not intersecting', async () => { + // Regression: live.preload renders the live element while the user is + // on gallery/viewer, where it is hidden via display:none + // (intersecting=false). Without this gate, document.visibilitychange + // would emit visible=true on tab focus, making microphone + // auto_unmute: ['visible'] open the mic from a hidden view. + const onChange = vi.fn(); + const observer = new VisibilityObserver(onChange); + observer.setRoot(createParent()); + await callIntersectionHandler(false); // baseline: visible=false + + await callVisibilityHandler(false); + await callVisibilityHandler(true); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should emit nothing for tab events received before any intersection callback', async () => { + const onChange = vi.fn(); + const observer = new VisibilityObserver(onChange); + observer.setRoot(createParent()); + + await callVisibilityHandler(false); + await callVisibilityHandler(true); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('combining tab and intersection signals', () => { + it('should emit true only when both signals are visible', async () => { + const onChange = vi.fn(); + const observer = new VisibilityObserver(onChange); + observer.setRoot(createParent()); + + // baseline: visible=false + await callIntersectionHandler(false); + + // visible=false (still) + await callVisibilityHandler(false); + expect(onChange).not.toHaveBeenCalled(); + + // Tab visible but element not intersecting -> still false. + await callVisibilityHandler(true); + expect(onChange).not.toHaveBeenCalled(); + + // Element starts intersecting AND tab visible -> emit true. + await callIntersectionHandler(true); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true); + }); + }); + + describe('lifecycle', () => { + it('should be idempotent on setRoot for the same element', () => { + const observer = new VisibilityObserver(vi.fn()); + const parent = createParent(); + + observer.setRoot(parent); + const intersectionObserver = getMockIntersectionObserver(); + expect(intersectionObserver?.observe).toHaveBeenCalledTimes(1); + expect(intersectionObserver?.disconnect).toHaveBeenCalledTimes(1); + + observer.setRoot(parent); + + // Same root: no re-observe, no re-disconnect. + expect(intersectionObserver?.observe).toHaveBeenCalledTimes(1); + expect(intersectionObserver?.disconnect).toHaveBeenCalledTimes(1); + }); + + it('should disconnect the intersection observer and remove the visibility listener on destroy', () => { + const removeEventListenerSpy = vi.spyOn(global.document, 'removeEventListener'); + const observer = new VisibilityObserver(vi.fn()); + observer.setRoot(createParent()); + + const intersectionObserver = getMockIntersectionObserver(); + + observer.destroy(); + + expect(intersectionObserver?.disconnect).toHaveBeenCalled(); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'visibilitychange', + expect.any(Function), + ); + }); + }); +}); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 0abeea58..53977b1b 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -133,6 +133,8 @@ describe('config defaults', () => { lazy_unload: [], microphone: { always_connected: false, + auto_mute: [], + auto_unmute: [], disconnect_seconds: 90, mute_after_microphone_mute_seconds: 60, },