feat: Add live.microphone.auto_mute / auto_unmute (#2482)
This commit is contained in:
committed by
dermotduffy
parent
9eb503deff
commit
a68b665f03
@@ -178,8 +178,10 @@ live:
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| 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. |
|
| `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`. |
|
| `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`. |
|
| `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`. |
|
||||||
|
|
||||||
@@ -244,6 +246,8 @@ live:
|
|||||||
24h: true
|
24h: true
|
||||||
microphone:
|
microphone:
|
||||||
always_connected: false
|
always_connected: false
|
||||||
|
auto_mute: []
|
||||||
|
auto_unmute: []
|
||||||
disconnect_seconds: 90
|
disconnect_seconds: 90
|
||||||
mute_after_microphone_mute_seconds: 60
|
mute_after_microphone_mute_seconds: 60
|
||||||
display:
|
display:
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
|
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
|
||||||
.configManager=${this._controller.getConfigManager()}
|
.configManager=${this._controller.getConfigManager()}
|
||||||
.hide=${!!fullCardIssue}
|
.hide=${!!fullCardIssue}
|
||||||
|
.microphoneManager=${this._controller.getMicrophoneManager()}
|
||||||
.microphoneState=${this._controller.getMicrophoneManager().getState()}
|
.microphoneState=${this._controller.getMicrophoneManager().getState()}
|
||||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||||
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '../config/schema/common/media-actions.js';
|
} from '../config/schema/common/media-actions.js';
|
||||||
import { MediaPlayerElement } from '../types.js';
|
import { MediaPlayerElement } from '../types.js';
|
||||||
import { Timer } from '../utils/timer.js';
|
import { Timer } from '../utils/timer.js';
|
||||||
|
import { VisibilityObserver } from './visibility-observer.js';
|
||||||
|
|
||||||
export interface MediaActionsControllerOptions {
|
export interface MediaActionsControllerOptions {
|
||||||
playerSelector: string;
|
playerSelector: string;
|
||||||
@@ -36,7 +37,6 @@ type MediaActionsTarget = {
|
|||||||
|
|
||||||
export class MediaActionsController {
|
export class MediaActionsController {
|
||||||
private _options: MediaActionsControllerOptions | null = null;
|
private _options: MediaActionsControllerOptions | null = null;
|
||||||
private _viewportIntersecting: boolean | null = null;
|
|
||||||
private _microphoneMuteTimer = new Timer();
|
private _microphoneMuteTimer = new Timer();
|
||||||
private _root: RenderRoot | null = null;
|
private _root: RenderRoot | null = null;
|
||||||
|
|
||||||
@@ -44,12 +44,12 @@ export class MediaActionsController {
|
|||||||
private _children: MediaPlayerElement[] = [];
|
private _children: MediaPlayerElement[] = [];
|
||||||
private _target: MediaActionsTarget | null = null;
|
private _target: MediaActionsTarget | null = null;
|
||||||
private _mutationObserver = new MutationObserver(this._mutationHandler.bind(this));
|
private _mutationObserver = new MutationObserver(this._mutationHandler.bind(this));
|
||||||
private _intersectionObserver = new IntersectionObserver(
|
private _visibilityObserver: VisibilityObserver;
|
||||||
this._intersectionHandler.bind(this),
|
|
||||||
);
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
document.addEventListener('visibilitychange', this._visibilityHandler);
|
this._visibilityObserver = new VisibilityObserver((visible) =>
|
||||||
|
this._changeVisibility(visible),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setOptions(options: MediaActionsControllerOptions): void {
|
public setOptions(options: MediaActionsControllerOptions): void {
|
||||||
@@ -68,15 +68,13 @@ export class MediaActionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this._viewportIntersecting = null;
|
|
||||||
this._microphoneMuteTimer.stop();
|
this._microphoneMuteTimer.stop();
|
||||||
this._root = null;
|
this._root = null;
|
||||||
this._removeChildHandlers();
|
this._removeChildHandlers();
|
||||||
this._children = [];
|
this._children = [];
|
||||||
this._target = null;
|
this._target = null;
|
||||||
this._mutationObserver.disconnect();
|
this._mutationObserver.disconnect();
|
||||||
this._intersectionObserver.disconnect();
|
this._visibilityObserver.destroy();
|
||||||
document.removeEventListener('visibilitychange', this._visibilityHandler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setTarget(index: number, selected: boolean): Promise<void> {
|
public async setTarget(index: number, selected: boolean): Promise<void> {
|
||||||
@@ -185,8 +183,14 @@ export class MediaActionsController {
|
|||||||
if (this._target?.index !== index) {
|
if (this._target?.index !== index) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this._unmuteTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
|
// Re-assert audio mute/play here because the media element may not have
|
||||||
await this._playTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
|
// 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 {
|
private _removeChildHandlers(): void {
|
||||||
@@ -205,8 +209,7 @@ export class MediaActionsController {
|
|||||||
this._root = root;
|
this._root = root;
|
||||||
this._initializeRoot();
|
this._initializeRoot();
|
||||||
|
|
||||||
this._intersectionObserver.disconnect();
|
this._visibilityObserver.setRoot(this._root);
|
||||||
this._intersectionObserver.observe(this._root);
|
|
||||||
|
|
||||||
this._mutationObserver.disconnect();
|
this._mutationObserver.disconnect();
|
||||||
this._mutationObserver.observe(this._root, { childList: true, subtree: true });
|
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> => {
|
private _changeVisibility = async (visible: boolean): Promise<void> => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
await this._unmuteTargetIfConfigured('visible');
|
await this._unmuteTargetIfConfigured('visible');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { customElement, property } from 'lit/decorators.js';
|
||||||
import { CameraManager } from '../../camera-manager/manager.js';
|
import { CameraManager } from '../../camera-manager/manager.js';
|
||||||
|
import { MicrophoneManager } from '../../card-controller/microphone-manager.js';
|
||||||
import { MicrophoneState } from '../../card-controller/types.js';
|
import { MicrophoneState } from '../../card-controller/types.js';
|
||||||
import { ViewManagerEpoch } from '../../card-controller/view/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 '../../components-lib/live/types.js';
|
||||||
import { LiveConfig } from '../../config/schema/live.js';
|
import { LiveConfig } from '../../config/schema/live.js';
|
||||||
import { CardWideConfig } from '../../config/schema/types.js';
|
import { CardWideConfig } from '../../config/schema/types.js';
|
||||||
@@ -28,12 +37,49 @@ export class AdvancedCameraCardLive extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneManager?: MicrophoneManager;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public microphoneState?: MicrophoneState;
|
public microphoneState?: MicrophoneState;
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
public triggeredCameraIDs?: Set<string>;
|
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 {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.cameraManager) {
|
if (!this.hass || !this.cameraManager) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { classMap } from 'lit/directives/class-map.js';
|
|||||||
import { CameraManager } from '../camera-manager/manager.js';
|
import { CameraManager } from '../camera-manager/manager.js';
|
||||||
import { FoldersManager } from '../card-controller/folders/manager.js';
|
import { FoldersManager } from '../card-controller/folders/manager.js';
|
||||||
import { IssuePresence } from '../card-controller/issues/types.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 { MicrophoneState } from '../card-controller/types.js';
|
||||||
import { ViewItemManager } from '../card-controller/view/item-manager.js';
|
import { ViewItemManager } from '../card-controller/view/item-manager.js';
|
||||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||||
@@ -60,6 +61,9 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hide?: boolean;
|
public hide?: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneManager?: MicrophoneManager;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public microphoneState?: MicrophoneState;
|
public microphoneState?: MicrophoneState;
|
||||||
|
|
||||||
@@ -230,6 +234,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
.liveConfig=${this.config.live}
|
.liveConfig=${this.config.live}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.microphoneManager=${this.microphoneManager}
|
||||||
.microphoneState=${this.microphoneState}
|
.microphoneState=${this.microphoneState}
|
||||||
.triggeredCameraIDs=${this.triggeredCameraIDs}
|
.triggeredCameraIDs=${this.triggeredCameraIDs}
|
||||||
class="${classMap(liveClasses)}"
|
class="${classMap(liveClasses)}"
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ export const MEDIA_UNMUTE_CONDITIONS = [
|
|||||||
'microphone',
|
'microphone',
|
||||||
] as const;
|
] 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 AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
||||||
export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
|
export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
|
||||||
export type AutoMuteCondition = (typeof MEDIA_MUTE_CONDITIONS)[number];
|
export type AutoMuteCondition = (typeof MEDIA_MUTE_CONDITIONS)[number];
|
||||||
export type AutoUnmuteCondition = (typeof MEDIA_UNMUTE_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];
|
export type LazyUnloadCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import {
|
|||||||
MEDIA_ACTION_POSITIVE_CONDITIONS,
|
MEDIA_ACTION_POSITIVE_CONDITIONS,
|
||||||
MEDIA_MUTE_CONDITIONS,
|
MEDIA_MUTE_CONDITIONS,
|
||||||
MEDIA_UNMUTE_CONDITIONS,
|
MEDIA_UNMUTE_CONDITIONS,
|
||||||
|
MICROPHONE_MUTE_CONDITIONS,
|
||||||
|
MICROPHONE_UNMUTE_CONDITIONS,
|
||||||
} from './common/media-actions';
|
} from './common/media-actions';
|
||||||
import { transitionEffectConfigSchema } from './common/transition-effect';
|
import { transitionEffectConfigSchema } from './common/transition-effect';
|
||||||
|
|
||||||
const microphoneConfigDefault = {
|
const microphoneConfigDefault = {
|
||||||
always_connected: false,
|
always_connected: false,
|
||||||
|
auto_mute: [],
|
||||||
|
auto_unmute: [],
|
||||||
disconnect_seconds: 90,
|
disconnect_seconds: 90,
|
||||||
mute_after_microphone_mute_seconds: 60,
|
mute_after_microphone_mute_seconds: 60,
|
||||||
};
|
};
|
||||||
@@ -28,6 +32,8 @@ const microphoneConfigDefault = {
|
|||||||
const microphoneConfigSchema = z
|
const microphoneConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
always_connected: z.boolean().default(microphoneConfigDefault.always_connected),
|
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
|
disconnect_seconds: z
|
||||||
.number()
|
.number()
|
||||||
.min(0)
|
.min(0)
|
||||||
|
|||||||
+6
-2
@@ -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_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
|
||||||
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
|
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
|
||||||
`${CONF_LIVE}.show_image_during_load` as const;
|
`${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 =
|
export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS =
|
||||||
`${CONF_LIVE}.microphone.disconnect_seconds` as const;
|
`${CONF_LIVE}.microphone.disconnect_seconds` as const;
|
||||||
export const CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS =
|
export const CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS =
|
||||||
`${CONF_LIVE}.microphone.mute_after_microphone_mute_seconds` as const;
|
`${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;
|
export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const;
|
||||||
|
|
||||||
const CONF_IMAGE = 'image' as const;
|
const CONF_IMAGE = 'image' as const;
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ import {
|
|||||||
CONF_LIVE_LAZY_LOAD,
|
CONF_LIVE_LAZY_LOAD,
|
||||||
CONF_LIVE_LAZY_UNLOAD,
|
CONF_LIVE_LAZY_UNLOAD,
|
||||||
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
||||||
|
CONF_LIVE_MICROPHONE_AUTO_MUTE,
|
||||||
|
CONF_LIVE_MICROPHONE_AUTO_UNMUTE,
|
||||||
CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
|
CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
|
||||||
CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS,
|
CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS,
|
||||||
CONF_LIVE_PRELOAD,
|
CONF_LIVE_PRELOAD,
|
||||||
@@ -3350,6 +3352,20 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
|||||||
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
||||||
this._defaults.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(
|
${this._renderNumberInput(
|
||||||
CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS,
|
CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -423,6 +423,8 @@
|
|||||||
"lazy_unload": "Live cameras are lazily unloaded",
|
"lazy_unload": "Live cameras are lazily unloaded",
|
||||||
"microphone": {
|
"microphone": {
|
||||||
"always_connected": "Always keep the microphone connected",
|
"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)",
|
"disconnect_seconds": "Seconds before disconnecting microphone (0=never)",
|
||||||
"editor_label": "Microphone",
|
"editor_label": "Microphone",
|
||||||
"mute_after_microphone_mute_seconds": "Seconds after microphone mute before muting inbound audio"
|
"mute_after_microphone_mute_seconds": "Seconds after microphone mute before muting inbound audio"
|
||||||
|
|||||||
@@ -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<MicrophoneManager>();
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,15 @@ describe('MediaActionsController', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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', () => {
|
describe('should set root', () => {
|
||||||
@@ -451,14 +460,16 @@ describe('MediaActionsController', () => {
|
|||||||
controller.setRoot(createParent({ children: children }));
|
controller.setRoot(createParent({ children: children }));
|
||||||
await controller.setTarget(0, true);
|
await controller.setTarget(0, true);
|
||||||
|
|
||||||
// Not configured to take action on selection.
|
// The 'visible' rule fires when the element is both intersecting and
|
||||||
expect(
|
// the tab is visible. Set up element-intersecting + tab-hidden first so
|
||||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
// that the next callVisibilityHandler(true) is a real hidden->visible
|
||||||
).not.toBeCalled();
|
// 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);
|
await callVisibilityHandler(true);
|
||||||
|
|
||||||
// Not configured to take action on selection.
|
|
||||||
expect(
|
expect(
|
||||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||||
).toBeCalledTimes(called ? 1 : 0);
|
).toBeCalledTimes(called ? 1 : 0);
|
||||||
@@ -494,14 +505,13 @@ describe('MediaActionsController', () => {
|
|||||||
controller.setRoot(createParent({ children: children }));
|
controller.setRoot(createParent({ children: children }));
|
||||||
await controller.setTarget(0, true);
|
await controller.setTarget(0, true);
|
||||||
|
|
||||||
// Not configured to take action on selection.
|
// The 'hidden' rule fires on a transition from visible to hidden.
|
||||||
expect(
|
// Establish element-intersecting + tab-visible first so that
|
||||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
// callVisibilityHandler(false) is a real visible->hidden transition.
|
||||||
).not.toBeCalled();
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
await callVisibilityHandler(false);
|
await callVisibilityHandler(false);
|
||||||
|
|
||||||
// Not configured to take action on selection.
|
|
||||||
expect(
|
expect(
|
||||||
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
(await getPlayer(children[0], 'video')?.getMediaPlayerController())?.[func],
|
||||||
).toBeCalledTimes(called ? 1 : 0);
|
).toBeCalledTimes(called ? 1 : 0);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -133,6 +133,8 @@ describe('config defaults', () => {
|
|||||||
lazy_unload: [],
|
lazy_unload: [],
|
||||||
microphone: {
|
microphone: {
|
||||||
always_connected: false,
|
always_connected: false,
|
||||||
|
auto_mute: [],
|
||||||
|
auto_unmute: [],
|
||||||
disconnect_seconds: 90,
|
disconnect_seconds: 90,
|
||||||
mute_after_microphone_mute_seconds: 60,
|
mute_after_microphone_mute_seconds: 60,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user