import { localize } from '../localize/localize'; import { AdvancedCameraCardError } from '../types'; import { Timer } from '../utils/timer'; import type { CardMicrophoneAPI, MicrophoneState } from './types'; export class MicrophoneNotSupportedError extends AdvancedCameraCardError { constructor() { super(localize('error.microphone_not_supported')); } } export class MicrophoneManager { private _api: CardMicrophoneAPI; private _stream?: MediaStream | null; private _timer = new Timer(); private _state: MicrophoneState = { connected: false, muted: true, forbidden: false, }; // We keep desired mute state separate from the overall state so that // mute/unmute can be expressed before the stream is even created -- and when // it's created it will have the right mute status. private _desireMute = true; // Whether something is actively using the microphone, and so when the // connection can safely be closed. private _inUse = false; constructor(api: CardMicrophoneAPI) { this._api = api; } public getState(): MicrophoneState { return this._state; } public initialize(): void { this._setState(); } public shouldConnectOnInitialization(): boolean { return ( !!this._api.getConfigManager().getConfig()?.live.microphone?.always_connected && // If it won't be possible to connect the microphone at all, we do not // block the initialization of the card (the microphone just won't work) this.isSupported() ); } public isSupported(): boolean { // Some browsers will have mediaDevices/getUserMedia as undefined if // accessed over http. // See: https://github.com/dermotduffy/advanced-camera-card/issues/1543 return !!navigator.mediaDevices?.getUserMedia; } public async connect(): Promise { if (!this.isSupported()) { throw new MicrophoneNotSupportedError(); } try { this._stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false, }); } catch (e: unknown) { this._stream = null; this._setState(); throw e; } this._setDesiredMuteOnStream(); this._setState(); } public disconnect(): void { this._timer.stop(); this._stream?.getTracks().forEach((track) => track.stop()); this._stream = undefined; this._setState(); } // Marks the microphone as in use (e.g. by an in-progress call). It stays // connected regardless of `disconnect_seconds` until `stopUsing`, since // stopping the tracks under a user would silently cut their audio. public startUsing(): void { this._inUse = true; this._timer.stop(); } // Marks the microphone as no longer in use. It is idle again, so the // disconnect countdown restarts from the full `disconnect_seconds`. public stopUsing(): void { this._inUse = false; this._startDisconnectTimer(); } public getStream(): MediaStream | undefined { return this._stream ?? undefined; } public mute(): void { this._desireMute = true; this._setDesiredMuteOnStream(); this._setState(); } public async unmute(): Promise { if (!this.isSupported()) { return; } this._desireMute = false; if (!this.isConnected() && !this.isForbidden()) { // Connecting will automatically set the desired mute. await this.connect(); } else if (this.isConnected()) { this._setDesiredMuteOnStream(); this._setState(); } } public isConnected(): boolean { return !!this._stream; } public isForbidden(): boolean { return this._stream === null; } public isMuted(): boolean { // For safety, this function always returns the stream mute status directly // (rather the desired internal state). return !this._stream || this._stream.getTracks().every((track) => !track.enabled); } private _setDesiredMuteOnStream(): void { this._stream?.getTracks().forEach((track) => { track.enabled = !this._desireMute; }); this._startDisconnectTimer(); } private _startDisconnectTimer(): void { const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone; if (microphoneConfig?.always_connected || this._inUse) { return; } const disconnectSeconds = microphoneConfig?.disconnect_seconds ?? 0; if (disconnectSeconds) { this._timer.start(disconnectSeconds, () => { this.disconnect(); }); } } private _setState(): void { this._state = { stream: this._stream, connected: this.isConnected(), muted: this.isMuted(), forbidden: this.isForbidden(), }; this._api.getConditionStateManager().setState({ microphone: this._state, }); this._api.getCardElementManager().update(); } }