feat: Add live.microphone.auto_mute / auto_unmute (#2482)
This commit is contained in:
committed by
dermotduffy
parent
9eb503deff
commit
a68b665f03
@@ -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';
|
||||
import { MediaPlayerElement } from '../types.js';
|
||||
import { Timer } from '../utils/timer.js';
|
||||
import { VisibilityObserver } from './visibility-observer.js';
|
||||
|
||||
export interface MediaActionsControllerOptions {
|
||||
playerSelector: string;
|
||||
@@ -36,7 +37,6 @@ type MediaActionsTarget = {
|
||||
|
||||
export class MediaActionsController {
|
||||
private _options: MediaActionsControllerOptions | null = null;
|
||||
private _viewportIntersecting: boolean | null = null;
|
||||
private _microphoneMuteTimer = new Timer();
|
||||
private _root: RenderRoot | null = null;
|
||||
|
||||
@@ -44,12 +44,12 @@ export class MediaActionsController {
|
||||
private _children: MediaPlayerElement[] = [];
|
||||
private _target: MediaActionsTarget | null = null;
|
||||
private _mutationObserver = new MutationObserver(this._mutationHandler.bind(this));
|
||||
private _intersectionObserver = new IntersectionObserver(
|
||||
this._intersectionHandler.bind(this),
|
||||
);
|
||||
private _visibilityObserver: VisibilityObserver;
|
||||
|
||||
constructor() {
|
||||
document.addEventListener('visibilitychange', this._visibilityHandler);
|
||||
this._visibilityObserver = new VisibilityObserver((visible) =>
|
||||
this._changeVisibility(visible),
|
||||
);
|
||||
}
|
||||
|
||||
public setOptions(options: MediaActionsControllerOptions): void {
|
||||
@@ -68,15 +68,13 @@ export class MediaActionsController {
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._viewportIntersecting = null;
|
||||
this._microphoneMuteTimer.stop();
|
||||
this._root = null;
|
||||
this._removeChildHandlers();
|
||||
this._children = [];
|
||||
this._target = null;
|
||||
this._mutationObserver.disconnect();
|
||||
this._intersectionObserver.disconnect();
|
||||
document.removeEventListener('visibilitychange', this._visibilityHandler);
|
||||
this._visibilityObserver.destroy();
|
||||
}
|
||||
|
||||
public async setTarget(index: number, selected: boolean): Promise<void> {
|
||||
@@ -185,8 +183,14 @@ export class MediaActionsController {
|
||||
if (this._target?.index !== index) {
|
||||
return;
|
||||
}
|
||||
await this._unmuteTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
|
||||
await this._playTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
|
||||
// Re-assert audio mute/play here because the media element may not have
|
||||
// been ready when setTarget originally fired. The microphone manager has
|
||||
// no such constraint, so it is intentionally not re-asserted here -- doing
|
||||
// so would clobber any manual user mute made between target selection and
|
||||
// media load.
|
||||
const condition = this._target.selected ? 'selected' : 'visible';
|
||||
await this._unmuteTargetIfConfigured(condition);
|
||||
await this._playTargetIfConfigured(condition);
|
||||
};
|
||||
|
||||
private _removeChildHandlers(): void {
|
||||
@@ -205,8 +209,7 @@ export class MediaActionsController {
|
||||
this._root = root;
|
||||
this._initializeRoot();
|
||||
|
||||
this._intersectionObserver.disconnect();
|
||||
this._intersectionObserver.observe(this._root);
|
||||
this._visibilityObserver.setRoot(this._root);
|
||||
|
||||
this._mutationObserver.disconnect();
|
||||
this._mutationObserver.observe(this._root, { childList: true, subtree: true });
|
||||
@@ -231,24 +234,6 @@ export class MediaActionsController {
|
||||
}
|
||||
}
|
||||
|
||||
private async _intersectionHandler(
|
||||
entries: IntersectionObserverEntry[],
|
||||
): Promise<void> {
|
||||
const wasIntersecting = this._viewportIntersecting;
|
||||
this._viewportIntersecting = entries.some((entry) => entry.isIntersecting);
|
||||
|
||||
if (wasIntersecting !== null && wasIntersecting !== this._viewportIntersecting) {
|
||||
// If the live view is preloaded (i.e. in the background) we may need to
|
||||
// take media actions, e.g. muting a live stream that is now running in
|
||||
// the background, so we act even if the new state is hidden.
|
||||
await this._changeVisibility(this._viewportIntersecting);
|
||||
}
|
||||
}
|
||||
|
||||
private _visibilityHandler = async (): Promise<void> => {
|
||||
await this._changeVisibility(document.visibilityState === 'visible');
|
||||
};
|
||||
|
||||
private _changeVisibility = async (visible: boolean): Promise<void> => {
|
||||
if (visible) {
|
||||
await this._unmuteTargetIfConfigured('visible');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user