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
+7 -37
View File
@@ -289,24 +289,6 @@ action: custom:advanced-camera-card-action
advanced_camera_card_action: menu_toggle advanced_camera_card_action: menu_toggle
``` ```
## `microphone_connect`
Connect the microphone for [2-way audio](../../../usage/2-way-audio.md).
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_connect
```
## `microphone_disconnect`
Disconnect the microphone during [2-way audio](../../../usage/2-way-audio.md).
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_disconnect
```
## `microphone_mute` ## `microphone_mute`
Mute the microphone during [2-way audio](../../../usage/2-way-audio.md). Mute the microphone during [2-way audio](../../../usage/2-way-audio.md).
@@ -913,48 +895,36 @@ elements:
advanced_camera_card_action: menu_toggle advanced_camera_card_action: menu_toggle
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-p-circle icon: mdi:alpha-p-circle
title: Microphone connect
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_connect
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-q-circle
title: Microphone disconnect
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_disconnect
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-r-circle
title: Microphone mute title: Microphone mute
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_mute advanced_camera_card_action: microphone_mute
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-s-circle icon: mdi:alpha-q-circle
title: Microphone unmute title: Microphone unmute
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
advanced_camera_card_action: microphone_unmute advanced_camera_card_action: microphone_unmute
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-t-circle icon: mdi:alpha-r-circle
title: Mute title: Mute
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
advanced_camera_card_action: mute advanced_camera_card_action: mute
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-u-circle icon: mdi:alpha-s-circle
title: Pause title: Pause
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
advanced_camera_card_action: pause advanced_camera_card_action: pause
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-v-circle icon: mdi:alpha-t-circle
title: Play title: Play
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
advanced_camera_card_action: play advanced_camera_card_action: play
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-x-circle icon: mdi:alpha-u-circle
title: Real PTZ Preset title: Real PTZ Preset
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
@@ -962,14 +932,14 @@ elements:
ptz_action: preset ptz_action: preset
ptz_preset: doorway ptz_preset: doorway
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-y-circle icon: mdi:alpha-v-circle
title: Show PTZ Controls title: Show PTZ Controls
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
advanced_camera_card_action: ptz_controls advanced_camera_card_action: ptz_controls
enabled: true enabled: true
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-z-circle icon: mdi:alpha-x-circle
title: Go to precise digital location title: Go to precise digital location
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
+18 -18
View File
@@ -421,28 +421,28 @@ triggers:
muted: true muted: true
``` ```
| Parameter | Description | | Parameter | Description |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `condition` / `trigger` | Must be `microphone`. | | `condition` / `trigger` | Must be `microphone`. |
| `connected` | If `true` or `false`, matches when the card does or does not hold an open microphone stream from the browser. See the note below. | | `connected` | If `true` or `false`, matches when the card does or does not hold an open microphone stream from the browser. See the note below. |
| `muted` | If `true` or `false`, matches when the microphone is muted or unmuted respectively. | | `muted` | If `true` or `false`, matches when the microphone is muted or unmuted respectively. A disconnected microphone counts as muted. See the note below. |
> [!NOTE] > [!NOTE]
> The `connected` parameter is about **browser microphone access**, not about > The `connected` parameter is about **browser microphone access**. It is `true`
> calls. It is `true` while the card holds an open microphone stream from the > while the card holds an open microphone stream from the browser -- the same
> browser -- the same state that lights your browser's "microphone in use" > state that lights your browser's "microphone in use" indicator (e.g. useful for
> indicator (e.g. useful for showing privacy indicators). > showing privacy indicators).
> >
> A [two-way audio](../usage/2-way-audio.md) call opens the microphone, but so do > The microphone is connected while a [two-way audio](../usage/2-way-audio.md)
> several things that involve no call at all: setting > call is in progress, and closes when that call ends. It is also open for as
> [`live.microphone.always_connected`](live.md?id=microphone), or a > long as the card runs when
> [`microphone_connect`](actions/custom/README.md?id=microphone_connect) or > [`live.microphone.always_connected`](live.md?id=microphone) is set.
> [`microphone_unmute`](actions/custom/README.md?id=microphone_unmute) action. It
> closes again after
> [`live.microphone.disconnect_seconds`](live.md?id=microphone) of disuse, or on
> a `microphone_disconnect` action.
> >
> To match a call in progress, use [`call`](#call) instead. > `muted` is `true` whenever the microphone is disconnected, so outside a call
> it is always `true`. It is `false` only during a call with the microphone
> unmuted. `muted: false` is therefore the way to match "the user can be heard
> right now"; to match the absence of a call, use [`call`](#call) rather than
> `muted: true`.
## `not` ## `not`
+7 -10
View File
@@ -222,13 +222,12 @@ live:
microphone: microphone:
``` ```
| Option | Default | Description | | Option | Default | Description |
| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `always_connected` | `false` | Whether or not to keep the microphone stream continually connected while the card is running, or only connect it when first needed (default) -- typically when a [two-way audio](../usage/2-way-audio.md) call is started. In the latter case there'll be a connection reset the first time the microphone connects -- using this option can avoid that reset. | | `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` | `[call]` | 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). `call` will automatically mute the microphone when an answered [two-way audio](../usage/2-way-audio.md) call ends. Use an empty list (`[]`) to never automatically mute the microphone via these conditions. | | `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. `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. `call` will automatically unmute the microphone when a [two-way audio](../usage/2-way-audio.md) call is started (or answered for inbound calls). 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. The microphone is still connected when the call starts (just left muted), so the browser may prompt for microphone permission at that point. | | `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. |
| `disconnect_seconds` | `90` | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`. The countdown is suspended for the duration of a [two-way audio](../usage/2-way-audio.md) call, and restarts in full when the call ends. | | `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`. |
| `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. 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.
@@ -304,10 +303,8 @@ live:
24h: true 24h: true
microphone: microphone:
always_connected: false always_connected: false
auto_mute: auto_mute: []
- call
auto_unmute: [] auto_unmute: []
disconnect_seconds: 90
mute_after_microphone_mute_seconds: 60 mute_after_microphone_mute_seconds: 60
display: display:
mode: single mode: single
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+3 -3
View File
@@ -62,9 +62,9 @@ note over User, Card
end note end note
Card --> User : Inbound audio mutes (<color:#43a047>**""live.auto_mute""**</color>) Card --> User : Inbound audio mutes (<color:#43a047>**""live.auto_mute""**</color>)
Card -> Mic : Microphone mutes (<color:#43a047>**""live.microphone.auto_mute""**</color>) Card -> Mic : Microphone mutes and disconnects
note over Mic note over Mic
Disconnects after <color:#43a047>**""live.microphone.disconnect_seconds""**</color> The browser stops reporting the microphone as in use, unless
unless <color:#43a047>**""live.microphone.always_connected""**</color> <color:#43a047>**""live.microphone.always_connected""**</color> holds it open
end note end note
@enduml @enduml
+10 -4
View File
@@ -69,16 +69,22 @@ enabled by default and appears in the `live` view whenever the selected camera
[`always_connected`](../configuration/live.md?id=microphone) microphone option [`always_connected`](../configuration/live.md?id=microphone) microphone option
is set to `true`. On the first call there may be a brief `webrtc` connection is set to `true`. On the first call there may be a brief `webrtc` connection
reset to include 2-way audio. reset to include 2-way audio.
- The browser asks for microphone permission when a call needs it. How often it
asks depends on the browser: Chrome remembers the choice for the site, Safari
asks once per page load, and Firefox asks for every call unless _Remember this
decision_ is ticked. Set
[`always_connected`](../configuration/live.md?id=microphone) to `true` to be
asked once at card load instead.
- While a call is in progress the card locks disruptive actions (camera and - While a call is in progress the card locks disruptive actions (camera and
substream changes, casting, reload, etc.) so an accidental tap, swipe, or substream changes, casting, reload, etc.) so an accidental tap, swipe, or
button press doesn't cut the call off. Set button press doesn't cut the call off. Set
[`live.controls.call.lock`](../configuration/live.md?id=call) to `false` to [`live.controls.call.lock`](../configuration/live.md?id=call) to `false` to
disable this. disable this.
- End the call with the overlay's end-call button. When the call ends the - End the call with the overlay's end-call button. When the call ends the
microphone and inbound audio are muted again. microphone and inbound audio are muted again, and the microphone is
- The video automatically resets to remove the microphone after the number of disconnected. The exception is
seconds specified by [`disconnect_seconds`](../configuration/live.md?id=microphone) [`always_connected`](../configuration/live.md?id=microphone), which holds the
have elapsed since the call ended. microphone connected for as long as the card is running.
Calls can also be controlled programmatically with the Calls can also be controlled programmatically with the
[`call_start`](../configuration/actions/custom/README.md?id=call_start), [`call_start`](../configuration/actions/custom/README.md?id=call_start),
+33 -33
View File
@@ -51,39 +51,39 @@ To send an action to a specific named Advanced Camera Card:
Only a subset of all [actions](../configuration/actions/README.md) are supported in URL form. Only a subset of all [actions](../configuration/actions/README.md) are supported in URL form.
| Action | Supported in URL | Explanation | | Action | Supported in URL | Explanation |
| ------------------------------------------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `camera_select` | :white_check_mark: | | | `camera_select` | :white_check_mark: | |
| `camera_ui` | :white_check_mark: | | | `camera_ui` | :white_check_mark: | |
| `clip` | :white_check_mark: | | | `clip` | :white_check_mark: | |
| `clips` | :white_check_mark: | | | `clips` | :white_check_mark: | |
| `default` | :white_check_mark: | | | `default` | :white_check_mark: | |
| `diagnostics` | :white_check_mark: | | | `diagnostics` | :white_check_mark: | |
| `download` | :heavy_multiplication_x: | Latest media information is not available on initial render. | | `download` | :heavy_multiplication_x: | Latest media information is not available on initial render. |
| `expand` | :white_check_mark: | | | `expand` | :white_check_mark: | |
| `folder` | :white_check_mark: | | | `folder` | :white_check_mark: | |
| `folders` | :white_check_mark: | | | `folders` | :white_check_mark: | |
| `fullscreen` | :heavy_multiplication_x: | Javascript does not support activating fullscreen without direct human interaction. Use `expand` as an alternative. | | `fullscreen` | :heavy_multiplication_x: | Javascript does not support activating fullscreen without direct human interaction. Use `expand` as an alternative. |
| `gallery` | :white_check_mark: | | | `gallery` | :white_check_mark: | |
| `image` | :white_check_mark: | | | `image` | :white_check_mark: | |
| `live` | :white_check_mark: | | | `live` | :white_check_mark: | |
| `media` | :white_check_mark: | | | `media` | :white_check_mark: | |
| `media_player` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | | `media_player` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
| `menu_toggle` | :white_check_mark: | | | `menu_toggle` | :white_check_mark: | |
| `microphone_connect`, `microphone_disconnect`, `microphone_mute`, `microphone_unmute` | :heavy_multiplication_x: | | | `microphone_mute`, `microphone_unmute` | :heavy_multiplication_x: | |
| `mute`, `unmute` | :heavy_multiplication_x: | | | `mute`, `unmute` | :heavy_multiplication_x: | |
| `play`, `pause` | :heavy_multiplication_x: | | | `play`, `pause` | :heavy_multiplication_x: | |
| `ptz` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | | `ptz` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
| `recording` | :white_check_mark: | | | `recording` | :white_check_mark: | |
| `recordings` | :white_check_mark: | | | `recordings` | :white_check_mark: | |
| `review` | :white_check_mark: | | | `review` | :white_check_mark: | |
| `reviews` | :white_check_mark: | | | `reviews` | :white_check_mark: | |
| `screenshot` | :heavy_multiplication_x: | Latest media information is not available on initial render. | | `screenshot` | :heavy_multiplication_x: | Latest media information is not available on initial render. |
| `ptz_controls` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | | `ptz_controls` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
| `snapshot` | :white_check_mark: | | | `snapshot` | :white_check_mark: | |
| `snapshots` | :white_check_mark: | | | `snapshots` | :white_check_mark: | |
| `substream_off` | :white_check_mark: | The `camera` field is not exposed via URL; the selected camera is used. Use the action config directly to target another camera. | | `substream_off` | :white_check_mark: | The `camera` field is not exposed via URL; the selected camera is used. Use the action config directly to target another camera. |
| `substream_on` | :white_check_mark: | Pass the substream camera ID as the URL value to engage it directly; omit the value to cycle. | | `substream_on` | :white_check_mark: | Pass the substream camera ID as the URL value to engage it directly; omit the value to cycle. |
## Examples ## Examples
@@ -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();
}
}
-6
View File
@@ -28,8 +28,6 @@ import { InternalCallbackAction } from './actions/internal-callback';
import { LogAction } from './actions/log'; import { LogAction } from './actions/log';
import { MediaPlayerAction } from './actions/media-player'; import { MediaPlayerAction } from './actions/media-player';
import { MenuToggleAction } from './actions/menu-toggle'; 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 { MicrophoneMuteAction } from './actions/microphone-mute';
import { MicrophoneUnmuteAction } from './actions/microphone-unmute'; import { MicrophoneUnmuteAction } from './actions/microphone-unmute';
import { MoreInfoAction } from './actions/more-info'; import { MoreInfoAction } from './actions/more-info';
@@ -159,10 +157,6 @@ export class ActionFactory {
return new SubstreamOnAction(context, action, options?.config); return new SubstreamOnAction(context, action, options?.config);
case 'media_player': case 'media_player':
return new MediaPlayerAction(context, action, options?.config); 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': case 'microphone_mute':
return new MicrophoneMuteAction(context, action, options?.config); return new MicrophoneMuteAction(context, action, options?.config);
case 'microphone_unmute': case 'microphone_unmute':
+48 -18
View File
@@ -109,7 +109,7 @@ export class CallManager {
// granting microphone access -- so connecting here would let a refusal stop // granting microphone access -- so connecting here would let a refusal stop
// the call from ever ringing. The connect is deferred to `answer()`. An // the call from ever ringing. The connect is deferred to `answer()`. An
// outbound call is answered by construction and needs it immediately. // outbound call is answered by construction and needs it immediately.
if (!inbound && !(await this._connectMicrophone())) { if (!inbound && !(await this._grantTransmissionAndConnect())) {
return false; return false;
} }
@@ -137,7 +137,8 @@ export class CallManager {
if (inbound && (existingCall.answered || !existingCall.inbound)) { if (inbound && (existingCall.answered || !existingCall.inbound)) {
return false; 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); // Outbound calls are answered by construction (the user initiated them);
@@ -152,9 +153,16 @@ export class CallManager {
answered, answered,
}; };
// The microphone's own idle timeout knows nothing about calls, so without // `_grantTransmissionAndConnect` above reported the need for transmission
// this the tracks would be stopped mid-conversation. // before awaiting the microphone connect; a call ending during that await
this._api.getMicrophoneManager().startUsing(); // 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({ this._api.getViewManager().setViewByParameters({
...(needsNavigation && { ...(needsNavigation && {
@@ -219,13 +227,14 @@ export class CallManager {
// An inbound call rings without the microphone, so this is where it is // An inbound call rings without the microphone, so this is where it is
// connected -- under the user gesture that answering provides. The call is // connected -- under the user gesture that answering provides. The call is
// left ringing on failure so it can be answered again. // left ringing on failure so it can be answered again.
if (!(await this._connectMicrophone())) { if (!(await this._grantTransmissionAndConnect())) {
return false; return false;
} }
// The call may have ended, or been superseded by another, while the // The call may have ended, or been superseded by another, while the
// microphone connect was in flight -- there is then nothing left to answer. // microphone connect was in flight -- there is then nothing left to answer.
if (this._call !== call) { if (this._call !== call) {
this._revokeTransmissionIfNoAnsweredCall();
return false; return false;
} }
@@ -287,9 +296,9 @@ export class CallManager {
this._initGeneration.invalidate(); this._initGeneration.invalidate();
this._ringtone.stop(); this._ringtone.stop();
this._unansweredTimer.stop(); this._unansweredTimer.stop();
this._api.getMicrophoneManager().setTransmissionActive(false);
if (this._call) { if (this._call) {
this._call = null; this._call = null;
this._api.getMicrophoneManager().stopUsing();
this._api.getConditionStateManager().setState({ call: 'idle' }); this._api.getConditionStateManager().setState({ call: 'idle' });
} }
this._api this._api
@@ -302,8 +311,9 @@ export class CallManager {
// is `false` for auto-ends (navigating away, camera/substream change), where // is `false` for auto-ends (navigating away, camera/substream change), where
// the user has already chosen a destination and the pre-call view is // 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. // 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. // Returns true iff a call was actually ended.
private _end(restoreView: boolean): boolean { private _end(restoreView: boolean, options?: { retainMicrophone?: boolean }): boolean {
if (!this._call) { if (!this._call) {
return false; return false;
} }
@@ -319,9 +329,9 @@ export class CallManager {
// and recurse. // and recurse.
this._call = null; this._call = null;
// The call no longer needs the microphone, so the normal if (!options?.retainMicrophone && call.answered) {
// `disconnect_seconds` countdown restarts from here. this._api.getMicrophoneManager().setTransmissionActive(false);
this._api.getMicrophoneManager().stopUsing(); }
const viewManager = this._api.getViewManager(); const viewManager = this._api.getViewManager();
@@ -424,9 +434,29 @@ export class CallManager {
return true; 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 // 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 // this request still belongs to the current init/uninit lifecycle. A denied
// failure is surfaced as a notification. // 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> { private async _connectMicrophone(): Promise<boolean> {
const microphoneManager = this._api.getMicrophoneManager(); const microphoneManager = this._api.getMicrophoneManager();
if (microphoneManager.isConnected()) { if (microphoneManager.isConnected()) {
@@ -435,11 +465,12 @@ export class CallManager {
const initGeneration = this._initGeneration.current(); const initGeneration = this._initGeneration.current();
let connected = false; let connected = false;
let forbidden = false;
try { try {
await microphoneManager.connect(); connected = await microphoneManager.connect();
connected = true;
} catch { } catch {
// Reported below, once this request is known to still be the current one. // 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, // If the init/uninit lifecycle advanced while the connect was in flight,
@@ -450,11 +481,10 @@ export class CallManager {
return false; return false;
} }
if (!connected) { if (forbidden) {
this._notifyError('error.call_microphone_forbidden'); this._notifyError('error.call_microphone_forbidden');
return false;
} }
return true; return connected;
} }
private _hasCallCapability(cameraID: string): boolean { private _hasCallCapability(cameraID: string): boolean {
+94 -59
View File
@@ -1,6 +1,6 @@
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { AdvancedCameraCardError } from '../types'; import { AdvancedCameraCardError } from '../types';
import { Timer } from '../utils/timer'; import { Generation } from '../utils/concurrency/generation';
import type { CardMicrophoneAPI, MicrophoneState } from './types'; import type { CardMicrophoneAPI, MicrophoneState } from './types';
export class MicrophoneNotSupportedError extends AdvancedCameraCardError { export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
@@ -11,8 +11,11 @@ export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
export class MicrophoneManager { export class MicrophoneManager {
private _api: CardMicrophoneAPI; private _api: CardMicrophoneAPI;
private _stream?: MediaStream | null; private _stream: MediaStream | null = null;
private _timer = new Timer();
// Whether the browser denied the most recent microphone request. Cleared by
// a later successful connect.
private _forbidden = false;
private _state: MicrophoneState = { private _state: MicrophoneState = {
connected: false, connected: false,
@@ -25,9 +28,14 @@ export class MicrophoneManager {
// it's created it will have the right mute status. // it's created it will have the right mute status.
private _desireMute = true; private _desireMute = true;
// Whether something is actively using the microphone, and so when the // Whether an outgoing audio path is active, i.e. something is consuming the
// connection can safely be closed. // microphone stream. While active, mute only disables the tracks so unmute is
private _inUse = false; // 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) { constructor(api: CardMicrophoneAPI) {
this._api = api; this._api = api;
@@ -57,72 +65,92 @@ export class MicrophoneManager {
return !!navigator.mediaDevices?.getUserMedia; 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()) { if (!this.isSupported()) {
throw new MicrophoneNotSupportedError(); throw new MicrophoneNotSupportedError();
} }
const generation = this._connectGeneration.next();
let stream: MediaStream;
try { try {
this._stream = await navigator.mediaDevices.getUserMedia({ stream = await navigator.mediaDevices.getUserMedia({
audio: true, audio: true,
video: false, video: false,
}); });
} catch (e: unknown) { } catch (e: unknown) {
this._stream = null; // A stale rejection must not mark the microphone forbidden.
this._setState(); if (this._connectGeneration.isCurrent(generation)) {
this._releaseStream();
this._forbidden = true;
this._setState();
}
throw e; 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(); this._setState();
} }
public disconnect(): void { public getStream(): MediaStream | null {
this._timer.stop(); return this._stream;
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 { public mute(): void {
this._desireMute = true; this._desireMute = true;
this._setDesiredMuteOnStream(); this._reconcile();
this._setState(); this._setState();
} }
public async unmute(): Promise<void> { 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; return;
} }
this._desireMute = false; this._desireMute = false;
if (!this.isConnected() && !this.isForbidden()) { if (!this.isConnected() && !this.isForbidden()) {
// Connecting will automatically set the desired mute. // Connecting applies the desired mute to the new stream.
await this.connect(); await this.connect();
} else if (this.isConnected()) { return;
this._setDesiredMuteOnStream();
this._setState();
} }
this._reconcile();
this._setState();
} }
public isConnected(): boolean { public isConnected(): boolean {
@@ -130,7 +158,7 @@ export class MicrophoneManager {
} }
public isForbidden(): boolean { public isForbidden(): boolean {
return this._stream === null; return this._forbidden;
} }
public isMuted(): boolean { public isMuted(): boolean {
@@ -139,28 +167,35 @@ export class MicrophoneManager {
return !this._stream || this._stream.getTracks().every((track) => !track.enabled); return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
} }
private _setDesiredMuteOnStream(): void { private _stopTracks(stream: MediaStream | null): void {
this._stream?.getTracks().forEach((track) => { stream?.getTracks().forEach((track) => track.stop());
track.enabled = !this._desireMute;
});
this._startDisconnectTimer();
} }
private _startDisconnectTimer(): void { private _releaseStream(): void {
const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone; 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; return;
} }
const alwaysConnected = !!this._api.getConfigManager().getConfig()?.live.microphone
const disconnectSeconds = microphoneConfig?.disconnect_seconds ?? 0; ?.always_connected;
if (this._desireMute && !this._transmissionActive && !alwaysConnected) {
if (disconnectSeconds) { this._releaseStream();
this._timer.start(disconnectSeconds, () => { return;
this.disconnect();
});
} }
this._stream.getTracks().forEach((track) => {
track.enabled = !this._desireMute;
});
} }
private _setState(): void { private _setState(): void {
@@ -31,10 +31,8 @@ export const getLiveAutoUnmuteOptions = (): HASelectSelectorOption[] => [
condition('call', 'call_unmute'), condition('call', 'call_unmute'),
]; ];
export const getMicrophoneMuteOptions = (): HASelectSelectorOption[] => [ export const getMicrophoneMuteOptions = (): HASelectSelectorOption[] =>
...getMediaActionNegativeOptions(), getMediaActionNegativeOptions();
condition('call', 'call_mute'),
];
export const getMicrophoneUnmuteOptions = (): HASelectSelectorOption[] => [ export const getMicrophoneUnmuteOptions = (): HASelectSelectorOption[] => [
...getMediaActionPositiveOptions(), ...getMediaActionPositiveOptions(),
-1
View File
@@ -103,7 +103,6 @@ const getMicrophoneSchema = (): HAFormExpandableSchema => ({
title: localize('config.live.microphone.editor_label'), title: localize('config.live.microphone.editor_label'),
icon: 'mdi:microphone', icon: 'mdi:microphone',
schema: [ schema: [
{ name: 'disconnect_seconds', selector: createNumberSelector({ min: 0 }) },
{ name: 'always_connected', selector: { boolean: {} } }, { name: 'always_connected', selector: { boolean: {} } },
{ {
name: 'auto_mute', name: 'auto_mute',
@@ -47,18 +47,17 @@ export class MicrophoneActionsController {
* when the live view first appears during an answered call. * when the live view first appears during an answered call.
* *
* Call answer unmutes the microphone only if the user opted into * Call answer unmutes the microphone only if the user opted into
* `microphone.auto_unmute: ['call']`; the symmetric mute fires on the * `microphone.auto_unmute: ['call']`. There is no symmetric mute: the
* answered-to-unanswered transition (call end after answer). * 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) { if (answered === this._callAnswered) {
return; return;
} }
this._callAnswered = answered; this._callAnswered = answered;
if (answered) { if (answered) {
void this._unmuteIfConfigured('call'); await this._unmuteIfConfigured('call');
} else {
this._muteIfConfigured('call');
} }
} }
+3 -1
View File
@@ -98,7 +98,9 @@ export class AdvancedCameraCardLive extends LitElement {
// ['call']` doesn't transmit audio during the pre-answer ringing state. // ['call']` doesn't transmit audio during the pre-answer ringing state.
// Outbound calls are answered at construction; inbound calls only after // Outbound calls are answered at construction; inbound calls only after
// the user accepts. // the user accepts.
this._microphoneActionsController.setCallAnswered(!!this.call?.answered); this._microphoneActionsController
.setCallAnswered(!!this.call?.answered)
.catch(() => {});
} }
} }
+87
View File
@@ -1494,6 +1494,87 @@ const triggersEventsToMediaEventsTransform = (triggers: unknown): unknown => {
return result; 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 = [ const UPGRADES = [
// v5.2.0 -> v6.0.0 // v5.2.0 -> v6.0.0
(data: unknown): boolean => { (data: unknown): boolean => {
@@ -1729,4 +1810,10 @@ const UPGRADES = [
isRecord(data) ? data : {}, 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', 'fullscreen',
'info', 'info',
'menu_toggle', 'menu_toggle',
'microphone_connect',
'microphone_disconnect',
'microphone_mute', 'microphone_mute',
'microphone_unmute', 'microphone_unmute',
'mute', 'mute',
+1 -4
View File
@@ -13,10 +13,7 @@ export const MEDIA_UNMUTE_CONDITIONS = [
'call', 'call',
] as const; ] as const;
export const MICROPHONE_MUTE_CONDITIONS = [ export const MICROPHONE_MUTE_CONDITIONS = [...MEDIA_ACTION_NEGATIVE_CONDITIONS] as const;
...MEDIA_ACTION_NEGATIVE_CONDITIONS,
'call',
] as const;
export const MICROPHONE_UNMUTE_CONDITIONS = [ export const MICROPHONE_UNMUTE_CONDITIONS = [
...MEDIA_ACTION_POSITIVE_CONDITIONS, ...MEDIA_ACTION_POSITIVE_CONDITIONS,
+1 -6
View File
@@ -25,9 +25,8 @@ import { transitionEffectConfigSchema } from './common/transition-effect';
const microphoneConfigDefault = { const microphoneConfigDefault = {
always_connected: false, always_connected: false,
auto_mute: ['call' as const], auto_mute: [],
auto_unmute: [], auto_unmute: [],
disconnect_seconds: 90,
mute_after_microphone_mute_seconds: 60, mute_after_microphone_mute_seconds: 60,
}; };
@@ -80,10 +79,6 @@ const microphoneConfigSchema = z
.enum(MICROPHONE_UNMUTE_CONDITIONS) .enum(MICROPHONE_UNMUTE_CONDITIONS)
.array() .array()
.default(microphoneConfigDefault.auto_unmute), .default(microphoneConfigDefault.auto_unmute),
disconnect_seconds: z
.number()
.min(0)
.default(microphoneConfigDefault.disconnect_seconds),
mute_after_microphone_mute_seconds: z mute_after_microphone_mute_seconds: z
.number() .number()
.min(0) .min(0)
-1
View File
@@ -255,7 +255,6 @@
"lazy_unload": "Les càmeres en directe es descarreguen amb mandra", "lazy_unload": "Les càmeres en directe es descarreguen amb mandra",
"microphone": { "microphone": {
"always_connected": "Mantingueu sempre el micròfon connectat", "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", "editor_label": "Micròfon",
"mute_after_microphone_mute_seconds": "Segons després de silenciar el micròfon per silenciar l'àudio entrant" "mute_after_microphone_mute_seconds": "Segons després de silenciar el micròfon per silenciar l'àudio entrant"
}, },
-1
View File
@@ -215,7 +215,6 @@
"live": { "live": {
"microphone": { "microphone": {
"always_connected": "Mikrofon immer verbunden lassen", "always_connected": "Mikrofon immer verbunden lassen",
"disconnect_seconds": "Sekunden nach denen das Mikrofon getrennt wird (0=niemals)",
"editor_label": "Mikrofon", "editor_label": "Mikrofon",
"mute_after_microphone_mute_seconds": "Sekunden nach denen das Mikrofon stumm geschalten wird, um eingehendes Audio stumm zu schalten" "mute_after_microphone_mute_seconds": "Sekunden nach denen das Mikrofon stumm geschalten wird, um eingehendes Audio stumm zu schalten"
}, },
-1
View File
@@ -493,7 +493,6 @@
"always_connected": "Always keep the microphone connected", "always_connected": "Always keep the microphone connected",
"auto_mute": "Automatically mute the microphone", "auto_mute": "Automatically mute the microphone",
"auto_unmute": "Automatically unmute the microphone", "auto_unmute": "Automatically unmute the microphone",
"disconnect_seconds": "Seconds before disconnecting microphone (0=never)",
"editor_label": "Microphone", "editor_label": "Microphone",
"mute_after_microphone_mute_seconds": "Seconds after microphone mute before muting inbound audio" "mute_after_microphone_mute_seconds": "Seconds after microphone mute before muting inbound audio"
}, },
-1
View File
@@ -336,7 +336,6 @@
"lazy_unload": "Les caméras en direct sont déchargées en différé", "lazy_unload": "Les caméras en direct sont déchargées en différé",
"microphone": { "microphone": {
"always_connected": "Toujours garder le microphone connecté", "always_connected": "Toujours garder le microphone connecté",
"disconnect_seconds": "Secondes après quoi déconnecter le microphone (0=jamais)",
"editor_label": "Microphone", "editor_label": "Microphone",
"mute_after_microphone_mute_seconds": "Secondes après la mise en sourdine du microphone pour couper l'audio entrant" "mute_after_microphone_mute_seconds": "Secondes après la mise en sourdine du microphone pour couper l'audio entrant"
}, },
-1
View File
@@ -378,7 +378,6 @@
"lazy_unload": "Kamery wyładowywane leniwie", "lazy_unload": "Kamery wyładowywane leniwie",
"microphone": { "microphone": {
"always_connected": "Zawsze utrzymuj połączenie mikrofonu", "always_connected": "Zawsze utrzymuj połączenie mikrofonu",
"disconnect_seconds": "Sekundy do rozłączenia mikrofonu (0=nigdy)",
"editor_label": "Mikrofon", "editor_label": "Mikrofon",
"mute_after_microphone_mute_seconds": "Sekundy po wyciszeniu mikrofonu do wyciszenia dźwięku przychodzącego" "mute_after_microphone_mute_seconds": "Sekundy po wyciszeniu mikrofonu do wyciszenia dźwięku przychodzącego"
}, },
@@ -1,19 +0,0 @@
import { expect, it } from 'vitest';
import { MicrophoneConnectAction } from '../../../../src/card-controller/actions/actions/microphone-connect';
import { createCardAPI } from '../../../test-utils';
it('should handle microphone_connect action', async () => {
const api = createCardAPI();
const action = new MicrophoneConnectAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'microphone_connect',
},
);
await action.execute(api);
expect(api.getMicrophoneManager().connect).toHaveBeenCalled();
});
@@ -1,19 +0,0 @@
import { expect, it } from 'vitest';
import { MicrophoneDisconnectAction } from '../../../../src/card-controller/actions/actions/microphone-disconnect';
import { createCardAPI } from '../../../test-utils';
it('should handle microphone_disconnect action', async () => {
const api = createCardAPI();
const action = new MicrophoneDisconnectAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'microphone_disconnect',
},
);
await action.execute(api);
expect(api.getMicrophoneManager().disconnect).toHaveBeenCalled();
});
@@ -20,8 +20,6 @@ import { InternalCallbackAction } from '../../../src/card-controller/actions/act
import { LogAction } from '../../../src/card-controller/actions/actions/log'; import { LogAction } from '../../../src/card-controller/actions/actions/log';
import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player'; import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player';
import { MenuToggleAction } from '../../../src/card-controller/actions/actions/menu-toggle'; import { MenuToggleAction } from '../../../src/card-controller/actions/actions/menu-toggle';
import { MicrophoneConnectAction } from '../../../src/card-controller/actions/actions/microphone-connect';
import { MicrophoneDisconnectAction } from '../../../src/card-controller/actions/actions/microphone-disconnect';
import { MicrophoneMuteAction } from '../../../src/card-controller/actions/actions/microphone-mute'; import { MicrophoneMuteAction } from '../../../src/card-controller/actions/actions/microphone-mute';
import { MicrophoneUnmuteAction } from '../../../src/card-controller/actions/actions/microphone-unmute'; import { MicrophoneUnmuteAction } from '../../../src/card-controller/actions/actions/microphone-unmute';
import { MoreInfoAction } from '../../../src/card-controller/actions/actions/more-info'; import { MoreInfoAction } from '../../../src/card-controller/actions/actions/more-info';
@@ -146,14 +144,6 @@ describe('ActionFactory', () => {
MediaPlayerAction, MediaPlayerAction,
], ],
[{ advanced_camera_card_action: 'menu_toggle' as const }, MenuToggleAction], [{ advanced_camera_card_action: 'menu_toggle' as const }, MenuToggleAction],
[
{ advanced_camera_card_action: 'microphone_connect' as const },
MicrophoneConnectAction,
],
[
{ advanced_camera_card_action: 'microphone_disconnect' as const },
MicrophoneDisconnectAction,
],
[ [
{ advanced_camera_card_action: 'microphone_mute' as const }, { advanced_camera_card_action: 'microphone_mute' as const },
MicrophoneMuteAction, MicrophoneMuteAction,
+356 -38
View File
@@ -527,7 +527,7 @@ describe('start', () => {
view: createView({ camera: 'camera.office' }), view: createView({ camera: 'camera.office' }),
microphoneConnected: false, microphoneConnected: false,
}); });
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(); vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
expect(await new CallManager(api).start()).toBe(true); expect(await new CallManager(api).start()).toBe(true);
@@ -1426,7 +1426,7 @@ describe('answer', () => {
microphoneConnected: false, microphoneConnected: false,
config: inboundConfig, config: inboundConfig,
}); });
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(); vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
const manager = new CallManager(api); const manager = new CallManager(api);
manager.initialize(); manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true); expect(await manager.start({ inbound: true })).toBe(true);
@@ -1442,9 +1442,9 @@ describe('answer', () => {
microphoneConnected: false, microphoneConnected: false,
config: inboundConfig, config: inboundConfig,
}); });
let resolveConnect: () => void = () => {}; let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue( vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => { new Promise<boolean>((resolve) => {
resolveConnect = resolve; resolveConnect = resolve;
}), }),
); );
@@ -1459,7 +1459,7 @@ describe('answer', () => {
// microphone permission prompt to be dealt with. // microphone permission prompt to be dealt with.
expect(getRingtone().stop).toHaveBeenCalled(); expect(getRingtone().stop).toHaveBeenCalled();
resolveConnect(); resolveConnect(true);
expect(await answerPromise).toBe(true); expect(await answerPromise).toBe(true);
}); });
@@ -1540,22 +1540,26 @@ describe('answer', () => {
}); });
}); });
describe('microphone usage', () => { describe('microphone transmission', () => {
it('should mark the microphone in use for the duration of the call', async () => { it('should transmit for the duration of the call', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) }); const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api); const manager = new CallManager(api);
expect(await manager.start()).toBe(true); expect(await manager.start()).toBe(true);
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(1); expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenCalledWith(true);
expect(api.getMicrophoneManager().stopUsing).not.toHaveBeenCalled(); expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
false,
);
manager.end(); manager.end();
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1); expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
}); });
it('should not mark the microphone in use when the start is aborted', async () => { it('should stop transmitting when the call fails to start', async () => {
const api = createAPI({ const api = createAPI({
view: createView({ camera: 'camera.office' }), view: createView({ camera: 'camera.office' }),
microphoneConnected: false, microphoneConnected: false,
@@ -1564,10 +1568,18 @@ describe('microphone usage', () => {
expect(await new CallManager(api).start()).toBe(false); expect(await new CallManager(api).start()).toBe(false);
expect(api.getMicrophoneManager().startUsing).not.toHaveBeenCalled(); // Transmission is granted ahead of the microphone connect, so the failed
// start must withdraw it again.
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenNthCalledWith(
1,
true,
);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
}); });
it('should keep the microphone in use when a call supersedes another', async () => { it('should keep transmitting when one call replaces another', async () => {
const api = createAPI({ const api = createAPI({
view: createView({ camera: 'camera.office' }), view: createView({ camera: 'camera.office' }),
store: createStore([ store: createStore([
@@ -1586,26 +1598,331 @@ describe('microphone usage', () => {
expect(await manager.start()).toBe(true); expect(await manager.start()).toBe(true);
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true); expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true);
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(2); expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1); false,
);
}); });
it('should mark the microphone unused on uninitialization', async () => { it('should stop transmitting when uninitialized during a call', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) }); const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api); const manager = new CallManager(api);
expect(await manager.start()).toBe(true); expect(await manager.start()).toBe(true);
manager.uninitialize(); manager.uninitialize();
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1); expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
}); });
it('should not mark the microphone unused when uninitializing without a call', () => { it('should stop transmitting when uninitialized without a call', () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) }); const api = createAPI({ view: createView({ camera: 'camera.office' }) });
new CallManager(api).uninitialize(); new CallManager(api).uninitialize();
expect(api.getMicrophoneManager().stopUsing).not.toHaveBeenCalled(); expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenCalledWith(false);
});
it('should leave transmission alone when an unanswered inbound call ends', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalled();
});
it('should stop transmitting when uninitialized while a call connects the microphone', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
const startPromise = manager.start();
manager.uninitialize();
// The grant must not outlive the manager while the connect is still
// pending.
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
resolveConnect(true);
expect(await startPromise).toBe(false);
});
it('should let a later call stop transmitting after an abandoned call is uninitialized', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
// A start from the first lifecycle never gets its microphone.
const stalledStart = manager.start();
manager.uninitialize();
manager.initialize();
// A call in the new lifecycle must end normally: the stalled request from
// the old lifecycle no longer holds transmission.
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(true);
expect(await manager.start()).toBe(true);
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
resolveConnect(true);
expect(await stalledStart).toBe(false);
});
it('should transmit for a call that starts while another call is ending', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
// The superseding start is suspended on the microphone while the answered
// call ends and reports transmission inactive.
const second = manager.start({ cameraID: 'camera.garage' });
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
expect(await second).toBe(true);
// The installed session restated its need so the user can be heard on unmute.
expect(manager.getCall()?.cameraID).toBe('camera.garage');
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
true,
);
});
it('should not transmit for an inbound call that rings after another call ends', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
expect(manager.end()).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
expect(await manager.start({ inbound: true })).toBe(true);
// A ring transmits nothing, so it must not restate a need for
// transmission.
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should stop transmitting when a call ends and the next cannot get the microphone', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start()).toBe(true);
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
let rejectConnect: (reason: unknown) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((_, reject) => {
rejectConnect = reject;
}),
);
const second = manager.start({ cameraID: 'camera.garage' });
expect(manager.end()).toBe(true);
rejectConnect(new Error());
expect(await second).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should abort a call when the microphone does not stay connected', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
const start = manager.start();
// The microphone manager releases a stream it connected while nothing was
// transmitting, and reports that the microphone is not connected.
resolveConnect(false);
expect(await start).toBe(false);
expect(manager.isActive()).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
});
it('should not transmit while an inbound call rings', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalled();
});
it('should allow transmission before connecting the microphone on answer', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(true);
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(await manager.answer()).toBe(true);
// If transmission is not yet active when a connect completes, the
// microphone manager immediately releases the new stream -- so the answer
// must activate transmission before it connects. The ringing start touches
// neither method, so index 0 is each method's first and only invocation,
// made by the answer.
const transmitOrder = vi.mocked(api.getMicrophoneManager().setTransmissionActive)
.mock.invocationCallOrder[0];
const connectOrder = vi.mocked(api.getMicrophoneManager().connect).mock
.invocationCallOrder[0];
expect(transmitOrder).toBeLessThan(connectOrder);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
true,
);
});
it('should stop transmitting when the microphone connect fails on answer', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
expect(await manager.answer()).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
expect(manager.getCall()?.answered).toBe(false);
});
it('should keep transmitting when a failed start leaves an earlier call in progress', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
// The second start fails its microphone connect, but the first call is
// still answered and holds the transmission grant.
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error());
expect(await manager.start({ cameraID: 'camera.garage' })).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
false,
);
expect(manager.getCall()?.cameraID).toBe('camera.office');
});
it('should stop transmitting when the call ends while answering connects the microphone', async () => {
const api = createAPI({
view: createView({ camera: 'camera.office' }),
microphoneConnected: false,
});
let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveConnect = resolve;
}),
);
const manager = new CallManager(api);
manager.initialize();
expect(await manager.start({ inbound: true })).toBe(true);
const answerPromise = manager.answer();
manager.end();
resolveConnect(true);
expect(await answerPromise).toBe(false);
expect(api.getMicrophoneManager().setTransmissionActive).toHaveBeenLastCalledWith(
false,
);
}); });
}); });
@@ -1839,9 +2156,9 @@ describe('state changes during in-flight start', () => {
view: createView({ camera: 'camera.office' }), view: createView({ camera: 'camera.office' }),
microphoneConnected: false, microphoneConnected: false,
}); });
let resolveConnect: () => void = () => {}; let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue( vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => { new Promise<boolean>((resolve) => {
resolveConnect = resolve; resolveConnect = resolve;
}), }),
); );
@@ -1850,7 +2167,7 @@ describe('state changes during in-flight start', () => {
const startPromise = manager.start(); const startPromise = manager.start();
manager.uninitialize(); manager.uninitialize();
resolveConnect(); resolveConnect(true);
expect(await startPromise).toBe(false); expect(await startPromise).toBe(false);
expect(manager.isActive()).toBe(false); expect(manager.isActive()).toBe(false);
@@ -1862,9 +2179,9 @@ describe('state changes during in-flight start', () => {
view: createView({ camera: 'camera.office' }), view: createView({ camera: 'camera.office' }),
microphoneConnected: false, microphoneConnected: false,
}); });
let resolveConnect: () => void = () => {}; let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue( vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => { new Promise<boolean>((resolve) => {
resolveConnect = resolve; resolveConnect = resolve;
}), }),
); );
@@ -1874,7 +2191,7 @@ describe('state changes during in-flight start', () => {
const startPromise = manager.start(); const startPromise = manager.start();
manager.uninitialize(); manager.uninitialize();
manager.initialize(); manager.initialize();
resolveConnect(); resolveConnect(true);
expect(await startPromise).toBe(false); expect(await startPromise).toBe(false);
expect(manager.isActive()).toBe(false); expect(manager.isActive()).toBe(false);
@@ -1896,9 +2213,9 @@ describe('state changes during in-flight start', () => {
}, },
]), ]),
}); });
let resolveConnect: () => void = () => {}; let resolveConnect: (connected: boolean) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue( vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve) => { new Promise<boolean>((resolve) => {
resolveConnect = resolve; resolveConnect = resolve;
}), }),
); );
@@ -1908,17 +2225,18 @@ describe('state changes during in-flight start', () => {
// Both requests read the (absent) session before either can install one. // Both requests read the (absent) session before either can install one.
const first = manager.start(); const first = manager.start();
const second = manager.start({ cameraID: 'camera.garage' }); const second = manager.start({ cameraID: 'camera.garage' });
resolveConnect(); resolveConnect(true);
expect(await first).toBe(true); expect(await first).toBe(true);
expect(await second).toBe(true); expect(await second).toBe(true);
// The second request must end the first request's session rather than // The second request must end the first request's session rather than
// overwrite it, leaving exactly one live session and no stranded microphone // overwrite it, leaving exactly one live session with transmission still
// marking. // granted.
expect(manager.getCall()?.cameraID).toBe('camera.garage'); expect(manager.getCall()?.cameraID).toBe('camera.garage');
expect(api.getMicrophoneManager().startUsing).toHaveBeenCalledTimes(2); expect(api.getMicrophoneManager().setTransmissionActive).not.toHaveBeenCalledWith(
expect(api.getMicrophoneManager().stopUsing).toHaveBeenCalledTimes(1); false,
);
}); });
it('should suppress the microphone-failure notification when uninitialized mid-await', async () => { it('should suppress the microphone-failure notification when uninitialized mid-await', async () => {
@@ -1928,7 +2246,7 @@ describe('state changes during in-flight start', () => {
}); });
let rejectConnect: (reason: unknown) => void = () => {}; let rejectConnect: (reason: unknown) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue( vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((_, reject) => { new Promise<boolean>((_, reject) => {
rejectConnect = reject; rejectConnect = reject;
}), }),
); );
@@ -1957,7 +2275,7 @@ describe('state changes during in-flight answer', () => {
api: CardController, api: CardController,
): Promise<{ ): Promise<{
manager: CallManager; manager: CallManager;
resolveConnect: () => void; resolveConnect: (connected: boolean) => void;
rejectConnect: (reason: unknown) => void; rejectConnect: (reason: unknown) => void;
}> => { }> => {
const manager = new CallManager(api); const manager = new CallManager(api);
@@ -1965,10 +2283,10 @@ describe('state changes during in-flight answer', () => {
expect(await manager.start({ inbound: true })).toBe(true); expect(await manager.start({ inbound: true })).toBe(true);
vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false); vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue(false);
let resolveConnect: () => void = () => {}; let resolveConnect: (connected: boolean) => void = () => {};
let rejectConnect: (reason: unknown) => void = () => {}; let rejectConnect: (reason: unknown) => void = () => {};
vi.mocked(api.getMicrophoneManager().connect).mockReturnValue( vi.mocked(api.getMicrophoneManager().connect).mockReturnValue(
new Promise<void>((resolve, reject) => { new Promise<boolean>((resolve, reject) => {
resolveConnect = resolve; resolveConnect = resolve;
rejectConnect = reject; rejectConnect = reject;
}), }),
@@ -1985,7 +2303,7 @@ describe('state changes during in-flight answer', () => {
const answerPromise = manager.answer(); const answerPromise = manager.answer();
manager.uninitialize(); manager.uninitialize();
resolveConnect(); resolveConnect(true);
expect(await answerPromise).toBe(false); expect(await answerPromise).toBe(false);
expect(manager.isActive()).toBe(false); expect(manager.isActive()).toBe(false);
@@ -2015,7 +2333,7 @@ describe('state changes during in-flight answer', () => {
const answerPromise = manager.answer(); const answerPromise = manager.answer();
expect(manager.end()).toBe(true); expect(manager.end()).toBe(true);
resolveConnect(); resolveConnect(true);
expect(await answerPromise).toBe(false); expect(await answerPromise).toBe(false);
expect(manager.isActive()).toBe(false); expect(manager.isActive()).toBe(false);
+331 -211
View File
@@ -28,7 +28,6 @@ const medialessNavigatorMock: Navigator = {
describe('MicrophoneManager', () => { describe('MicrophoneManager', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('navigator', navigatorMock); vi.stubGlobal('navigator', navigatorMock);
vi.useRealTimers();
}); });
afterEach(() => { afterEach(() => {
@@ -44,30 +43,100 @@ describe('MicrophoneManager', () => {
return stream; return stream;
}; };
// Streams from `createMockStream` carry exactly one track.
const getTrack = (stream: MediaStream): MediaStreamTrack => stream.getTracks()[0];
const createDeferredStream = (): {
promise: Promise<MediaStream>;
resolve: (stream: MediaStream) => void;
reject: (error: Error) => void;
} => {
let resolveStream: ((stream: MediaStream) => void) | null = null;
let rejectStream: ((error: Error) => void) | null = null;
const promise = new Promise<MediaStream>((resolve, reject) => {
resolveStream = resolve;
rejectStream = reject;
});
return {
promise,
resolve: (stream: MediaStream) => resolveStream?.(stream),
reject: (error: Error) => rejectStream?.(error),
};
};
it('should be muted on creation', () => { it('should be muted on creation', () => {
const manager = new MicrophoneManager(createCardAPI()); const manager = new MicrophoneManager(createCardAPI());
expect(manager).toBeTruthy(); expect(manager).toBeTruthy();
expect(manager.isMuted()).toBeTruthy(); expect(manager.isMuted()).toBeTruthy();
}); });
it('should be undefined without creation', () => { it('should have no stream before connecting', () => {
const manager = new MicrophoneManager(createCardAPI()); const manager = new MicrophoneManager(createCardAPI());
expect(manager.getStream()).toBeUndefined(); expect(manager.getStream()).toBeNull();
}); });
it('should connect', async () => { it('should connect while transmission is active', async () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
const stream = createMockStream(); const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
manager.setTransmissionActive(true);
expect(await manager.connect()).toBe(true);
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(stream);
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should release a stream that connects without active transmission', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
expect(await manager.connect()).toBe(false);
expect(manager.isConnected()).toBeFalsy();
expect(manager.isForbidden()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expect.objectContaining({ connected: false, muted: true }),
}),
);
});
it('should hold a muted stream without transmission when always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect(); await manager.connect();
expect(manager.isConnected()).toBeTruthy(); expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(stream);
expect(manager.isMuted()).toBeTruthy(); expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalled(); expect(getTrack(stream).stop).not.toHaveBeenCalled();
manager.mute();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
}); });
it('should be unsupported without browser support', () => { it('should be unsupported without browser support', () => {
@@ -104,24 +173,74 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toHaveBeenCalled(); expect(api.getCardElementManager().update).toHaveBeenCalled();
}); });
it('should mute and unmute', async () => { it('should mute and unmute while transmission is active', async () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(), createMockStream(),
); );
await manager.connect(); manager.setTransmissionActive(true);
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
manager.mute(); await manager.connect();
expect(manager.isMuted()).toBeTruthy(); expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
await manager.unmute(); await manager.unmute();
expect(manager.isMuted()).toBeFalsy(); expect(manager.isMuted()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(3); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(3);
manager.mute();
expect(manager.isMuted()).toBeTruthy();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(4);
// Unmuting again reuses the held stream rather than re-requesting the
// device.
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
expect(navigatorMock.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1);
});
it('should not unmute without active transmission', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.unmute();
expect(navigatorMock.mediaDevices.getUserMedia).not.toHaveBeenCalled();
expect(manager.isConnected()).toBeFalsy();
expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).not.toHaveBeenCalled();
});
it('should not unmute without active transmission when always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
await manager.connect();
expect(manager.isMuted()).toBeTruthy();
await manager.unmute();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
}); });
it('should not unmute when microphone forbidden', async () => { it('should not unmute when microphone forbidden', async () => {
@@ -129,14 +248,15 @@ describe('MicrophoneManager', () => {
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error()); vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockRejectedValue(new Error());
manager.setTransmissionActive(true);
await expect(manager.connect()).rejects.toThrow(Error); await expect(manager.connect()).rejects.toThrow(Error);
expect(manager.isMuted()).toBeTruthy(); expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
await manager.unmute(); await manager.unmute();
expect(manager.isForbidden()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy(); expect(manager.isMuted()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
}); });
it('should not unmute when not supported', async () => { it('should not unmute when not supported', async () => {
@@ -150,13 +270,14 @@ describe('MicrophoneManager', () => {
expect(manager.isMuted()).toBeTruthy(); expect(manager.isMuted()).toBeTruthy();
}); });
it('should connect on unmute', async () => { it('should connect on unmute while transmission is active', async () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new MicrophoneManager(api); const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(), createMockStream(),
); );
manager.setTransmissionActive(true);
expect(manager.isConnected()).toBeFalsy(); expect(manager.isConnected()).toBeFalsy();
await manager.unmute(); await manager.unmute();
@@ -167,236 +288,234 @@ describe('MicrophoneManager', () => {
expect(api.getCardElementManager().update).toHaveBeenCalled(); expect(api.getCardElementManager().update).toHaveBeenCalled();
}); });
it('should disconnect', async () => { describe('should follow transmission state', () => {
const api = createCardAPI(); it('should release the stream and reset mute when transmission ends', async () => {
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
manager.disconnect();
expect(manager.isConnected()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
});
it('should automatically disconnect', async () => {
vi.useFakeTimers();
const disconnectSeconds = 10;
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: false,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeFalsy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(2);
});
it('should not automatically disconnect when always connected', async () => {
vi.useFakeTimers();
const disconnectSeconds = 10;
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(
createMockStream(),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
});
describe('should stay connected while in use', () => {
const disconnectSeconds = 10;
const createManagerWithDisconnectSeconds = (): MicrophoneManager => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( const manager = new MicrophoneManager(api);
createMockStream(), const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
manager.setTransmissionActive(true);
await manager.connect();
await manager.unmute();
expect(manager.isMuted()).toBeFalsy();
manager.setTransmissionActive(false);
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(stream).stop).toHaveBeenCalled();
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
microphone: expect.objectContaining({ connected: false, muted: true }),
}),
); );
// The next transmission must not start with a hot microphone: the desired mute
// was reset when the previous transmission ended.
const newStream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(newStream);
manager.setTransmissionActive(true);
await manager.connect();
expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
});
it('should keep the stream muted when transmission ends and always connected', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
live: { live: {
microphone: { microphone: {
always_connected: false, always_connected: true,
disconnect_seconds: disconnectSeconds,
}, },
}, },
}), }),
); );
return new MicrophoneManager(api); const stream = createMockStream();
}; vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
it('should not automatically disconnect while a user is registered', async () => { manager.setTransmissionActive(true);
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect(); await manager.connect();
manager.startUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
});
it('should not automatically disconnect when a user is registered before connection', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
manager.startUsing();
await manager.connect();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeTruthy();
});
it('should not automatically disconnect on mute or unmute while in use', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
await manager.unmute(); await manager.unmute();
manager.mute(); expect(manager.isMuted()).toBeFalsy();
vi.advanceTimersByTime(disconnectSeconds * 1000);
manager.setTransmissionActive(false);
expect(manager.isConnected()).toBeTruthy(); expect(manager.isConnected()).toBeTruthy();
expect(manager.isMuted()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
}); });
it('should restart the countdown in full when use stops', async () => { it('should ignore the end of a transmission that never started', async () => {
vi.useFakeTimers(); const api = createCardAPI();
const manager = new MicrophoneManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
live: {
microphone: {
always_connected: true,
},
},
}),
);
const stream = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue(stream);
const manager = createManagerWithDisconnectSeconds();
await manager.connect(); await manager.connect();
manager.startUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000);
manager.stopUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000 - 1);
expect(manager.isConnected()).toBeTruthy(); expect(manager.isConnected()).toBeTruthy();
expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1); manager.setTransmissionActive(false);
expect(manager.isConnected()).toBeFalsy();
});
it('should become idle on a single stop no matter how often use was started', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
manager.startUsing();
manager.startUsing();
manager.stopUsing();
vi.advanceTimersByTime(disconnectSeconds * 1000);
expect(manager.isConnected()).toBeFalsy();
});
it('should restart the countdown when stopped without having been in use', async () => {
vi.useFakeTimers();
const manager = createManagerWithDisconnectSeconds();
await manager.connect();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
manager.stopUsing();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2);
expect(manager.isConnected()).toBeTruthy(); expect(manager.isConnected()).toBeTruthy();
expect(getTrack(stream).stop).not.toHaveBeenCalled();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
expect(manager.isConnected()).toBeFalsy();
}); });
it('should not reconnect after an explicit disconnect while in use', async () => { it('should ignore a transmission report that changes nothing', () => {
vi.useFakeTimers(); const api = createCardAPI();
const manager = new MicrophoneManager(api);
const manager = createManagerWithDisconnectSeconds(); manager.setTransmissionActive(true);
await manager.connect(); manager.setTransmissionActive(true);
manager.startUsing();
manager.disconnect();
vi.advanceTimersByTime(disconnectSeconds * 1000); expect(api.getCardElementManager().update).toHaveBeenCalledTimes(1);
expect(manager.isConnected()).toBeFalsy();
}); });
}); });
it('should not disconnect after being explicitly disconnected and reconnected', async () => { describe('should guard in-flight connections', () => {
vi.useFakeTimers(); it('should release a stream whose connect completes after a mute', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockReturnValue(
deferred.promise,
);
const disconnectSeconds = 10; const connectPromise = manager.connect();
const api = createCardAPI(); manager.mute();
const manager = new MicrophoneManager(api); deferred.resolve(stream);
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( await connectPromise;
createMockStream(),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( expect(manager.isConnected()).toBeFalsy();
createConfig({ expect(getTrack(stream).stop).toHaveBeenCalled();
live: { });
microphone: {
always_connected: false,
disconnect_seconds: disconnectSeconds,
},
},
}),
);
await manager.connect(); it('should release a stream whose connect completes after transmission ends', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const stream = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockReturnValue(
deferred.promise,
);
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2); manager.setTransmissionActive(true);
manager.disconnect(); const connectPromise = manager.connect();
await manager.connect(); manager.setTransmissionActive(false);
deferred.resolve(stream);
await connectPromise;
// The countdown from the first connection must not survive the disconnect expect(manager.isConnected()).toBeFalsy();
// and cut the second one short. expect(getTrack(stream).stop).toHaveBeenCalled();
vi.advanceTimersByTime((disconnectSeconds * 1000) / 2); });
expect(manager.isConnected()).toBeTruthy();
it('should not install a stream whose connect completes after the held stream is released', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const held = createMockStream();
const late = createMockStream();
const deferred = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockResolvedValueOnce(held)
.mockReturnValueOnce(deferred.promise);
manager.setTransmissionActive(true);
await manager.connect();
const connectPromise = manager.connect();
// Releasing the held stream also invalidates the connect still in
// flight.
manager.setTransmissionActive(false);
deferred.resolve(late);
await connectPromise;
expect(manager.isConnected()).toBeFalsy();
expect(getTrack(held).stop).toHaveBeenCalled();
expect(getTrack(late).stop).toHaveBeenCalled();
});
it('should keep only the newest of overlapping connects', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamA = createMockStream();
const streamB = createMockStream();
const deferredA = createDeferredStream();
const deferredB = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockReturnValueOnce(deferredA.promise)
.mockReturnValueOnce(deferredB.promise);
manager.setTransmissionActive(true);
const connectA = manager.connect();
const connectB = manager.connect();
deferredA.resolve(streamA);
deferredB.resolve(streamB);
expect(await connectA).toBe(false);
expect(await connectB).toBe(true);
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(streamB);
expect(getTrack(streamA).stop).toHaveBeenCalled();
expect(getTrack(streamB).stop).not.toHaveBeenCalled();
});
it('should not mark forbidden on a stale connect rejection', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamB = createMockStream();
const deferredA = createDeferredStream();
const deferredB = createDeferredStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockReturnValueOnce(deferredA.promise)
.mockReturnValueOnce(deferredB.promise);
manager.setTransmissionActive(true);
const connectA = manager.connect();
const connectB = manager.connect();
deferredA.reject(new Error());
deferredB.resolve(streamB);
await expect(connectA).rejects.toThrow(Error);
await connectB;
expect(manager.isForbidden()).toBeFalsy();
expect(manager.isConnected()).toBeTruthy();
expect(manager.getStream()).toBe(streamB);
});
it('should stop the replaced stream when connecting over an existing stream', async () => {
const api = createCardAPI();
const manager = new MicrophoneManager(api);
const streamA = createMockStream();
const streamB = createMockStream();
vi.mocked(navigatorMock.mediaDevices.getUserMedia)
.mockResolvedValueOnce(streamA)
.mockResolvedValueOnce(streamB);
manager.setTransmissionActive(true);
await manager.connect();
expect(manager.getStream()).toBe(streamA);
await manager.connect();
expect(manager.getStream()).toBe(streamB);
expect(getTrack(streamA).stop).toHaveBeenCalled();
expect(getTrack(streamB).stop).not.toHaveBeenCalled();
});
}); });
describe('should require initialization', async () => { describe('should require initialization', async () => {
@@ -457,7 +576,7 @@ describe('MicrophoneManager', () => {
manager.initialize(); manager.initialize();
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({ expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
microphone: { connected: false, muted: true, forbidden: false, stream: undefined }, microphone: { connected: false, muted: true, forbidden: false, stream: null },
}); });
}); });
@@ -469,6 +588,7 @@ describe('MicrophoneManager', () => {
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled(); expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
manager.setTransmissionActive(true);
await manager.connect(); await manager.connect();
let expectedState: MicrophoneState = { let expectedState: MicrophoneState = {
@@ -515,11 +635,11 @@ describe('MicrophoneManager', () => {
}), }),
); );
manager.disconnect(); manager.setTransmissionActive(false);
expectedState = { expectedState = {
forbidden: false, forbidden: false,
stream: undefined, stream: null,
connected: false, connected: false,
muted: true, muted: true,
}; };
@@ -309,7 +309,7 @@ describe('MicrophoneActionsController', () => {
}); });
describe('on call answered state change', () => { describe('on call answered state change', () => {
it('should unmute on call answer when call is a configured unmute condition', () => { it('should unmute on call answer when call is a configured unmute condition', async () => {
const microphoneManager = createMicrophoneManager(); const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController(); const controller = new MicrophoneActionsController();
controller.setOptions({ controller.setOptions({
@@ -317,13 +317,13 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: ['call' as const], autoUnmuteConditions: ['call' as const],
}); });
controller.setCallAnswered(false); await controller.setCallAnswered(false);
controller.setCallAnswered(true); await controller.setCallAnswered(true);
expect(microphoneManager.unmute).toHaveBeenCalledTimes(1); expect(microphoneManager.unmute).toHaveBeenCalledTimes(1);
}); });
it('should unmute when the call is already answered on first notification', () => { it('should unmute when the call is already answered on first notification', async () => {
const microphoneManager = createMicrophoneManager(); const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController(); const controller = new MicrophoneActionsController();
controller.setOptions({ controller.setOptions({
@@ -334,41 +334,42 @@ describe('MicrophoneActionsController', () => {
// `setCallAnswered(true)` is the first call-state signal, with no // `setCallAnswered(true)` is the first call-state signal, with no
// preceding `false` -- as for a live view that mounts while a call is // 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. // already answered. The initial state must not be swallowed as a baseline.
controller.setCallAnswered(true); await controller.setCallAnswered(true);
expect(microphoneManager.unmute).toHaveBeenCalledTimes(1); expect(microphoneManager.unmute).toHaveBeenCalledTimes(1);
}); });
it('should mute on call end when call is a configured mute condition', () => { it('should not mute on call end', async () => {
const microphoneManager = createMicrophoneManager(); const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController(); const controller = new MicrophoneActionsController();
controller.setOptions({ controller.setOptions({
microphoneManager, microphoneManager,
autoMuteConditions: ['call' as const],
});
controller.setCallAnswered(true);
controller.setCallAnswered(false);
expect(microphoneManager.mute).toHaveBeenCalledTimes(1);
});
it('should not act on the initial call state', () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
microphoneManager,
autoMuteConditions: ['call' as const],
autoUnmuteConditions: ['call' as const], autoUnmuteConditions: ['call' as const],
}); });
controller.setCallAnswered(false); await controller.setCallAnswered(true);
await controller.setCallAnswered(false);
// 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 () => {
const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController();
controller.setOptions({
microphoneManager,
autoUnmuteConditions: ['call' as const],
});
await controller.setCallAnswered(false);
expect(microphoneManager.mute).not.toHaveBeenCalled(); expect(microphoneManager.mute).not.toHaveBeenCalled();
expect(microphoneManager.unmute).not.toHaveBeenCalled(); expect(microphoneManager.unmute).not.toHaveBeenCalled();
}); });
it('should not act on call answer when call is not a configured condition', () => { it('should not act on call answer when call is not a configured condition', async () => {
const microphoneManager = createMicrophoneManager(); const microphoneManager = createMicrophoneManager();
const controller = new MicrophoneActionsController(); const controller = new MicrophoneActionsController();
controller.setOptions({ controller.setOptions({
@@ -376,8 +377,8 @@ describe('MicrophoneActionsController', () => {
autoUnmuteConditions: [], autoUnmuteConditions: [],
}); });
controller.setCallAnswered(false); await controller.setCallAnswered(false);
controller.setCallAnswered(true); await controller.setCallAnswered(true);
expect(microphoneManager.unmute).not.toHaveBeenCalled(); expect(microphoneManager.unmute).not.toHaveBeenCalled();
}); });
+446
View File
@@ -3749,6 +3749,452 @@ describe('should handle version specific upgrades', () => {
}); });
describe('v8.0.0+', () => { describe('v8.0.0+', () => {
describe('should remove the retired microphone options', () => {
it('should delete disconnect_seconds', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { disconnect_seconds: 120, always_connected: true } },
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { always_connected: true } },
});
postUpgradeChecks(config);
});
it('should delete disconnect_seconds in overrides', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
overrides: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
merge: { live: { microphone: { disconnect_seconds: 120 } } },
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.overrides[0].merge).toEqual({ live: { microphone: {} } });
postUpgradeChecks(config);
});
it('should strip call from the microphone auto_mute', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_mute: ['hidden', 'call'] } },
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { auto_mute: ['hidden'] } },
});
postUpgradeChecks(config);
});
it('should empty the microphone auto_mute when only call is listed', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_mute: ['call'], always_connected: true } },
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { auto_mute: [], always_connected: true } },
});
postUpgradeChecks(config);
});
it('should empty an overridden microphone auto_mute rather than delete it', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_mute: ['hidden' as const] } },
overrides: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
set: { 'live.microphone.auto_mute': ['call'] },
},
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: false }],
merge: { live: { microphone: { auto_mute: ['call'] } } },
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
// Deleting the key would restore the base `['hidden']` for anyone the
// override applies to, rather than leaving them with nothing.
expect(config.overrides[0].set).toEqual({
live: { microphone: { auto_mute: [] } },
});
expect(config.overrides[1].merge).toEqual({
live: { microphone: { auto_mute: [] } },
});
postUpgradeChecks(config);
});
it('should leave a microphone auto_mute without call alone', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { microphone: { auto_mute: ['hidden'] } },
};
expect(upgradeConfig(config)).toBeFalsy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { microphone: { auto_mute: ['hidden'] } },
});
});
it('should not strip call from the live auto_mute', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
live: { auto_mute: ['hidden', 'call'] },
};
expect(upgradeConfig(config)).toBeFalsy();
expect(config).toEqual({
type: 'custom:advanced-camera-card',
cameras: [{}],
live: { auto_mute: ['hidden', 'call'] },
});
});
});
describe('should remove the retired microphone actions', () => {
it('should delete a singular tap_action', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
tap_action: {
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.elements[0]).toEqual({
type: 'custom:advanced-camera-card-menu-icon',
icon: 'mdi:microphone',
});
postUpgradeChecks(config);
});
it('should splice an action array and keep the order of the rest', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
tap_action: [
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_unmute' as const,
},
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_disconnect' as const,
},
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_mute' as const,
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.elements[0]['tap_action']).toEqual([
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_unmute',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_mute',
},
]);
postUpgradeChecks(config);
});
it('should delete the start and end tap actions of a momentary button', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
start_tap_action: {
action: 'fire-dom-event' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
end_tap_action: {
action: 'fire-dom-event' as const,
advanced_camera_card_action: 'microphone_disconnect' as const,
},
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.elements[0]).toEqual({
type: 'custom:advanced-camera-card-menu-icon',
icon: 'mdi:microphone',
});
postUpgradeChecks(config);
});
it('should remove the actions from an override', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
overrides: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
merge: {
'menu.buttons.microphone': {
tap_action: {
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
},
},
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.overrides[0].merge).toEqual({
'menu.buttons.microphone': {},
});
postUpgradeChecks(config);
});
it('should keep an automation that retains other actions', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
automations: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
actions: [
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_unmute' as const,
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]['actions']).toEqual([
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_unmute',
},
]);
postUpgradeChecks(config);
});
it('should empty the actions of an automation left with none', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
automations: [
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: true }],
actions: [
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_connect' as const,
},
],
},
{
conditions: [{ condition: 'fullscreen' as const, fullscreen: false }],
actions: [
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_mute' as const,
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations).toHaveLength(2);
expect(config.automations[0]['actions']).toEqual([]);
expect(config.automations[1]['actions']).toEqual([
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_mute',
},
]);
postUpgradeChecks(config);
});
it('should keep the required then of an if action that empties', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
automations: [
{
triggers: [{ trigger: 'camera' as const }],
actions: [
{
if: [{ condition: 'fullscreen' as const, fullscreen: true }],
then: [
{
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_disconnect' as const,
},
],
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
// `then` is required by the schema, so it must survive as an empty
// array rather than be deleted.
expect(config.automations[0]['actions']).toEqual([
{ if: [{ condition: 'fullscreen', fullscreen: true }], then: [] },
]);
postUpgradeChecks(config);
});
it('should leave an unrelated action payload alone', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
tap_action: {
action: 'perform-action' as const,
perform_action: 'input_boolean.turn_on',
data: {
advanced_camera_card_action: 'microphone_connect',
},
},
},
],
};
expect(upgradeConfig(config)).toBeFalsy();
expect(config.elements[0]['tap_action']).toEqual({
action: 'perform-action',
perform_action: 'input_boolean.turn_on',
data: {
advanced_camera_card_action: 'microphone_connect',
},
});
});
it('should leave an action-shaped service data payload alone', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
tap_action: {
action: 'perform-action' as const,
perform_action: 'script.relay',
data: {
payload: {
action: 'fire-dom-event',
advanced_camera_card_action: 'microphone_connect',
},
},
},
},
],
};
expect(upgradeConfig(config)).toBeFalsy();
// `data` is opaque to the card: whatever it holds is the user's to
// send on, even when it looks like an action.
expect(config.elements[0]['tap_action']['data']).toEqual({
payload: {
action: 'fire-dom-event',
advanced_camera_card_action: 'microphone_connect',
},
});
});
it('should leave the surviving microphone actions alone', () => {
const config = {
type: 'custom:advanced-camera-card' as const,
cameras: [{}],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon' as const,
icon: 'mdi:microphone',
tap_action: {
action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'microphone_mute' as const,
},
},
],
};
expect(upgradeConfig(config)).toBeFalsy();
});
});
describe('live.controls.thumbnails.media_type -> cameras_global.media.type', () => { describe('live.controls.thumbnails.media_type -> cameras_global.media.type', () => {
it.each([['events' as const], ['recordings' as const]])( it.each([['events' as const], ['recordings' as const]])(
'should handle %s', 'should handle %s',
+1 -10
View File
@@ -147,9 +147,8 @@ describe('config defaults', () => {
lazy_unload: [], lazy_unload: [],
microphone: { microphone: {
always_connected: false, always_connected: false,
auto_mute: ['call'], auto_mute: [],
auto_unmute: [], auto_unmute: [],
disconnect_seconds: 90,
mute_after_microphone_mute_seconds: 60, mute_after_microphone_mute_seconds: 60,
}, },
preload: false, preload: false,
@@ -1111,14 +1110,6 @@ describe('config defaults', () => {
action: 'custom:advanced-camera-card-action', action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'menu_toggle', advanced_camera_card_action: 'menu_toggle',
}, },
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_connect',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_disconnect',
},
{ {
action: 'custom:advanced-camera-card-action', action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'microphone_mute', advanced_camera_card_action: 'microphone_mute',