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:
Dermot Duffy
2026-08-14 16:17:31 -07:00
committed by GitHub
parent 65bc52d18e
commit 85d6811761
33 changed files with 1480 additions and 572 deletions
+87
View File
@@ -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,
];