fix: Release the microphone to the browser when a call ends (#2685)
The microphone is connected when a call needs it and released the moment that call ends, so the browser stops reporting it as in use at hangup rather than `disconnect_seconds` later. Existing configurations are migrated automatically by the visual editor. - Closes: #2681 BREAKING CHANGE: `live.microphone.disconnect_seconds` is removed. The microphone is released when a call ends, so there is no idle countdown to configure. Use `live.microphone.always_connected` to hold it open instead. BREAKING CHANGE: The `microphone_connect` and `microphone_disconnect` actions are removed. The card owns the microphone lifecycle; `microphone_mute` and `microphone_unmute` remain. BREAKING CHANGE: `call` is removed from `live.microphone.auto_mute`, whose default is now `[]`. The microphone is muted when a call ends regardless of this option. BREAKING CHANGE: `microphone_unmute` has no effect outside a call. Nothing carries the audio at any other time, so the request is ignored rather than opening the microphone.
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
import type { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import type { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class MicrophoneConnectAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
await api.getMicrophoneManager().connect();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import type { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class MicrophoneDisconnectAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
api.getMicrophoneManager().disconnect();
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,6 @@ import { InternalCallbackAction } from './actions/internal-callback';
|
||||
import { LogAction } from './actions/log';
|
||||
import { MediaPlayerAction } from './actions/media-player';
|
||||
import { MenuToggleAction } from './actions/menu-toggle';
|
||||
import { MicrophoneConnectAction } from './actions/microphone-connect';
|
||||
import { MicrophoneDisconnectAction } from './actions/microphone-disconnect';
|
||||
import { MicrophoneMuteAction } from './actions/microphone-mute';
|
||||
import { MicrophoneUnmuteAction } from './actions/microphone-unmute';
|
||||
import { MoreInfoAction } from './actions/more-info';
|
||||
@@ -159,10 +157,6 @@ export class ActionFactory {
|
||||
return new SubstreamOnAction(context, action, options?.config);
|
||||
case 'media_player':
|
||||
return new MediaPlayerAction(context, action, options?.config);
|
||||
case 'microphone_connect':
|
||||
return new MicrophoneConnectAction(context, action, options?.config);
|
||||
case 'microphone_disconnect':
|
||||
return new MicrophoneDisconnectAction(context, action, options?.config);
|
||||
case 'microphone_mute':
|
||||
return new MicrophoneMuteAction(context, action, options?.config);
|
||||
case 'microphone_unmute':
|
||||
|
||||
@@ -109,7 +109,7 @@ export class CallManager {
|
||||
// granting microphone access -- so connecting here would let a refusal stop
|
||||
// the call from ever ringing. The connect is deferred to `answer()`. An
|
||||
// outbound call is answered by construction and needs it immediately.
|
||||
if (!inbound && !(await this._connectMicrophone())) {
|
||||
if (!inbound && !(await this._grantTransmissionAndConnect())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,8 @@ export class CallManager {
|
||||
if (inbound && (existingCall.answered || !existingCall.inbound)) {
|
||||
return false;
|
||||
}
|
||||
this._end(false);
|
||||
// The replacement call inherits the microphone.
|
||||
this._end(false, { retainMicrophone: true });
|
||||
}
|
||||
|
||||
// Outbound calls are answered by construction (the user initiated them);
|
||||
@@ -152,9 +153,16 @@ export class CallManager {
|
||||
answered,
|
||||
};
|
||||
|
||||
// The microphone's own idle timeout knows nothing about calls, so without
|
||||
// this the tracks would be stopped mid-conversation.
|
||||
this._api.getMicrophoneManager().startUsing();
|
||||
// `_grantTransmissionAndConnect` above reported the need for transmission
|
||||
// before awaiting the microphone connect; a call ending during that await
|
||||
// may have withdrawn it since, so an answered session reports it again --
|
||||
// ahead of the view and condition state below, whose listeners may replace
|
||||
// the session. A ringing session reports nothing: it transmits nothing,
|
||||
// and reporting inactive could end a transmission another request is
|
||||
// using.
|
||||
if (answered) {
|
||||
this._api.getMicrophoneManager().setTransmissionActive(true);
|
||||
}
|
||||
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
...(needsNavigation && {
|
||||
@@ -219,13 +227,14 @@ export class CallManager {
|
||||
// An inbound call rings without the microphone, so this is where it is
|
||||
// connected -- under the user gesture that answering provides. The call is
|
||||
// left ringing on failure so it can be answered again.
|
||||
if (!(await this._connectMicrophone())) {
|
||||
if (!(await this._grantTransmissionAndConnect())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The call may have ended, or been superseded by another, while the
|
||||
// microphone connect was in flight -- there is then nothing left to answer.
|
||||
if (this._call !== call) {
|
||||
this._revokeTransmissionIfNoAnsweredCall();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -287,9 +296,9 @@ export class CallManager {
|
||||
this._initGeneration.invalidate();
|
||||
this._ringtone.stop();
|
||||
this._unansweredTimer.stop();
|
||||
this._api.getMicrophoneManager().setTransmissionActive(false);
|
||||
if (this._call) {
|
||||
this._call = null;
|
||||
this._api.getMicrophoneManager().stopUsing();
|
||||
this._api.getConditionStateManager().setState({ call: 'idle' });
|
||||
}
|
||||
this._api
|
||||
@@ -302,8 +311,9 @@ export class CallManager {
|
||||
// is `false` for auto-ends (navigating away, camera/substream change), where
|
||||
// the user has already chosen a destination and the pre-call view is
|
||||
// deliberately not reinstated; only the manager's own auto-end paths pass it.
|
||||
// `retainMicrophone` does not relinquish the microphone.
|
||||
// Returns true iff a call was actually ended.
|
||||
private _end(restoreView: boolean): boolean {
|
||||
private _end(restoreView: boolean, options?: { retainMicrophone?: boolean }): boolean {
|
||||
if (!this._call) {
|
||||
return false;
|
||||
}
|
||||
@@ -319,9 +329,9 @@ export class CallManager {
|
||||
// and recurse.
|
||||
this._call = null;
|
||||
|
||||
// The call no longer needs the microphone, so the normal
|
||||
// `disconnect_seconds` countdown restarts from here.
|
||||
this._api.getMicrophoneManager().stopUsing();
|
||||
if (!options?.retainMicrophone && call.answered) {
|
||||
this._api.getMicrophoneManager().setTransmissionActive(false);
|
||||
}
|
||||
|
||||
const viewManager = this._api.getViewManager();
|
||||
|
||||
@@ -424,9 +434,29 @@ export class CallManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private _revokeTransmissionIfNoAnsweredCall(): void {
|
||||
if (!this._call?.answered) {
|
||||
this._api.getMicrophoneManager().setTransmissionActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The microphone manager releases a stream that connects while transmission
|
||||
// is inactive, so transmission is activated first and revoked on failure.
|
||||
// Returns true iff the microphone is connected and this request is still
|
||||
// current.
|
||||
private async _grantTransmissionAndConnect(): Promise<boolean> {
|
||||
this._api.getMicrophoneManager().setTransmissionActive(true);
|
||||
if (!(await this._connectMicrophone())) {
|
||||
this._revokeTransmissionIfNoAnsweredCall();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Connects the microphone for a call. Returns true iff it is connected and
|
||||
// this request still belongs to the current init/uninit lifecycle. A connect
|
||||
// failure is surfaced as a notification.
|
||||
// this request still belongs to the current init/uninit lifecycle. A denied
|
||||
// connect is surfaced as a notification; a connect superseded by a newer
|
||||
// request fails silently (the newer request owns the outcome).
|
||||
private async _connectMicrophone(): Promise<boolean> {
|
||||
const microphoneManager = this._api.getMicrophoneManager();
|
||||
if (microphoneManager.isConnected()) {
|
||||
@@ -435,11 +465,12 @@ export class CallManager {
|
||||
|
||||
const initGeneration = this._initGeneration.current();
|
||||
let connected = false;
|
||||
let forbidden = false;
|
||||
try {
|
||||
await microphoneManager.connect();
|
||||
connected = true;
|
||||
connected = await microphoneManager.connect();
|
||||
} catch {
|
||||
// Reported below, once this request is known to still be the current one.
|
||||
forbidden = true;
|
||||
}
|
||||
|
||||
// If the init/uninit lifecycle advanced while the connect was in flight,
|
||||
@@ -450,11 +481,10 @@ export class CallManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
if (forbidden) {
|
||||
this._notifyError('error.call_microphone_forbidden');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return connected;
|
||||
}
|
||||
|
||||
private _hasCallCapability(cameraID: string): boolean {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { localize } from '../localize/localize';
|
||||
import { AdvancedCameraCardError } from '../types';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { Generation } from '../utils/concurrency/generation';
|
||||
import type { CardMicrophoneAPI, MicrophoneState } from './types';
|
||||
|
||||
export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
|
||||
@@ -11,8 +11,11 @@ export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
|
||||
|
||||
export class MicrophoneManager {
|
||||
private _api: CardMicrophoneAPI;
|
||||
private _stream?: MediaStream | null;
|
||||
private _timer = new Timer();
|
||||
private _stream: MediaStream | null = null;
|
||||
|
||||
// Whether the browser denied the most recent microphone request. Cleared by
|
||||
// a later successful connect.
|
||||
private _forbidden = false;
|
||||
|
||||
private _state: MicrophoneState = {
|
||||
connected: false,
|
||||
@@ -25,9 +28,14 @@ export class MicrophoneManager {
|
||||
// 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;
|
||||
// Whether an outgoing audio path is active, i.e. something is consuming the
|
||||
// microphone stream. While active, mute only disables the tracks so unmute is
|
||||
// instant; while inactive, a muted microphone is released outright.
|
||||
private _transmissionActive = false;
|
||||
|
||||
// Guards in-flight getUserMedia requests: a result that resolves after a
|
||||
// newer connect or a release must not be installed.
|
||||
private _connectGeneration = new Generation();
|
||||
|
||||
constructor(api: CardMicrophoneAPI) {
|
||||
this._api = api;
|
||||
@@ -57,72 +65,92 @@ export class MicrophoneManager {
|
||||
return !!navigator.mediaDevices?.getUserMedia;
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
// Returns true iff the microphone is connected when this request completes:
|
||||
// a request superseded by a newer connect or a release resolves false, as
|
||||
// does one whose stream is immediately released for want of an active
|
||||
// transmission. A denied request throws.
|
||||
public async connect(): Promise<boolean> {
|
||||
if (!this.isSupported()) {
|
||||
throw new MicrophoneNotSupportedError();
|
||||
}
|
||||
|
||||
const generation = this._connectGeneration.next();
|
||||
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
this._stream = await navigator.mediaDevices.getUserMedia({
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
video: false,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
this._stream = null;
|
||||
this._setState();
|
||||
// A stale rejection must not mark the microphone forbidden.
|
||||
if (this._connectGeneration.isCurrent(generation)) {
|
||||
this._releaseStream();
|
||||
this._forbidden = true;
|
||||
this._setState();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
this._setDesiredMuteOnStream();
|
||||
|
||||
if (!this._connectGeneration.isCurrent(generation)) {
|
||||
// Superseded while the permission prompt was up: this stream must not
|
||||
// survive as an open capture nothing is tracking.
|
||||
this._stopTracks(stream);
|
||||
return false;
|
||||
}
|
||||
|
||||
// A connect over an existing stream must not leak the tracks of the
|
||||
// stream it replaces.
|
||||
this._stopTracks(this._stream);
|
||||
this._stream = stream;
|
||||
this._forbidden = false;
|
||||
this._reconcile();
|
||||
this._setState();
|
||||
return this.isConnected();
|
||||
}
|
||||
|
||||
// Reports whether an outgoing audio path is active. When transmission ends,
|
||||
// the microphone returns to muted and -- unless `always_connected` -- the
|
||||
// device is released.
|
||||
public setTransmissionActive(transmissionActive: boolean): void {
|
||||
if (this._transmissionActive === transmissionActive) {
|
||||
return;
|
||||
}
|
||||
this._transmissionActive = transmissionActive;
|
||||
if (!transmissionActive) {
|
||||
this._desireMute = true;
|
||||
}
|
||||
this._reconcile();
|
||||
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 getStream(): MediaStream | null {
|
||||
return this._stream;
|
||||
}
|
||||
|
||||
public mute(): void {
|
||||
this._desireMute = true;
|
||||
this._setDesiredMuteOnStream();
|
||||
this._reconcile();
|
||||
this._setState();
|
||||
}
|
||||
|
||||
public async unmute(): Promise<void> {
|
||||
if (!this.isSupported()) {
|
||||
// An unmute without an active outgoing audio path is meaningless: nothing
|
||||
// consumes the stream, so enabling (or creating) a capture would only light
|
||||
// the browser recording indicator.
|
||||
if (!this.isSupported() || !this._transmissionActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._desireMute = false;
|
||||
|
||||
if (!this.isConnected() && !this.isForbidden()) {
|
||||
// Connecting will automatically set the desired mute.
|
||||
// Connecting applies the desired mute to the new stream.
|
||||
await this.connect();
|
||||
} else if (this.isConnected()) {
|
||||
this._setDesiredMuteOnStream();
|
||||
this._setState();
|
||||
return;
|
||||
}
|
||||
this._reconcile();
|
||||
this._setState();
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
@@ -130,7 +158,7 @@ export class MicrophoneManager {
|
||||
}
|
||||
|
||||
public isForbidden(): boolean {
|
||||
return this._stream === null;
|
||||
return this._forbidden;
|
||||
}
|
||||
|
||||
public isMuted(): boolean {
|
||||
@@ -139,28 +167,35 @@ export class MicrophoneManager {
|
||||
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 _stopTracks(stream: MediaStream | null): void {
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
private _startDisconnectTimer(): void {
|
||||
const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone;
|
||||
private _releaseStream(): void {
|
||||
this._connectGeneration.invalidate();
|
||||
this._stopTracks(this._stream);
|
||||
this._stream = null;
|
||||
}
|
||||
|
||||
if (microphoneConfig?.always_connected || this._inUse) {
|
||||
// The single place that applies microphone policy to the device: whether
|
||||
// the device is held or released, and whether its tracks are live. A muted
|
||||
// microphone with no active transmission is released entirely (turning the
|
||||
// browser recording indicator off) unless `always_connected`; while
|
||||
// transmission is active, mute only disables the tracks so unmute needs no
|
||||
// new permission request or renegotiation.
|
||||
private _reconcile(): void {
|
||||
if (!this._stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const disconnectSeconds = microphoneConfig?.disconnect_seconds ?? 0;
|
||||
|
||||
if (disconnectSeconds) {
|
||||
this._timer.start(disconnectSeconds, () => {
|
||||
this.disconnect();
|
||||
});
|
||||
const alwaysConnected = !!this._api.getConfigManager().getConfig()?.live.microphone
|
||||
?.always_connected;
|
||||
if (this._desireMute && !this._transmissionActive && !alwaysConnected) {
|
||||
this._releaseStream();
|
||||
return;
|
||||
}
|
||||
this._stream.getTracks().forEach((track) => {
|
||||
track.enabled = !this._desireMute;
|
||||
});
|
||||
}
|
||||
|
||||
private _setState(): void {
|
||||
|
||||
@@ -31,10 +31,8 @@ export const getLiveAutoUnmuteOptions = (): HASelectSelectorOption[] => [
|
||||
condition('call', 'call_unmute'),
|
||||
];
|
||||
|
||||
export const getMicrophoneMuteOptions = (): HASelectSelectorOption[] => [
|
||||
...getMediaActionNegativeOptions(),
|
||||
condition('call', 'call_mute'),
|
||||
];
|
||||
export const getMicrophoneMuteOptions = (): HASelectSelectorOption[] =>
|
||||
getMediaActionNegativeOptions();
|
||||
|
||||
export const getMicrophoneUnmuteOptions = (): HASelectSelectorOption[] => [
|
||||
...getMediaActionPositiveOptions(),
|
||||
|
||||
@@ -103,7 +103,6 @@ const getMicrophoneSchema = (): HAFormExpandableSchema => ({
|
||||
title: localize('config.live.microphone.editor_label'),
|
||||
icon: 'mdi:microphone',
|
||||
schema: [
|
||||
{ name: 'disconnect_seconds', selector: createNumberSelector({ min: 0 }) },
|
||||
{ name: 'always_connected', selector: { boolean: {} } },
|
||||
{
|
||||
name: 'auto_mute',
|
||||
|
||||
@@ -47,18 +47,17 @@ export class MicrophoneActionsController {
|
||||
* when the live view first appears during an answered call.
|
||||
*
|
||||
* Call answer unmutes the microphone only if the user opted into
|
||||
* `microphone.auto_unmute: ['call']`; the symmetric mute fires on the
|
||||
* answered-to-unanswered transition (call end after answer).
|
||||
* `microphone.auto_unmute: ['call']`. There is no symmetric mute: the
|
||||
* microphone manager mutes and releases the microphone itself when the call
|
||||
* ends.
|
||||
*/
|
||||
public setCallAnswered(answered: boolean): void {
|
||||
public async setCallAnswered(answered: boolean): Promise<void> {
|
||||
if (answered === this._callAnswered) {
|
||||
return;
|
||||
}
|
||||
this._callAnswered = answered;
|
||||
if (answered) {
|
||||
void this._unmuteIfConfigured('call');
|
||||
} else {
|
||||
this._muteIfConfigured('call');
|
||||
await this._unmuteIfConfigured('call');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,9 @@ export class AdvancedCameraCardLive extends LitElement {
|
||||
// ['call']` doesn't transmit audio during the pre-answer ringing state.
|
||||
// Outbound calls are answered at construction; inbound calls only after
|
||||
// the user accepts.
|
||||
this._microphoneActionsController.setCallAnswered(!!this.call?.answered);
|
||||
this._microphoneActionsController
|
||||
.setCallAnswered(!!this.call?.answered)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1494,6 +1494,87 @@ const triggersEventsToMediaEventsTransform = (triggers: unknown): unknown => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const REMOVED_MICROPHONE_ACTIONS = ['microphone_connect', 'microphone_disconnect'];
|
||||
|
||||
// The properties that hold actions: the tap handlers of elements, menu buttons,
|
||||
// notification controls and views (`actionsBaseSchema`), the actions of an
|
||||
// automation, and the branches of an `if` action. Each holds either a single
|
||||
// action or a list of them.
|
||||
const ACTION_PROPERTIES = [
|
||||
'actions',
|
||||
'double_tap_action',
|
||||
'else',
|
||||
'end_tap_action',
|
||||
'hold_action',
|
||||
'start_tap_action',
|
||||
'tap_action',
|
||||
'then',
|
||||
];
|
||||
|
||||
const isRemovedMicrophoneAction = (data: unknown): boolean =>
|
||||
isRecord(data) &&
|
||||
(data['action'] === 'fire-dom-event' ||
|
||||
data['action'] === 'custom:advanced-camera-card-action') &&
|
||||
typeof data['advanced_camera_card_action'] === 'string' &&
|
||||
REMOVED_MICROPHONE_ACTIONS.includes(data['advanced_camera_card_action']);
|
||||
|
||||
/**
|
||||
* Remove the `microphone_connect` / `microphone_disconnect` actions wherever
|
||||
* they appear. The whole tree is walked because card actions can appear
|
||||
* anywhere (menu buttons, elements, automations, view-action handlers, etc.),
|
||||
* but only the properties that hold actions are touched, so an object that
|
||||
* merely resembles an action -- the `data` of a `perform-action`, for
|
||||
* instance -- is left as the user wrote it.
|
||||
*
|
||||
* A property holding a single such action is deleted, since every one of those
|
||||
* is optional. A list keeps its property even when it empties: some are
|
||||
* required (`automations[].actions`, an `if` action's `then`) and an empty one
|
||||
* is valid everywhere.
|
||||
*/
|
||||
const removeMicrophoneActionsTransform = (data: unknown): boolean => {
|
||||
// Arrays are records too, so their entries are walked by the loop below.
|
||||
if (!isRecord(data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
for (const key of Object.keys(data)) {
|
||||
if (ACTION_PROPERTIES.includes(key)) {
|
||||
const value = data[key];
|
||||
if (isRemovedMicrophoneAction(value)) {
|
||||
delete data[key];
|
||||
modified = true;
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const kept = value.filter((item) => !isRemovedMicrophoneAction(item));
|
||||
if (kept.length !== value.length) {
|
||||
data[key] = kept;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modified = removeMicrophoneActionsTransform(data[key]) || modified;
|
||||
}
|
||||
return modified;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a value from an array. The array is kept even when it empties: inside
|
||||
* an override, deleting it would restore whatever the base configuration says
|
||||
* rather than leaving nothing. An array that does not hold the value is left
|
||||
* alone.
|
||||
*/
|
||||
const removeFromArrayTransform = (removed: unknown): ((value: unknown) => unknown) => {
|
||||
return (value: unknown): unknown => {
|
||||
if (!Array.isArray(value) || !value.includes(removed)) {
|
||||
return undefined;
|
||||
}
|
||||
return value.filter((item) => item !== removed);
|
||||
};
|
||||
};
|
||||
|
||||
const UPGRADES = [
|
||||
// v5.2.0 -> v6.0.0
|
||||
(data: unknown): boolean => {
|
||||
@@ -1729,4 +1810,10 @@ const UPGRADES = [
|
||||
isRecord(data) ? data : {},
|
||||
);
|
||||
},
|
||||
|
||||
// Retire microphone parameters/actions not needed with 'call'.
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2681
|
||||
deleteWithOverrides('live.microphone.disconnect_seconds'),
|
||||
upgradeWithOverrides('live.microphone.auto_mute', removeFromArrayTransform('call')),
|
||||
removeMicrophoneActionsTransform,
|
||||
];
|
||||
|
||||
@@ -10,8 +10,6 @@ const GENERAL_ACTIONS = [
|
||||
'fullscreen',
|
||||
'info',
|
||||
'menu_toggle',
|
||||
'microphone_connect',
|
||||
'microphone_disconnect',
|
||||
'microphone_mute',
|
||||
'microphone_unmute',
|
||||
'mute',
|
||||
|
||||
@@ -13,10 +13,7 @@ export const MEDIA_UNMUTE_CONDITIONS = [
|
||||
'call',
|
||||
] as const;
|
||||
|
||||
export const MICROPHONE_MUTE_CONDITIONS = [
|
||||
...MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||
'call',
|
||||
] as const;
|
||||
export const MICROPHONE_MUTE_CONDITIONS = [...MEDIA_ACTION_NEGATIVE_CONDITIONS] as const;
|
||||
|
||||
export const MICROPHONE_UNMUTE_CONDITIONS = [
|
||||
...MEDIA_ACTION_POSITIVE_CONDITIONS,
|
||||
|
||||
@@ -25,9 +25,8 @@ import { transitionEffectConfigSchema } from './common/transition-effect';
|
||||
|
||||
const microphoneConfigDefault = {
|
||||
always_connected: false,
|
||||
auto_mute: ['call' as const],
|
||||
auto_mute: [],
|
||||
auto_unmute: [],
|
||||
disconnect_seconds: 90,
|
||||
mute_after_microphone_mute_seconds: 60,
|
||||
};
|
||||
|
||||
@@ -80,10 +79,6 @@ const microphoneConfigSchema = z
|
||||
.enum(MICROPHONE_UNMUTE_CONDITIONS)
|
||||
.array()
|
||||
.default(microphoneConfigDefault.auto_unmute),
|
||||
disconnect_seconds: z
|
||||
.number()
|
||||
.min(0)
|
||||
.default(microphoneConfigDefault.disconnect_seconds),
|
||||
mute_after_microphone_mute_seconds: z
|
||||
.number()
|
||||
.min(0)
|
||||
|
||||
@@ -255,7 +255,6 @@
|
||||
"lazy_unload": "Les càmeres en directe es descarreguen amb mandra",
|
||||
"microphone": {
|
||||
"always_connected": "Mantingueu sempre el micròfon connectat",
|
||||
"disconnect_seconds": "Segons després dels quals desconnectar el micròfon (0=mai)",
|
||||
"editor_label": "Micròfon",
|
||||
"mute_after_microphone_mute_seconds": "Segons després de silenciar el micròfon per silenciar l'àudio entrant"
|
||||
},
|
||||
|
||||
@@ -215,7 +215,6 @@
|
||||
"live": {
|
||||
"microphone": {
|
||||
"always_connected": "Mikrofon immer verbunden lassen",
|
||||
"disconnect_seconds": "Sekunden nach denen das Mikrofon getrennt wird (0=niemals)",
|
||||
"editor_label": "Mikrofon",
|
||||
"mute_after_microphone_mute_seconds": "Sekunden nach denen das Mikrofon stumm geschalten wird, um eingehendes Audio stumm zu schalten"
|
||||
},
|
||||
|
||||
@@ -493,7 +493,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -336,7 +336,6 @@
|
||||
"lazy_unload": "Les caméras en direct sont déchargées en différé",
|
||||
"microphone": {
|
||||
"always_connected": "Toujours garder le microphone connecté",
|
||||
"disconnect_seconds": "Secondes après quoi déconnecter le microphone (0=jamais)",
|
||||
"editor_label": "Microphone",
|
||||
"mute_after_microphone_mute_seconds": "Secondes après la mise en sourdine du microphone pour couper l'audio entrant"
|
||||
},
|
||||
|
||||
@@ -378,7 +378,6 @@
|
||||
"lazy_unload": "Kamery wyładowywane leniwie",
|
||||
"microphone": {
|
||||
"always_connected": "Zawsze utrzymuj połączenie mikrofonu",
|
||||
"disconnect_seconds": "Sekundy do rozłączenia mikrofonu (0=nigdy)",
|
||||
"editor_label": "Mikrofon",
|
||||
"mute_after_microphone_mute_seconds": "Sekundy po wyciszeniu mikrofonu do wyciszenia dźwięku przychodzącego"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user