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
@@ -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];