fix: Remove the microphone selection conditions (#2686)

- Closes: #2679

BREAKING CHANGE: `selected` and `unselected` are removed from
`live.microphone.auto_unmute` / `auto_mute`. A camera change ends any
call and the microphone only carries audio during a call, so neither
were useful.
This commit is contained in:
Dermot Duffy
2026-08-14 18:16:39 -07:00
committed by GitHub
parent 85d6811761
commit c2f25861ea
8 changed files with 261 additions and 244 deletions
+6 -6
View File
@@ -222,12 +222,12 @@ live:
microphone:
```
| Option | Default | Description |
| ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `always_connected` | `false` | Whether or not to keep the microphone connected while the card is running. By default the microphone is connected when a [two-way audio](../usage/2-way-audio.md) call needs it and disconnected when that call ends. Setting this to `true` connects it at card load and never disconnects it, which avoids the connection setup on the first call at the cost of the browser reporting the microphone as in use for as long as the card is running. |
| `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. The microphone is always muted when a call ends. |
| `auto_unmute` | `[]` | A list of conditions in which the microphone is unmuted. `call` will automatically unmute the microphone when a [two-way audio](../usage/2-way-audio.md) call is started (or answered for inbound calls). `selected` will automatically unmute the microphone when a camera is selected in the carousel or grid. `visible` will automatically unmute when the card becomes visible. By default this list is empty, so the microphone stays muted even after answering (push-to-talk) -- tap the microphone button in the call overlay to talk. Unmuting only has an effect during a call: at any other time nothing can carry the audio, so the request is ignored. |
| `mute_after_microphone_mute_seconds` | `60` | The number of seconds after the microphone mutes to automatically mute the inbound audio when `live.auto_mute` includes `microphone`. |
| Option | Default | Description |
| ------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `always_connected` | `false` | Whether or not to keep the microphone connected while the card is running. By default the microphone is connected when a [two-way audio](../usage/2-way-audio.md) call needs it and disconnected when that call ends. Setting this to `true` connects it at card load and never disconnects it, which avoids the connection setup on the first call at the cost of the browser reporting the microphone as in use for as long as the card is running. |
| `auto_mute` | `[]` | A list of conditions in which the microphone is muted. `hidden` will automatically mute the microphone when the card becomes hidden (e.g. browser/tab change, or the card scrolling out of view). Use an empty list (`[]`, the default) to never automatically mute the microphone this way. The microphone is always muted when a call ends. |
| `auto_unmute` | `[]` | A list of conditions in which the microphone is unmuted. `call` will automatically unmute the microphone when a [two-way audio](../usage/2-way-audio.md) call is started (or answered for inbound calls). `visible` will automatically unmute the microphone when the card becomes visible again. By default this list is empty, so the microphone stays muted even after answering (push-to-talk) -- tap the microphone button in the call overlay to talk. Unmuting only has an effect during a call: at any other time nothing can carry the audio, so the request is ignored. |
| `mute_after_microphone_mute_seconds` | `60` | The number of seconds after the microphone mutes to automatically mute the inbound audio when `live.auto_mute` includes `microphone`. |
See [Using 2-way audio](../usage/2-way-audio.md) for more information about the very particular requirements that must be followed for 2-way audio to work.
@@ -31,10 +31,11 @@ export const getLiveAutoUnmuteOptions = (): HASelectSelectorOption[] => [
condition('call', 'call_unmute'),
];
export const getMicrophoneMuteOptions = (): HASelectSelectorOption[] =>
getMediaActionNegativeOptions();
export const getMicrophoneMuteOptions = (): HASelectSelectorOption[] => [
condition('hidden'),
];
export const getMicrophoneUnmuteOptions = (): HASelectSelectorOption[] => [
...getMediaActionPositiveOptions(),
condition('visible'),
condition('call', 'call_unmute'),
];
@@ -1,3 +1,4 @@
import type { CallSession } from '../../card-controller/call/types.js';
import type { MicrophoneManager } from '../../card-controller/microphone-manager.js';
import type {
MicrophoneAutoMuteCondition,
@@ -17,16 +18,13 @@ interface MicrophoneActionsControllerOptions {
* 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 _callAnswered = false;
private _answeredCall: CallSession | null = null;
private _visibilityObserver: VisibilityObserver;
constructor() {
@@ -40,23 +38,28 @@ export class MicrophoneActionsController {
}
/**
* Notifies the controller of the call-answered state (outbound calls are
* Notifies the controller of the active call session (outbound calls are
* answered at start; inbound calls only become answered when the user
* accepts). Acts only on a genuine transition. The initial state is treated
* as unanswered, so a first-ever `true` counts -- the call rules apply even
* when the live view first appears during an answered call.
* accepts). A session that is still ringing unmutes nothing, so the user is
* never heard before accepting.
*
* Call answer unmutes the microphone only if the user opted into
* Each session is a distinct object, so a call that replaces another is
* acted on in its own right: the microphone connection carries over, but the
* user is muted or unmuted by the new call's own rules rather than
* inheriting where the previous call left them.
*
* An answered session unmutes the microphone only if the user opted into
* `microphone.auto_unmute: ['call']`. There is no symmetric mute: the
* microphone manager mutes and releases the microphone itself when the call
* ends.
*/
public async setCallAnswered(answered: boolean): Promise<void> {
if (answered === this._callAnswered) {
public async setCall(call?: CallSession): Promise<void> {
const answeredCall = call?.answered ? call : null;
if (answeredCall === this._answeredCall) {
return;
}
this._callAnswered = answered;
if (answered) {
this._answeredCall = answeredCall;
if (answeredCall) {
await this._unmuteIfConfigured('call');
}
}
@@ -66,30 +69,9 @@ export class MicrophoneActionsController {
}
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');
+1 -19
View File
@@ -81,26 +81,8 @@ export class AdvancedCameraCardLive extends LitElement {
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();
void this._microphoneActionsController.setSelectedCamera(
view?.is('live') ? view.camera ?? null : null,
);
}
if (changedProps.has('call')) {
// Gate on `answered`, not mere presence, so `microphone.auto_unmute:
// ['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)
.catch(() => {});
this._microphoneActionsController.setCall(this.call).catch(() => {});
}
}
+19 -7
View File
@@ -1561,17 +1561,19 @@ const removeMicrophoneActionsTransform = (data: unknown): boolean => {
};
/**
* Remove a value from an array. The array is kept even when it empties: inside
* Remove values 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
* rather than leaving nothing. An array that holds none of the values is left
* alone.
*/
const removeFromArrayTransform = (removed: unknown): ((value: unknown) => unknown) => {
const removeFromArrayTransform = (
...removed: unknown[]
): ((value: unknown) => unknown) => {
return (value: unknown): unknown => {
if (!Array.isArray(value) || !value.includes(removed)) {
if (!Array.isArray(value) || !value.some((item) => removed.includes(item))) {
return undefined;
}
return value.filter((item) => item !== removed);
return value.filter((item) => !removed.includes(item));
};
};
@@ -1811,9 +1813,19 @@ const UPGRADES = [
);
},
// Retire microphone parameters/actions not needed with 'call'.
// Retire microphone parameters/actions not needed with 'call'. The selection
// conditions go with them: the card ends any call when the selected camera
// changes, and the microphone only carries audio during a call.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2681
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2679
deleteWithOverrides('live.microphone.disconnect_seconds'),
upgradeWithOverrides('live.microphone.auto_mute', removeFromArrayTransform('call')),
upgradeWithOverrides(
'live.microphone.auto_mute',
removeFromArrayTransform('call', 'unselected'),
),
upgradeWithOverrides(
'live.microphone.auto_unmute',
removeFromArrayTransform('selected'),
),
removeMicrophoneActionsTransform,
];
+5 -5
View File
@@ -13,12 +13,12 @@ export const MEDIA_UNMUTE_CONDITIONS = [
'call',
] as const;
export const MICROPHONE_MUTE_CONDITIONS = [...MEDIA_ACTION_NEGATIVE_CONDITIONS] as const;
// The microphone conditions have no 'selection' counterparts: the card ends any
// call when the selected camera changes, and the microphone only carries audio
// during a call.
export const MICROPHONE_MUTE_CONDITIONS = ['hidden'] as const;
export const MICROPHONE_UNMUTE_CONDITIONS = [
...MEDIA_ACTION_POSITIVE_CONDITIONS,
'call',
] as const;
export const MICROPHONE_UNMUTE_CONDITIONS = ['visible', 'call'] as const;
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { CallSession } from '../../../src/card-controller/call/types';
import type { MicrophoneManager } from '../../../src/card-controller/microphone-manager';
import { MicrophoneActionsController } from '../../../src/components-lib/live/microphone-actions-controller';
import {
@@ -10,6 +11,7 @@ import {
getMockIntersectionObserver,
IntersectionObserverMock,
} from '../../test-utils';
import { createView } from '../../view/test-utils';
const createMicrophoneManager = (): MicrophoneManager => {
const microphoneManager = mock<MicrophoneManager>();
@@ -17,6 +19,14 @@ const createMicrophoneManager = (): MicrophoneManager => {
return microphoneManager;
};
const createCallSession = (session?: Partial<CallSession>): CallSession => ({
cameraID: 'camera-1',
previousView: createView(),
inbound: false,
answered: true,
...session,
});
// @vitest-environment jsdom
describe('MicrophoneActionsController', () => {
beforeAll(() => {
@@ -42,161 +52,6 @@ describe('MicrophoneActionsController', () => {
});
});
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).toHaveBeenCalledTimes(1);
expect(microphoneManager.mute).not.toHaveBeenCalled();
});
it('should swallow a rejected auto-unmute so a denied microphone does not surface', async () => {
const microphoneManager = createMicrophoneManager();
vi.mocked(microphoneManager.unmute).mockRejectedValue(new Error('denied'));
const controller = new MicrophoneActionsController();
controller.setOptions({
microphoneManager,
autoUnmuteConditions: ['selected' as const],
});
await expect(controller.setSelectedCamera('camera-1')).resolves.toBeUndefined();
expect(microphoneManager.unmute).toHaveBeenCalledTimes(1);
});
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).toHaveBeenCalledTimes(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).toHaveBeenCalledTimes(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).toHaveBeenCalledTimes(1);
expect(microphoneManager.unmute).not.toHaveBeenCalled();
});
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.toHaveBeenCalled();
expect(microphoneManager.unmute).toHaveBeenCalledTimes(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.toHaveBeenCalled();
expect(microphoneManager.unmute).not.toHaveBeenCalled();
});
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();
@@ -306,9 +161,66 @@ describe('MicrophoneActionsController', () => {
expect(microphoneManager.mute).not.toHaveBeenCalled();
expect(microphoneManager.unmute).not.toHaveBeenCalled();
});
it('should swallow a rejected auto-unmute so a denied microphone does not surface', async () => {
const microphoneManager = createMicrophoneManager();
vi.mocked(microphoneManager.unmute).mockRejectedValue(new Error('denied'));
const controller = new MicrophoneActionsController();
controller.setOptions({
microphoneManager,
autoUnmuteConditions: ['visible' as const],
});
controller.setRoot(createParent());
await callIntersectionHandler(false);
await expect(callIntersectionHandler(true)).resolves.toBeUndefined();
expect(microphoneManager.unmute).toHaveBeenCalledTimes(1);
});
it('should not act when no condition is configured', async () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({ microphoneManager });
controller.setRoot(createParent());
await callIntersectionHandler(true);
await callIntersectionHandler(false);
await callIntersectionHandler(true);
expect(microphoneManager.mute).not.toHaveBeenCalled();
expect(microphoneManager.unmute).not.toHaveBeenCalled();
});
it('should not act when conditions are configured but no microphone manager is', 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: ['hidden' as const],
autoUnmuteConditions: ['visible' as const],
});
controller.setRoot(createParent());
await callIntersectionHandler(true);
await expect(callIntersectionHandler(false)).resolves.toBeUndefined();
await expect(callIntersectionHandler(true)).resolves.toBeUndefined();
});
it('should not act before options are set', async () => {
const controller = new MicrophoneActionsController();
controller.setRoot(createParent());
await callIntersectionHandler(true);
await expect(callIntersectionHandler(false)).resolves.toBeUndefined();
await expect(callIntersectionHandler(true)).resolves.toBeUndefined();
});
});
describe('on call answered state change', () => {
describe('on call session change', () => {
it('should unmute on call answer when call is a configured unmute condition', async () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
@@ -317,13 +229,13 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: ['call' as const],
});
await controller.setCallAnswered(false);
await controller.setCallAnswered(true);
await controller.setCall(createCallSession({ inbound: true, answered: false }));
await controller.setCall(createCallSession({ inbound: true }));
expect(microphoneManager.unmute).toHaveBeenCalledTimes(1);
});
it('should unmute when the call is already answered on first notification', async () => {
it('should unmute when the first call session is already answered', async () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
@@ -331,10 +243,42 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: ['call' as const],
});
// `setCallAnswered(true)` is the first call-state signal, with no
// preceding `false` -- as for a live view that mounts while a call is
// already answered. The initial state must not be swallowed as a baseline.
await controller.setCallAnswered(true);
// Scenario: An outbound call started from the gallery, installs the
// answered session and then navigates to live, so the live view's first
// sight of the call is already answered.
await controller.setCall(createCallSession());
expect(microphoneManager.unmute).toHaveBeenCalledTimes(1);
});
it('should unmute again for a call that replaces an answered call', async () => {
// A replacement call keeps the microphone connected, but the user may
// have muted themselves during the call it replaced, so the new call
// applies its own unmute rules rather than inheriting that mute.
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
microphoneManager,
autoUnmuteConditions: ['call' as const],
});
await controller.setCall(createCallSession({ cameraID: 'camera-1' }));
await controller.setCall(createCallSession({ cameraID: 'camera-2' }));
expect(microphoneManager.unmute).toHaveBeenCalledTimes(2);
});
it('should not unmute again when the same call session is set again', async () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
microphoneManager,
autoUnmuteConditions: ['call' as const],
});
const call = createCallSession();
await controller.setCall(call);
await controller.setCall(call);
expect(microphoneManager.unmute).toHaveBeenCalledTimes(1);
});
@@ -347,15 +291,15 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: ['call' as const],
});
await controller.setCallAnswered(true);
await controller.setCallAnswered(false);
await controller.setCall(createCallSession());
await controller.setCall();
// The microphone manager mutes and releases the microphone itself when
// the call ends.
expect(microphoneManager.mute).not.toHaveBeenCalled();
});
it('should not act on the initial call state', async () => {
it('should not act on a ringing call', async () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
@@ -363,7 +307,7 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: ['call' as const],
});
await controller.setCallAnswered(false);
await controller.setCall(createCallSession({ inbound: true, answered: false }));
expect(microphoneManager.mute).not.toHaveBeenCalled();
expect(microphoneManager.unmute).not.toHaveBeenCalled();
@@ -377,8 +321,8 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: [],
});
await controller.setCallAnswered(false);
await controller.setCallAnswered(true);
await controller.setCall(createCallSession({ inbound: true, answered: false }));
await controller.setCall(createCallSession({ inbound: true }));
expect(microphoneManager.unmute).not.toHaveBeenCalled();
});
+97 -1
View File
@@ -3849,7 +3849,7 @@ describe('should handle version specific upgrades', () => {
postUpgradeChecks(config);
});
it('should leave a microphone auto_mute without call alone', () => {
it('should not touch a microphone auto_mute without call or unselected', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
@@ -3865,6 +3865,102 @@ describe('should handle version specific upgrades', () => {
});
});
it('should strip unselected from the microphone auto_mute', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_mute: ['unselected', 'hidden', 'call'] } },
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { auto_mute: ['hidden'] } },
});
postUpgradeChecks(config);
});
it('should strip selected from the microphone auto_unmute', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_unmute: ['selected', 'visible', 'call'] } },
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { auto_unmute: ['visible', 'call'] } },
});
postUpgradeChecks(config);
});
it('should empty an overridden microphone auto_unmute rather than delete it', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_unmute: ['call' as const] } },
overrides: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
set: { 'live.microphone.auto_unmute': ['selected'] },
},
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: false }],
merge: { live: { microphone: { auto_unmute: ['selected'] } } },
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
// Deleting the key would restore the base `['call']` for anyone the
// override applies to, rather than leaving them with nothing.
expect(config.overrides[0].set).toEqual({
live: { microphone: { auto_unmute: [] } },
});
expect(config.overrides[1].merge).toEqual({
live: { microphone: { auto_unmute: [] } },
});
postUpgradeChecks(config);
});
it('should not touch a microphone auto_unmute without selected', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_unmute: ['visible', 'call'] } },
};
expect(upgradeConfig(config)).toBeFalsy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { auto_unmute: ['visible', 'call'] } },
});
});
it('should not strip selected from the live auto_unmute', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { auto_unmute: ['selected', 'visible'] },
};
expect(upgradeConfig(config)).toBeFalsy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { auto_unmute: ['selected', 'visible'] },
});
});
it('should not strip call from the live auto_mute', () => {
const config = {
type: 'custom:advanced-camera-card' as const,