From f397596ed1b80c677335471739ecead9a3d958d5 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 24 May 2026 10:00:09 -0700 Subject: [PATCH] feat: Explicit answer/reject for inbound calls (#2504) --- docs/configuration/actions/custom/README.md | 11 +- docs/configuration/live.md | 62 ++-- docs/usage/2-way-audio.md | 46 +-- .../actions/actions/call-answer.ts | 11 + src/card-controller/actions/factory.ts | 7 +- src/card-controller/call/manager.ts | 53 ++- src/card-controller/types.ts | 1 + .../live/microphone-actions-controller.ts | 24 +- .../media-actions-controller.ts | 22 +- src/components/call-controls.ts | 81 ++++- src/components/live/carousel.ts | 26 +- src/components/live/grid.ts | 2 +- src/components/live/index.ts | 6 +- .../schema/actions/custom/call-answer.ts | 8 + src/config/schema/actions/types.ts | 2 + src/localize/languages/en.json | 4 +- src/scss/call-controls.scss | 20 +- src/scss/pop-animation.scss | 23 ++ src/scss/themes/base.scss | 10 +- src/utils/action.ts | 11 + .../actions/actions/call-answer.test.ts | 18 + tests/card-controller/actions/factory.test.ts | 2 + tests/card-controller/call/manager.test.ts | 322 +++++++----------- .../microphone-actions-controller.test.ts | 30 +- .../media-actions-controller.test.ts | 45 +-- tests/utils/action.test.ts | 18 + 26 files changed, 503 insertions(+), 362 deletions(-) create mode 100644 src/card-controller/actions/actions/call-answer.ts create mode 100644 src/config/schema/actions/custom/call-answer.ts create mode 100644 tests/card-controller/actions/actions/call-answer.test.ts diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index d789440f..cfcdd887 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -12,9 +12,18 @@ action: custom:advanced-camera-card-action | `action` | Must be `custom:advanced-camera-card-action`. | | `advanced_camera_card_action` | A supported Advanced Camera Card action. One of the below actions. | +## `call_answer` + +Answer an inbound [two-way audio](../../../usage/2-way-audio.md) call that is currently ringing -- the same effect as pressing the green answer button on the in-call overlay. Has no effect if there is no inbound call or it has already been answered. To reject a ringing call instead, use [`call_end`](#call_end). + +```yaml +action: custom:advanced-camera-card-action +advanced_camera_card_action: call_answer +``` + ## `call_end` -End the [two-way audio](../../../usage/2-way-audio.md) call in progress. Has no effect if no call is active. +End the [two-way audio](../../../usage/2-way-audio.md) call in progress or rejects a ringing inbound call. Has no effect if no call is active. ```yaml action: custom:advanced-camera-card-action diff --git a/docs/configuration/live.md b/docs/configuration/live.md index 7fcac9c5..c45ae47b 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -7,23 +7,23 @@ live: # [...] ``` -| Option | Default | Description | -| ------------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `actions` | | [Actions](actions/README.md) to use for the `live` view. | -| `auto_mute` | `[unselected, hidden, microphone, call]` | A list of conditions in which live camera feeds are muted. `unselected` will automatically mute when a camera is unselected in the carousel or grid. `hidden` will automatically mute when the camera becomes hidden (e.g. browser tab change). `microphone` will automatically mute after the microphone is muted as long as the camera stays selected (see the `live.microphone.mute_after_microphone_mute_seconds` to control how long after). `call` will automatically mute the inbound audio when a [two-way audio](../usage/2-way-audio.md) call ends. Use an empty list (`[]`) to never automatically mute. Note that if `auto_play` is enabled, the stream may mute itself automatically in order to honor the `auto_play` setting, as some browsers will not auto play media that is unmuted -- that is to say, where necessary, the `auto_play` parameter will take priority over the `auto_mute` parameter. | -| `auto_pause` | `[]` | A list of conditions in which live camera feeds are automatically paused. `unselected` will automatically pause when a camera is unselected in the carousel or grid. `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause. **Caution**: Some live providers (e.g. `jsmpeg`) may not offer human-accessible means to resume play if it is paused, unless the `auto_play` option is used. | -| `auto_play` | `[selected, visible]` | A list of conditions in which live camera feeds are automatically played. `selected` will automatically play when a camera is selected in a carousel or grid. `visible` will automatically play when a camera becomes visible (e.g. browser tab change, or visible in a grid but not selected). Use an empty list (`[]`) to never automatically play. Some live providers (e.g. `webrtc-card`, `jsmpeg`) do not support the prevention of automatic play on initial load, but should still respect the value of this parameter on play-after-pause. | -| `auto_unmute` | `[microphone, call]` | A list of conditions in which live camera feeds are unmuted. `selected` will automatically unmute when a camera is selected in a carousel or grid. `visible` will automatically unmute when a camera becomes visible (e.g. a browser/tab change, or visible in a grid but not selected). `microphone` will automatically unmute after the microphone is unmuted. `call` will automatically unmute the inbound audio when a [two-way audio](../usage/2-way-audio.md) call starts, so the caller can be heard immediately. Use an empty list (`[]`) to never automatically unmute. Some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead. | -| `controls` | | Configuration for the `live` view controls. See [`controls`](#controls). | -| `display` | | Controls whether to show a single or grid `live` view. See [`display`](#display). | -| `draggable` | `true` | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. | -| `lazy_load` | `true` | Whether or not to lazily load cameras in the camera carousel. Setting this to `false` will cause all cameras to load simultaneously when the `live` carousel is opened (or cause all cameras to load continually if `preload` is also `true`). This will result in a smoother carousel experience at a cost of (potentially) a substantial amount of continually streamed data. | -| `lazy_unload` | `[]` | A list of conditions in which live camera feeds are unloaded. `unselected` will unload a camera when it is not visible in the carousel/grid and `hidden` will unload a camera when the browser itself is minimized or the browser tab changes. An empty list (`[]`, the default) will never automatically unload a stream once loaded unless the user navigates away entirely, so that it's always instantly visible on carousel scroll. Once unloaded, subsequently revisiting the camera will cause a reloading delay. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting. | -| `microphone` | | See [`microphone`](#microphone). | -| `preload` | `false` | Whether or not to preload the live view. Preloading causes the live view to render in the background regardless of what view is actually shown, so it's instantly available when requested. The currently-selected camera's media is loaded in the background; other cameras follow the `lazy_load` setting (set `lazy_load: false` to preload them all). This consumes additional network/CPU resources continually. | -| `show_image_during_load` | `true` | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. | -| `transition_effect` | `slide` | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. | -| `zoomable` | `true` | Whether or not the live carousel can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. | +| Option | Default | Description | +| ------------------------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `actions` | | [Actions](actions/README.md) to use for the `live` view. | +| `auto_mute` | `[unselected, hidden, microphone, call]` | A list of conditions in which live camera feeds are muted. `unselected` will automatically mute when a camera is unselected in the carousel or grid. `hidden` will automatically mute when the camera becomes hidden (e.g. browser tab change). `microphone` will automatically mute after the microphone is muted as long as the camera stays selected (see the `live.microphone.mute_after_microphone_mute_seconds` to control how long after). `call` will automatically mute the inbound audio when an answered [two-way audio](../usage/2-way-audio.md) call ends. Use an empty list (`[]`) to never automatically mute. Note that if `auto_play` is enabled, the stream may mute itself automatically in order to honor the `auto_play` setting, as some browsers will not auto play media that is unmuted -- that is to say, where necessary, the `auto_play` parameter will take priority over the `auto_mute` parameter. | +| `auto_pause` | `[]` | A list of conditions in which live camera feeds are automatically paused. `unselected` will automatically pause when a camera is unselected in the carousel or grid. `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause. **Caution**: Some live providers (e.g. `jsmpeg`) may not offer human-accessible means to resume play if it is paused, unless the `auto_play` option is used. | +| `auto_play` | `[selected, visible]` | A list of conditions in which live camera feeds are automatically played. `selected` will automatically play when a camera is selected in a carousel or grid. `visible` will automatically play when a camera becomes visible (e.g. browser tab change, or visible in a grid but not selected). Use an empty list (`[]`) to never automatically play. Some live providers (e.g. `webrtc-card`, `jsmpeg`) do not support the prevention of automatic play on initial load, but should still respect the value of this parameter on play-after-pause. | +| `auto_unmute` | `[microphone, call]` | A list of conditions in which live camera feeds are unmuted. `selected` will automatically unmute when a camera is selected in a carousel or grid. `visible` will automatically unmute when a camera becomes visible (e.g. a browser/tab change, or visible in a grid but not selected). `microphone` will automatically unmute after the microphone is unmuted. `call` will automatically unmute the inbound audio when a [two-way audio](../usage/2-way-audio.md) call is answered (outbound calls are answered at start; inbound calls when the user accepts), so the caller can be heard. Use an empty list (`[]`) to never automatically unmute. Some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead. | +| `controls` | | Configuration for the `live` view controls. See [`controls`](#controls). | +| `display` | | Controls whether to show a single or grid `live` view. See [`display`](#display). | +| `draggable` | `true` | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. | +| `lazy_load` | `true` | Whether or not to lazily load cameras in the camera carousel. Setting this to `false` will cause all cameras to load simultaneously when the `live` carousel is opened (or cause all cameras to load continually if `preload` is also `true`). This will result in a smoother carousel experience at a cost of (potentially) a substantial amount of continually streamed data. | +| `lazy_unload` | `[]` | A list of conditions in which live camera feeds are unloaded. `unselected` will unload a camera when it is not visible in the carousel/grid and `hidden` will unload a camera when the browser itself is minimized or the browser tab changes. An empty list (`[]`, the default) will never automatically unload a stream once loaded unless the user navigates away entirely, so that it's always instantly visible on carousel scroll. Once unloaded, subsequently revisiting the camera will cause a reloading delay. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting. | +| `microphone` | | See [`microphone`](#microphone). | +| `preload` | `false` | Whether or not to preload the live view. Preloading causes the live view to render in the background regardless of what view is actually shown, so it's instantly available when requested. The currently-selected camera's media is loaded in the background; other cameras follow the `lazy_load` setting (set `lazy_load: false` to preload them all). This consumes additional network/CPU resources continually. | +| `show_image_during_load` | `true` | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. | +| `transition_effect` | `slide` | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. | +| `zoomable` | `true` | Whether or not the live carousel can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. | ## `controls` @@ -46,7 +46,7 @@ live: ### `call` -Configures the on-screen controls shown during a [two-way audio](../usage/2-way-audio.md) call. The overlay appears whenever a call is in progress and offers buttons to mute/unmute the microphone, mute/unmute the inbound audio, and end the call. +Configures the on-screen controls shown during a [two-way audio](../usage/2-way-audio.md) call. The overlay appears whenever a call is in progress and offers buttons to answer calls (for inbound), end calls, mute/unmute the microphone and mute/unmute the inbound audio. ```yaml live: @@ -55,12 +55,12 @@ live: # [...] ``` -| Option | Default | Description | -| ---------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `button_size` | `40` | The size of the call control buttons in pixels. Must be >= `20`. | -| `lock` | `true` | Whether to lock the rest of the card controls/actions while a call is in progress. Prevents an accidental tap, swipe or action mid-call. Set to `false` to allow interactions regardless of call state. | -| `ringtone` | | The audible chime played while an inbound call (e.g. one started by [`view.triggers.actions.trigger: call`](./view.md?id=trigger-action-configuration)) is ringing. Stops as soon as the microphone is un-muted (the "answer" gesture) or the call ends. Manual calls never ring. See [`ringtone`](#ringtone). | -| `unanswered_timeout_seconds` | `60` | The number of seconds an inbound call may ring unanswered before it is automatically ended. The timer is cancelled the moment the microphone is un-muted. Set to `0` to disable the timeout. | +| Option | Default | Description | +| ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `button_size` | `40` | The size of the call control buttons in pixels. Must be >= `20`. | +| `lock` | `true` | Whether to lock the rest of the card controls/actions while a call is in progress. Prevents an accidental tap, swipe or action mid-call. Set to `false` to allow interactions regardless of call state. | +| `ringtone` | | The audible chime played while an inbound call (e.g. one started by [`view.triggers.actions.trigger: call`](./view.md?id=trigger-action-configuration)) is ringing. Stops as soon as the call is answered or ended. Manual calls never ring. See [`ringtone`](#ringtone). | +| `unanswered_timeout_seconds` | `60` | The number of seconds an inbound call may ring unanswered before it is automatically ended. The timer is cancelled the moment the call is answered. Set to `0` to disable the timeout. | > [!NOTE] Browser autoplay restrictions may prevent the ringtone from playing > until the page has received a user gesture (e.g. a tap or click). When that @@ -221,13 +221,13 @@ live: microphone: ``` -| 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. | -| `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 a [two-way audio](../usage/2-way-audio.md) call ends. Use an empty list (`[]`) to never automatically mute the microphone via these conditions. | -| `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 starts. By default this list is empty, so the microphone stays muted when a call starts (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. | -| `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`. | -| `mute_after_microphone_mute_seconds` | `60` | The number of seconds after the microphone mutes to automatically mute the inbound audio when `live.auto_mute` includes `microphone`. | +| Option | Default | Description | +| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `always_connected` | `false` | Whether or not to keep the microphone 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. | +| `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_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. | +| `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`. | +| `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. diff --git a/docs/usage/2-way-audio.md b/docs/usage/2-way-audio.md index 034c5e6a..895584b9 100644 --- a/docs/usage/2-way-audio.md +++ b/docs/usage/2-way-audio.md @@ -45,15 +45,20 @@ enabled by default and appears in the `live` view whenever the selected camera -- or one of its [dependencies](../configuration/cameras/README.md?id=dependencies) -- supports 2-way audio. -- Tap the call button to start a call. An on-screen overlay appears with - controls to mute/unmute the microphone, mute/unmute the inbound audio, and end - the call. When more than one 2-way-audio camera is available the button - becomes a submenu with one entry per camera. -- When a call starts the inbound audio is unmuted automatically, so the caller - can be heard immediately. The microphone stays muted by default - (push-to-talk) -- tap the microphone button in the overlay to speak. This is - configurable via [`live.microphone.auto_unmute`](../configuration/live.md?id=microphone) - and [`live.auto_unmute`](../configuration/live.md). +- Tap the call button to start an **outbound** call. An on-screen overlay + appears with controls to mute/unmute the microphone, mute/unmute the inbound + audio, and end the call. When more than one 2-way-audio camera is available + the button becomes a submenu with one entry per camera. +- **Inbound** calls (started by a + [`view.triggers.actions.trigger: call`](../configuration/view.md?id=trigger-action-configuration) + trigger -- e.g. a doorbell) open the overlay in a ringing state with only + two buttons: a red **Reject** and a green **Answer**. +- When a call is answered (outbound calls are answered by definition) the + inbound audio is unmuted automatically, so the caller can be heard. The + microphone stays muted by default (push-to-talk) -- tap the microphone button + in the overlay to speak. Both behaviors are configurable via + [`live.microphone.auto_unmute`](../configuration/live.md?id=microphone) and + [`live.auto_unmute`](../configuration/live.md). - The camera will always load _without_ the microphone connected, unless the [`always_connected`](../configuration/live.md?id=microphone) microphone option is set to `true`. On the first call there may be a brief `webrtc` connection @@ -69,8 +74,9 @@ enabled by default and appears in the `live` view whenever the selected camera seconds specified by [`disconnect_seconds`](../configuration/live.md?id=microphone) have elapsed since the call ended. -Calls can also be started and ended programmatically with the -[`call_start`](../configuration/actions/custom/README.md?id=call_start) and +Calls can also be controlled programmatically with the +[`call_start`](../configuration/actions/custom/README.md?id=call_start), +[`call_answer`](../configuration/actions/custom/README.md?id=call_answer), and [`call_end`](../configuration/actions/custom/README.md?id=call_end) actions -- for example, from an [automation](../configuration/automations.md) that fires when a doorbell sensor triggers. The [`call` condition](../configuration/conditions.md?id=call) @@ -84,13 +90,13 @@ The diagram below traces a call from start to finish: ## Talking with a single tap -By default, two taps are needed to speak: the call button starts the call (so -you can hear), then the microphone button in the call overlay unmutes your -microphone (so you can be heard). This push-to-talk default keeps the microphone -muted until you explicitly choose to speak. +By default, two taps are needed to speak: start (or answer) the call so you can +hear, then unmute the microphone via the in-call overlay so you can be heard. +This push-to-talk default keeps the microphone muted until you explicitly choose +to speak. To collapse that to a single tap, unmute the microphone automatically when a -call starts: +call is answered: ```yaml live: @@ -98,9 +104,7 @@ live: auto_unmute: ['call'] ``` -The call button then behaves as a toggle -- one tap starts the call and opens -the microphone, a second tap ends the call and closes it again. Note this also -opens the microphone for calls started by an -[automation](../configuration/automations.md); leave +For outbound calls the microphone opens the moment the call starts; for inbound +calls it opens the moment you press the green answer button. Leave [`auto_unmute`](../configuration/live.md?id=microphone) empty (the default) to -always start muted. +always start muted regardless. diff --git a/src/card-controller/actions/actions/call-answer.ts b/src/card-controller/actions/actions/call-answer.ts new file mode 100644 index 00000000..399c6a04 --- /dev/null +++ b/src/card-controller/actions/actions/call-answer.ts @@ -0,0 +1,11 @@ +import { CallAnswerActionConfig } from '../../../config/schema/actions/custom/call-answer'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class CallAnswerAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + api.getCallManager().answer(); + } +} diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index c6cb824a..16250535 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -2,6 +2,7 @@ import { ActionContext } from 'action'; import { INTERNAL_CALLBACK_ACTION } from '../../config/schema/actions/custom/internal'; import { ActionConfig, AuxillaryActionConfig } from '../../config/schema/actions/types'; import { isAdvancedCameraCardCustomAction } from '../../utils/action'; +import { CallAnswerAction } from './actions/call-answer'; import { CallEndAction } from './actions/call-end'; import { CallServiceAction } from './actions/call-service'; import { CallStartAction } from './actions/call-start'; @@ -123,10 +124,12 @@ export class ActionFactory { return new InfoAction(context, action, options?.config); case 'menu_toggle': return new MenuToggleAction(context, action, options?.config); - case 'call_start': - return new CallStartAction(context, action, options?.config); + case 'call_answer': + return new CallAnswerAction(context, action, options?.config); case 'call_end': return new CallEndAction(context, action, options?.config); + case 'call_start': + return new CallStartAction(context, action, options?.config); case 'camera_select': return new CameraSelectAction(context, action, options?.config); case 'substream_off': diff --git a/src/card-controller/call/manager.ts b/src/card-controller/call/manager.ts index 0891ffdf..ca2ff058 100644 --- a/src/card-controller/call/manager.ts +++ b/src/card-controller/call/manager.ts @@ -22,8 +22,7 @@ export class CallManager { public initialize(): void { // A call runs on the live view of a specific camera. The listener watches // condition state so the call can be ended when the view, camera, or - // engaged substream moves off what the call started on -- and so an inbound - // call can register the user's "answer" (microphone un-mute). + // engaged substream moves off what the call started on. this._api.getConditionStateManager().addListener(this._handleConditionStateChange); } @@ -119,8 +118,9 @@ export class CallManager { this._end(false); } - // An already-unmuted mic is treated as "answered" for an inbound call. - const answered = inbound && !this._api.getMicrophoneManager().isMuted(); + // Outbound calls are answered by construction (the user initiated them); + // inbound calls start unanswered and wait for an explicit Answer. + const answered = !inbound; this._call = { cameraID: parentID, @@ -173,6 +173,24 @@ export class CallManager { return this._end(true); } + // Marks an inbound ringing call as answered: stops the ringtone, cancels + // the unanswered timer, and lets the normal call controls take over. + // No-op (returns false) if there is no call or it's already answered; + // rejecting a ringing call uses `end()` (same teardown). + public answer(): boolean { + if (!this._call || this._call.answered) { + return false; + } + this._ringtone.stop(); + this._unansweredTimer.stop(); + // Replace (don't mutate) so Lit identity checks downstream pick up the + // change. The `update()` below forces card.ts to re-render and re-read + // `getCall()`, propagating the new session to the carousel. + this._call = { ...this._call, answered: true }; + this._api.getCardElementManager().update(); + return true; + } + // Ends the active call iff every supplied predicate matches the session. // Returns true iff a call was actually ended. public endIf(options: { @@ -268,34 +286,15 @@ export class CallManager { return true; } - // Watches condition state for two transitions during an active call: - // - // 1. End the call once it can no longer be conducted from where it started - // (e.g. view change). Only react to changes in view/camera/substream - // themselves -- not to unrelated state updates (e.g. `mediaLoadedInfo`) - // that may arrive before the view-manager's own state update. - // - // 2. Register an inbound call as "answered" the first time the microphone - // un-mutes during the call -- a muted->unmuted transition. Idempotent: - // once answered we never flip back, so re-muting later does not undo it. - // Answering also silences the ringtone. + // Ends the call once it can no longer be conducted from where it started + // (e.g. view change). Only reacts to changes in view/camera/substream + // themselves -- not to unrelated state updates (e.g. `mediaLoadedInfo`) + // that may arrive before the view-manager's own state update. private _handleConditionStateChange = (stateChange: ConditionStateChange): void => { if (!this._call) { return; } - if ( - this._call.inbound && - !this._call.answered && - stateChange.change.microphone && - stateChange.new.microphone?.muted === false && - stateChange.old.microphone?.muted !== false - ) { - this._call.answered = true; - this._ringtone.stop(); - this._unansweredTimer.stop(); - } - const viewRelevantChange = stateChange.change.view !== undefined || stateChange.change.camera !== undefined || diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 48556e2f..5b6734fa 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -79,6 +79,7 @@ export interface CardAutomationsAPI { export interface CardCallAPI { getCameraManager(): CameraManager; + getCardElementManager(): CardElementManager; getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getMicrophoneManager(): MicrophoneManager; diff --git a/src/components-lib/live/microphone-actions-controller.ts b/src/components-lib/live/microphone-actions-controller.ts index d6b5470f..fe43563d 100644 --- a/src/components-lib/live/microphone-actions-controller.ts +++ b/src/components-lib/live/microphone-actions-controller.ts @@ -26,7 +26,7 @@ interface MicrophoneActionsControllerOptions { export class MicrophoneActionsController { private _options: MicrophoneActionsControllerOptions | null = null; private _selectedCamera: string | null = null; - private _callActive = false; + private _callAnswered = false; private _visibilityObserver: VisibilityObserver; constructor() { @@ -40,20 +40,22 @@ export class MicrophoneActionsController { } /** - * Notifies the controller of the call-active state, acting only on a genuine - * transition. The initial state is treated as inactive, so a first-ever - * `true` counts -- the call rules apply even when the live view first - * appears during an active call. + * Notifies the controller of the call-answered state (outbound calls are + * answered at start; inbound calls only become answered when the user + * accepts). Acts only on a genuine transition. The initial state is treated + * as unanswered, so a first-ever `true` counts -- the call rules apply even + * when the live view first appears during an answered call. * - * Call start unmutes the microphone only if the user opted into - * `microphone.auto_unmute: ['call']`. + * Call answer unmutes the microphone only if the user opted into + * `microphone.auto_unmute: ['call']`; the symmetric mute fires on the + * answered-to-unanswered transition (call end after answer). */ - public setCallActive(active: boolean): void { - if (active === this._callActive) { + public setCallAnswered(answered: boolean): void { + if (answered === this._callAnswered) { return; } - this._callActive = active; - if (active) { + this._callAnswered = answered; + if (answered) { this._unmuteIfConfigured('call'); } else { this._muteIfConfigured('call'); diff --git a/src/components-lib/media-actions-controller.ts b/src/components-lib/media-actions-controller.ts index a843e1e4..0fe5d453 100644 --- a/src/components-lib/media-actions-controller.ts +++ b/src/components-lib/media-actions-controller.ts @@ -42,7 +42,7 @@ export class MediaActionsController { // Audio-related state fed in via dedicated setters (not `setOptions`, which // is pure configuration). private _microphoneState?: MicrophoneState; - private _callActive = false; + private _callAnswered = false; // Deferred because the media player is not always ready when a call starts: // the call may start from another view, or engage a substream that is still @@ -71,17 +71,19 @@ export class MediaActionsController { this._microphoneStateChangeHandler(previous, state); } - // Audio-out auto-mute/unmute driven by the call lifecycle: unmute on call - // start (hear the caller), mute on call end. Acts only on a genuine - // transition. The first-ever `true` counts as a transition: a carousel that - // loads while a call is already active (e.g. `call_start` dispatched from a - // non-live view) must still unmute. - public setCallActive(active: boolean): void { - if (active === this._callActive) { + // Audio-out auto-mute/unmute driven by call answer: unmute when the call + // is answered (hear the caller), mute when an answered call ends. Acts only + // on a genuine transition. The first-ever `true` counts as a transition: a + // carousel that loads while an answered call is already active must still + // unmute. An inbound call that's rejected pre-answer never sees a `true`, + // so neither side fires -- the camera audio is never auto-disturbed by a + // call the user didn't accept. + public setCallAnswered(answered: boolean): void { + if (answered === this._callAnswered) { return; } - this._callActive = active; - if (active) { + this._callAnswered = answered; + if (answered) { this._pendingCallStartAction = true; this._applyPendingCallStartAction(); } else { diff --git a/src/components/call-controls.ts b/src/components/call-controls.ts index 137d569b..3100d1d6 100644 --- a/src/components/call-controls.ts +++ b/src/components/call-controls.ts @@ -14,6 +14,7 @@ import { ActionConfig } from '../config/schema/actions/types.js'; import { localize } from '../localize/localize.js'; import callControlsStyle from '../scss/call-controls.scss'; import { + createCallAnswerAction, createCallEndAction, createGeneralAction, stopEventFromActivatingCardWideActions, @@ -22,19 +23,27 @@ import { hasPopOutAnimationEnded } from '../utils/animation.js'; import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js'; /** - * The on-screen overlay shown during an active two-way audio call: a centered - * pill with end-call, microphone-toggle, and mute-toggle buttons. + * The on-screen overlay shown during a two-way audio call: a centered pill + * whose contents depend on call state. Pre-answer (inbound ringing) shows + * reject + answer; post-answer (or outbound) shows end-call + microphone + * toggle + audio-out toggle. * * This is a purely presentational control showing state and emitting intents. - * The end-call and microphone buttons dispatch actions; the audio-out button - * fires an `advanced-camera-card:call:mute-toggle` event for the host to act on. + * Button taps dispatch actions; the audio-out button fires an + * `advanced-camera-card:call:mute-toggle` event for the host to act on. */ @customElement('advanced-camera-card-call-controls') export class AdvancedCameraCardCallControls extends LitElement { - // Whether a call is in progress. + // Whether a call exists on this carousel's camera, in either the unanswered + // or answered state. Drives whether the overlay renders at all. @property({ attribute: false }) public active = false; + // Whether that call has been answered. Selects between the pre-answer (reject + // + answer) and post-answer (end + mic + audio) button sets. + @property({ attribute: false }) + public answered = true; + @property({ attribute: false }) public microphoneState?: MicrophoneState; @@ -49,6 +58,13 @@ export class AdvancedCameraCardCallControls extends LitElement { @state() private _exiting = false; + // Tracks which controls are rendered. Synced from `answered` only while + // the controls are actually showing (`active`), so it keeps its last value + // through the exit animation (as the parent's `answered` prop may otherwise + // change mid-exit when the call session disappears). + @state() + private _type: 'answered' | 'unanswered' = 'answered'; + public connectedCallback(): void { super.connectedCallback(); window.addEventListener('keydown', this._handleKeyDown); @@ -72,6 +88,14 @@ export class AdvancedCameraCardCallControls extends LitElement { // call (re)starting cancels any in-progress exit. this._exiting = !this.active && !!changedProps.get('active'); } + + // Only mirror `answered` while the pill is actually showing: this leaves + // `_type` frozen through the exit animation, so the outgoing pill keeps the + // same button set it had pre-exit even if the parent's `answered` prop + // changes after the call session disappears. + if (this.active) { + this._type = this.answered ? 'answered' : 'unanswered'; + } } protected render(): TemplateResult | void { @@ -79,21 +103,50 @@ export class AdvancedCameraCardCallControls extends LitElement { return; } - const microphoneMuted = this.microphoneState?.muted ?? true; - const audioAvailable = this.muted !== undefined; - const audioMuted = this.muted ?? true; - return html`
stopEventFromActivatingCardWideActions(ev)} @animationend=${this._handleAnimationEnd} > + ${this._type === 'answered' + ? this._renderPostAnswerButtons() + : this._renderPreAnswerButtons()} +
+
`; + } + + private _renderPreAnswerButtons(): TemplateResult { + return html` +
+ ${this._renderButton( + 'mdi:phone-hangup', + localize('config.live.controls.call.reject'), + { + emphasis: 'negative', + action: createCallEndAction(), + }, + )} + ${this._renderButton('mdi:phone', localize('config.live.controls.call.answer'), { + emphasis: 'positive', + action: createCallAnswerAction(), + })} +
+ `; + } + + private _renderPostAnswerButtons(): TemplateResult { + const microphoneMuted = this.microphoneState?.muted ?? true; + const audioAvailable = this.muted !== undefined; + const audioMuted = this.muted ?? true; + + return html` +
${this._renderButton( 'mdi:phone-hangup', localize('config.live.controls.call.end'), { - emphasis: 'critical', + emphasis: 'negative', action: createCallEndAction(), }, )} @@ -103,7 +156,7 @@ export class AdvancedCameraCardCallControls extends LitElement { ? localize('config.live.controls.call.unmute_microphone') : localize('config.live.controls.call.mute_microphone'), { - emphasis: microphoneMuted ? undefined : 'critical', + emphasis: microphoneMuted ? undefined : 'negative', action: createGeneralAction( microphoneMuted ? 'microphone_unmute' : 'microphone_mute', ), @@ -120,7 +173,7 @@ export class AdvancedCameraCardCallControls extends LitElement { }, )}
- `; + `; } private _handleKeyDown = (ev: KeyboardEvent): void => { @@ -142,7 +195,7 @@ export class AdvancedCameraCardCallControls extends LitElement { label: string, options?: { disabled?: boolean; - emphasis?: 'critical'; + emphasis?: 'negative' | 'positive'; action?: ActionConfig; handler?: () => void; }, @@ -152,7 +205,7 @@ export class AdvancedCameraCardCallControls extends LitElement { .label=${label} title=${label} ?disabled=${!!options?.disabled} - class=${options?.emphasis === 'critical' ? 'critical' : ''} + class=${options?.emphasis ?? ''} @click=${() => { if (options?.handler) { options.handler(); diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index 83a860de..aed4c636 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -194,11 +194,13 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { changedProps.has('viewManagerEpoch') || changedProps.has('viewFilterCameraID') ) { - // Scope the call-active signal to the carousel that owns the call: in + // Scope the call-answered signal to the carousel that owns the call: in // grid mode every carousel receives `.call`, but only the call camera's - // audio should be acted on. - this._mediaActionsController.setCallActive( - this.call?.cameraID === this._getCarouselCameraID(), + // audio should be acted on. Gating on `answered` (not mere presence) + // keeps `live.auto_unmute: ['call']` from unmuting the camera's audio + // during the pre-answer ringing state. + this._mediaActionsController.setCallAnswered( + this.call?.cameraID === this._getCarouselCameraID() && !!this.call?.answered, ); } } @@ -294,17 +296,20 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { return view?.context?.live?.overrides?.get(cameraID) ?? cameraID; } - // Return a microphone stream only for the camera the call runs on, and - // only while that camera's engaged stream is still the call's audio source. - // Keying off the call session (not the selected slide) keeps the microphone - // routed to the call's camera, and stops transmission if the substream has - // since changed. + // Return a microphone stream only for the camera the call runs on, only + // while the call has been answered, and only while that camera's engaged + // stream is still the call's audio source. The `answered` gate is a + // privacy guarantee: an inbound call that's still ringing must not + // transmit audio even if the mic happens to be un-muted (e.g. left open + // by `auto_unmute: ['selected']` or a prior call). The substream gate + // stops transmission if the substream has since changed. private _getRelevantMicrophoneStream( cameraID: string, view?: View | null, ): MediaStream | null { const isRelevant = - this.call?.cameraID === cameraID && + !!this.call?.answered && + this.call.cameraID === cameraID && this._getSubstreamCameraID(cameraID, view) === (this.call.callCameraID ?? cameraID); return isRelevant ? this.microphoneState?.stream ?? null : null; @@ -458,6 +463,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { diff --git a/src/components/live/index.ts b/src/components/live/index.ts index cd4261e2..15037a86 100644 --- a/src/components/live/index.ts +++ b/src/components/live/index.ts @@ -86,7 +86,11 @@ export class AdvancedCameraCardLive extends LitElement { ); } if (changedProps.has('call')) { - this._microphoneActionsController.setCallActive(!!this.call); + // Gate on `answered`, not mere presence, so `microphone.auto_unmute: + // ['call']` doesn't transmit audio during the pre-answer ringing state. + // Outbound calls are answered at construction; inbound calls only after + // the user accepts. + this._microphoneActionsController.setCallAnswered(!!this.call?.answered); } } diff --git a/src/config/schema/actions/custom/call-answer.ts b/src/config/schema/actions/custom/call-answer.ts new file mode 100644 index 00000000..07fb7ae5 --- /dev/null +++ b/src/config/schema/actions/custom/call-answer.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { advancedCameraCardCustomActionsBaseSchema } from './base'; + +export const callAnswerActionConfigSchema = + advancedCameraCardCustomActionsBaseSchema.extend({ + advanced_camera_card_action: z.literal('call_answer'), + }); +export type CallAnswerActionConfig = z.infer; diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index f32eec3a..d66149f6 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -3,6 +3,7 @@ import { linkSchema } from '../common/link'; import { severitySchema } from '../common/severity'; import { statusBarItemBaseSchema } from '../common/status-bar'; import { advancedCameraCardCustomActionsBaseSchema } from './custom/base'; +import { callAnswerActionConfigSchema } from './custom/call-answer'; import { callEndActionConfigSchema } from './custom/call-end'; import { callStartActionConfigSchema } from './custom/call-start'; import { cameraSelectActionConfigSchema } from './custom/camera-select'; @@ -61,6 +62,7 @@ export const statusBarActionConfigSchema: z.ZodSchema = }); const advancedCameraCardCustomActionSchema = z.union([ + callAnswerActionConfigSchema, callEndActionConfigSchema, callStartActionConfigSchema, cameraSelectActionConfigSchema, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 5a6e2809..2fb326f0 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -316,7 +316,7 @@ }, "media_action_conditions": { "call_mute": "On call end", - "call_unmute": "On call start", + "call_unmute": "On call answer", "hidden": "On browser/tab hiding", "microphone_mute": "On microphone mute", "microphone_unmute": "On microphone unmute", @@ -395,12 +395,14 @@ "auto_unmute": "Automatically unmute live cameras", "controls": { "call": { + "answer": "Answer call", "button_size": "Call control button size", "editor_label": "Two-way audio call", "end": "End 2-way audio call", "lock": "Lock UI during an active call", "mute_audio": "Mute audio", "mute_microphone": "Mute microphone", + "reject": "Reject call", "ringtone": { "repeat": "Ringtone repeats per inbound call (0=indefinite)", "type": "Ringtone for inbound calls", diff --git a/src/scss/call-controls.scss b/src/scss/call-controls.scss index 0dd0397e..65545d4a 100644 --- a/src/scss/call-controls.scss +++ b/src/scss/call-controls.scss @@ -45,12 +45,28 @@ pointer-events: none; } +// Wrapper around each button group. The pre/post-answer ternary swaps which +// `.buttons` element is in the DOM, so the pop-animation swap fires when the +// user accepts (or, more generally, whenever the answered state flips +// mid-call). +.buttons { + display: flex; + align-items: center; + gap: 4px; + + @include pop-swap; +} + ha-icon-button { color: var(--advanced-camera-card-button-color); background: var(--advanced-camera-card-button-background); border-radius: var(--advanced-camera-card-button-border-radius); - &.critical { - color: var(--advanced-camera-card-call-controls-critical-color); + &.negative { + color: var(--advanced-camera-card-call-controls-negative-color); + } + + &.positive { + color: var(--advanced-camera-card-call-controls-positive-color); } } diff --git a/src/scss/pop-animation.scss b/src/scss/pop-animation.scss index e4db9395..fff1c6cb 100644 --- a/src/scss/pop-animation.scss +++ b/src/scss/pop-animation.scss @@ -13,6 +13,14 @@ animation: pop-out 0.25s ease-out forwards; } +// Lighter "swap" pop: a subtle in-place fade/scale used when an overlay's +// contents change without the overlay itself entering or leaving the DOM. +// Quieter than `pop-in` so it can sit inside an already-popped panel without +// fighting the parent animation. +@mixin pop-swap { + animation: pop-swap 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.5) forwards; +} + @keyframes pop-in { 0% { opacity: 0; @@ -44,3 +52,18 @@ transform: translateY(20px) scale(0.95); } } + +@keyframes pop-swap { + 0% { + opacity: 0; + transform: scale(0.4); + } + 60% { + opacity: 1; + transform: scale(1.15); + } + 100% { + opacity: 1; + transform: scale(1); + } +} diff --git a/src/scss/themes/base.scss b/src/scss/themes/base.scss index 41c27520..8921a9b9 100644 --- a/src/scss/themes/base.scss +++ b/src/scss/themes/base.scss @@ -14,6 +14,7 @@ --advanced-camera-card-active-color: var(--accent-color, orange); --advanced-camera-card-warning-color: var(--error-color, red); + --advanced-camera-card-success-color: var(--success-color, #43a047); --advanced-camera-card-text-color: var(--primary-text-color); --advanced-camera-card-divider-color: var(--divider-color); @@ -74,11 +75,16 @@ --advanced-camera-card-control-background-transparent ); - // The color of the call controls end-call (critical) button. - --advanced-camera-card-call-controls-critical-color: var( + // The color of the call controls negative button (end / reject). + --advanced-camera-card-call-controls-negative-color: var( --advanced-camera-card-warning-color ); + // The color of the call controls positive button (answer). + --advanced-camera-card-call-controls-positive-color: var( + --advanced-camera-card-success-color + ); + /****** * Menu ******/ diff --git a/src/utils/action.ts b/src/utils/action.ts index 44257aae..b83fefd8 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -1,5 +1,6 @@ import { CardActionsAPI } from '../card-controller/types.js'; import { ZoomSettingsBase } from '../components-lib/zoom/types.js'; +import { CallAnswerActionConfig } from '../config/schema/actions/custom/call-answer.js'; import { CallEndActionConfig } from '../config/schema/actions/custom/call-end.js'; import { CallStartActionConfig } from '../config/schema/actions/custom/call-start.js'; import { CameraSelectActionConfig } from '../config/schema/actions/custom/camera-select.js'; @@ -314,6 +315,16 @@ export function createCallStartAction(options?: { }; } +export function createCallAnswerAction(options?: { + cardID?: string; +}): CallAnswerActionConfig { + return { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_answer', + ...(options?.cardID && { card_id: options.cardID }), + }; +} + export function createCallEndAction(options?: { cardID?: string }): CallEndActionConfig { return { action: 'fire-dom-event', diff --git a/tests/card-controller/actions/actions/call-answer.test.ts b/tests/card-controller/actions/actions/call-answer.test.ts new file mode 100644 index 00000000..4bbaac80 --- /dev/null +++ b/tests/card-controller/actions/actions/call-answer.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { CallAnswerAction } from '../../../../src/card-controller/actions/actions/call-answer'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle call_answer action', async () => { + const api = createCardAPI(); + const action = new CallAnswerAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_answer', + }, + ); + + await action.execute(api); + + expect(api.getCallManager().answer).toBeCalled(); +}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index b7e687f8..cd2743b6 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { CallAnswerAction } from '../../../src/card-controller/actions/actions/call-answer'; import { CallEndAction } from '../../../src/card-controller/actions/actions/call-end'; import { CallServiceAction } from '../../../src/card-controller/actions/actions/call-service'; import { CallStartAction } from '../../../src/card-controller/actions/actions/call-start'; @@ -88,6 +89,7 @@ describe('ActionFactory', () => { describe('custom actions', () => { it.each([ + [{ advanced_camera_card_action: 'call_answer' as const }, CallAnswerAction], [{ advanced_camera_card_action: 'call_end' as const }, CallEndAction], [{ advanced_camera_card_action: 'call_start' as const }, CallStartAction], [{ advanced_camera_card_action: 'camera_select' as const }, CameraSelectAction], diff --git a/tests/card-controller/call/manager.test.ts b/tests/card-controller/call/manager.test.ts index 45c4ce52..2477c0b4 100644 --- a/tests/card-controller/call/manager.test.ts +++ b/tests/card-controller/call/manager.test.ts @@ -104,12 +104,12 @@ describe('isActive', () => { expect(manager.isActive()).toBe(true); // The call runs on the parent camera's own stream, so callCameraID is - // absent. + // absent. Outbound calls are answered by construction. expect(manager.getCall()).toEqual({ cameraID: 'camera.office', previousView: expect.any(View), inbound: false, - answered: false, + answered: true, }); expect(manager.getCall()?.previousView?.view).toBe('live'); }); @@ -569,20 +569,7 @@ describe('inbound supersede policy', () => { manager.initialize(); expect(await manager.start({ inbound: true })).toBe(true); - // Answer the call. - getConditionStateListener(api)({ - old: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: true, forbidden: false }, - }, - change: { microphone: { connected: true, muted: false, forbidden: false } }, - new: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: false, forbidden: false }, - }, - }); + expect(manager.answer()).toBe(true); expect(manager.getCall()?.answered).toBe(true); expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe( @@ -637,19 +624,7 @@ describe('inbound supersede policy', () => { const manager = new CallManager(api); manager.initialize(); expect(await manager.start({ inbound: true })).toBe(true); - getConditionStateListener(api)({ - old: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: true, forbidden: false }, - }, - change: { microphone: { connected: true, muted: false, forbidden: false } }, - new: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: false, forbidden: false }, - }, - }); + expect(manager.answer()).toBe(true); expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true); @@ -862,19 +837,7 @@ describe('endIf', () => { const manager = new CallManager(api); manager.initialize(); expect(await manager.start({ inbound: true })).toBe(true); - getConditionStateListener(api)({ - old: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: true, forbidden: false }, - }, - change: { microphone: { connected: true, muted: false, forbidden: false } }, - new: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: false, forbidden: false }, - }, - }); + expect(manager.answer()).toBe(true); expect(manager.getCall()?.answered).toBe(true); expect(manager.endIf({ answered: false })).toBe(false); @@ -1242,15 +1205,12 @@ describe('inbound option', () => { }); }); -// Answered tracking: the first muted->unmuted microphone transition during an -// inbound call flips `answered` to true (once; later mute/unmute cycles do not -// flip it back) and stops the ringtone / cancels the unanswered timer. -describe('answered tracking', () => { +describe('answer', () => { const inboundConfig = { live: { controls: { call: { ringtone: { type: 'chime' as const } } } }, }; - it('should not be answered immediately after an inbound start', async () => { + it('should default to unanswered for an inbound start', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }), config: inboundConfig, @@ -1263,7 +1223,40 @@ describe('answered tracking', () => { expect(manager.getCall()?.answered).toBe(false); }); - it('should mark answered on a muted->unmuted microphone transition', async () => { + it('should default to answered for an outbound start', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + + expect(await manager.start()).toBe(true); + + // Outbound calls are answered by construction -- the user initiated them. + expect(manager.getCall()?.answered).toBe(true); + }); + + it('should default to unanswered for inbound even if the mic is already un-muted', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneMuted: false, + config: inboundConfig, + }); + const manager = new CallManager(api); + manager.initialize(); + + expect(await manager.start({ inbound: true })).toBe(true); + + expect(manager.getCall()?.answered).toBe(false); + expect(getRingtone().start).toBeCalled(); + }); + + it('should no-op when no call is active', () => { + const api = createAPI(); + const manager = new CallManager(api); + + expect(manager.answer()).toBe(false); + }); + + it('should no-op when the call is already answered', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }), config: inboundConfig, @@ -1271,25 +1264,100 @@ describe('answered tracking', () => { const manager = new CallManager(api); manager.initialize(); expect(await manager.start({ inbound: true })).toBe(true); + expect(manager.answer()).toBe(true); + vi.mocked(getRingtone().stop).mockClear(); + vi.mocked(api.getCardElementManager().update).mockClear(); - getConditionStateListener(api)({ - old: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: true, forbidden: false }, - }, - change: { microphone: { connected: true, muted: false, forbidden: false } }, - new: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: false, forbidden: false }, - }, + expect(manager.answer()).toBe(false); + + expect(getRingtone().stop).not.toBeCalled(); + expect(api.getCardElementManager().update).not.toBeCalled(); + }); + + it('should mark answered and replace the session immutably', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig, }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + const before = manager.getCall(); + expect(manager.answer()).toBe(true); + + const after = manager.getCall(); + expect(after?.answered).toBe(true); + // New object identity so Lit consumers re-render on the prop change. + expect(after).not.toBe(before); + expect(after?.cameraID).toBe(before?.cameraID); + expect(after?.inbound).toBe(before?.inbound); + }); + + it('should stop the ringtone and unanswered timer on answer', async () => { + vi.useFakeTimers(); + try { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: { + live: { + controls: { + call: { + ringtone: { type: 'chime' as const }, + unanswered_timeout_seconds: 60, + }, + }, + }, + }, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + vi.mocked(getRingtone().stop).mockClear(); + + expect(manager.answer()).toBe(true); + + expect(getRingtone().stop).toBeCalled(); + + // Timer was armed and should now be cancelled: advancing past the + // timeout must not end the (now-answered) call. + vi.advanceTimersByTime(60_000); + expect(manager.isActive()).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('should force a card re-render on answer', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + vi.mocked(api.getCardElementManager().update).mockClear(); + + expect(manager.answer()).toBe(true); + + // The card subtree depends on `getCall().answered`, which the manager + // mutates outside the view-manager epoch -- so `update()` is what drives + // the re-render through to the call-controls overlay. + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should not mark non-inbound (outbound) calls via answer (already answered)', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start()).toBe(true); + + // Outbound starts answered, so `answer()` is a no-op. + expect(manager.answer()).toBe(false); expect(manager.getCall()?.answered).toBe(true); }); - it('should not flip answered back when the user re-mutes after answering', async () => { + it('should not flip answered on mic mute/unmute transitions', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }), config: inboundConfig, @@ -1299,6 +1367,7 @@ describe('answered tracking', () => { expect(await manager.start({ inbound: true })).toBe(true); const listener = getConditionStateListener(api); + // Mic mute -> unmute during the pre-answer ring: must NOT auto-answer. listener({ old: { camera: 'camera.office', @@ -1312,124 +1381,7 @@ describe('answered tracking', () => { microphone: { connected: true, muted: false, forbidden: false }, }, }); - expect(manager.getCall()?.answered).toBe(true); - listener({ - old: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: false, forbidden: false }, - }, - change: { microphone: { connected: true, muted: true, forbidden: false } }, - new: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: true, forbidden: false }, - }, - }); - - expect(manager.getCall()?.answered).toBe(true); - }); - - it('should not mark non-inbound calls answered on un-mute', async () => { - const api = createAPI({ view: createView({ camera: 'camera.office' }) }); - const manager = new CallManager(api); - manager.initialize(); - expect(await manager.start()).toBe(true); - - getConditionStateListener(api)({ - old: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: true, forbidden: false }, - }, - change: { microphone: { connected: true, muted: false, forbidden: false } }, - new: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: false, forbidden: false }, - }, - }); - - expect(manager.getCall()?.answered).toBe(false); - }); - - it('should stop the ringtone on answer', async () => { - const api = createAPI({ - view: createView({ camera: 'camera.office' }), - config: inboundConfig, - }); - const manager = new CallManager(api); - manager.initialize(); - expect(await manager.start({ inbound: true })).toBe(true); - vi.mocked(getRingtone().stop).mockClear(); - - getConditionStateListener(api)({ - old: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: true, forbidden: false }, - }, - change: { microphone: { connected: true, muted: false, forbidden: false } }, - new: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: false, forbidden: false }, - }, - }); - - expect(getRingtone().stop).toBeCalled(); - }); - - it('should treat an inbound call as already-answered when the mic is already un-muted', async () => { - const api = createAPI({ - view: createView({ camera: 'camera.office' }), - microphoneMuted: false, - config: inboundConfig, - }); - const manager = new CallManager(api); - manager.initialize(); - - expect(await manager.start({ inbound: true })).toBe(true); - - expect(manager.getCall()?.answered).toBe(true); - expect(getRingtone().start).not.toBeCalled(); - }); - - it('should not arm the unanswered timer when the mic is already un-muted', async () => { - vi.useFakeTimers(); - try { - const api = createAPI({ - view: createView({ camera: 'camera.office' }), - microphoneMuted: false, - config: { - live: { controls: { call: { unanswered_timeout_seconds: 60 } } }, - }, - }); - const manager = new CallManager(api); - manager.initialize(); - expect(await manager.start({ inbound: true })).toBe(true); - - vi.advanceTimersByTime(60_000); - - expect(manager.isActive()).toBe(true); - } finally { - vi.useRealTimers(); - } - }); - - it('should not mark a manual (non-inbound) call as answered even if the mic is un-muted', async () => { - const api = createAPI({ - view: createView({ camera: 'camera.office' }), - microphoneMuted: false, - }); - const manager = new CallManager(api); - manager.initialize(); - - expect(await manager.start()).toBe(true); - - // `answered` only carries meaning alongside `inbound`, so for manual - // calls it stays at its default to make the intent explicit. expect(manager.getCall()?.answered).toBe(false); }); }); @@ -1572,19 +1524,7 @@ describe('unanswered timeout', () => { manager.initialize(); expect(await manager.start({ inbound: true })).toBe(true); - getConditionStateListener(api)({ - old: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: true, forbidden: false }, - }, - change: { microphone: { connected: true, muted: false, forbidden: false } }, - new: { - camera: 'camera.office', - view: 'live', - microphone: { connected: true, muted: false, forbidden: false }, - }, - }); + expect(manager.answer()).toBe(true); vi.advanceTimersByTime(60_000); expect(manager.isActive()).toBe(true); diff --git a/tests/components-lib/live/microphone-actions-controller.test.ts b/tests/components-lib/live/microphone-actions-controller.test.ts index 9699472d..27593368 100644 --- a/tests/components-lib/live/microphone-actions-controller.test.ts +++ b/tests/components-lib/live/microphone-actions-controller.test.ts @@ -294,8 +294,8 @@ describe('MicrophoneActionsController', () => { }); }); - describe('on call state change', () => { - it('should unmute on call start when call is a configured unmute condition', () => { + describe('on call answered state change', () => { + it('should unmute on call answer when call is a configured unmute condition', () => { const microphoneManager = createMicrophoneManager(); const controller = new MicrophoneActionsController(); controller.setOptions({ @@ -303,13 +303,13 @@ describe('MicrophoneActionsController', () => { autoUnmuteConditions: ['call' as const], }); - controller.setCallActive(false); - controller.setCallActive(true); + controller.setCallAnswered(false); + controller.setCallAnswered(true); expect(microphoneManager.unmute).toBeCalledTimes(1); }); - it('should unmute when the call is already active on first notification', () => { + it('should unmute when the call is already answered on first notification', () => { const microphoneManager = createMicrophoneManager(); const controller = new MicrophoneActionsController(); controller.setOptions({ @@ -317,10 +317,10 @@ describe('MicrophoneActionsController', () => { autoUnmuteConditions: ['call' as const], }); - // `setCallActive(true)` is the first call-state signal, with no preceding - // `false` -- as for a live view that mounts while a call is already - // active. The initial state must not be swallowed as a baseline. - controller.setCallActive(true); + // `setCallAnswered(true)` is the first call-state signal, with no + // preceding `false` -- as for a live view that mounts while a call is + // already answered. The initial state must not be swallowed as a baseline. + controller.setCallAnswered(true); expect(microphoneManager.unmute).toBeCalledTimes(1); }); @@ -333,8 +333,8 @@ describe('MicrophoneActionsController', () => { autoMuteConditions: ['call' as const], }); - controller.setCallActive(true); - controller.setCallActive(false); + controller.setCallAnswered(true); + controller.setCallAnswered(false); expect(microphoneManager.mute).toBeCalledTimes(1); }); @@ -348,13 +348,13 @@ describe('MicrophoneActionsController', () => { autoUnmuteConditions: ['call' as const], }); - controller.setCallActive(false); + controller.setCallAnswered(false); expect(microphoneManager.mute).not.toBeCalled(); expect(microphoneManager.unmute).not.toBeCalled(); }); - it('should not act on call start when call is not a configured condition', () => { + it('should not act on call answer when call is not a configured condition', () => { const microphoneManager = createMicrophoneManager(); const controller = new MicrophoneActionsController(); controller.setOptions({ @@ -362,8 +362,8 @@ describe('MicrophoneActionsController', () => { autoUnmuteConditions: [], }); - controller.setCallActive(false); - controller.setCallActive(true); + controller.setCallAnswered(false); + controller.setCallAnswered(true); expect(microphoneManager.unmute).not.toBeCalled(); }); diff --git a/tests/components-lib/media-actions-controller.test.ts b/tests/components-lib/media-actions-controller.test.ts index f8866470..91fd2303 100644 --- a/tests/components-lib/media-actions-controller.test.ts +++ b/tests/components-lib/media-actions-controller.test.ts @@ -712,22 +712,22 @@ describe('MediaActionsController', () => { }); }); - describe('should take action on call state changes', () => { - it('should unmute the target on call start', async () => { + describe('should take action on call answered state changes', () => { + it('should unmute the target on call answer', async () => { const controller = new MediaActionsController(); controller.setOptions({ autoUnmuteConditions: ['call' as const], playerSelector: 'video', }); - controller.setCallActive(false); + controller.setCallAnswered(false); const children = createPlayerSlideNodes(); controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - controller.setCallActive(true); + controller.setCallAnswered(true); expect( (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, @@ -741,14 +741,14 @@ describe('MediaActionsController', () => { autoMuteConditions: ['call' as const], playerSelector: 'video', }); - controller.setCallActive(true); + controller.setCallAnswered(true); const children = createPlayerSlideNodes(); controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - controller.setCallActive(false); + controller.setCallAnswered(false); expect( (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute, @@ -768,7 +768,7 @@ describe('MediaActionsController', () => { controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - controller.setCallActive(false); + controller.setCallAnswered(false); expect( (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute, @@ -782,20 +782,20 @@ describe('MediaActionsController', () => { autoUnmuteConditions: [], playerSelector: 'video', }); - controller.setCallActive(false); + controller.setCallAnswered(false); const children = createPlayerSlideNodes(); controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - controller.setCallActive(true); + controller.setCallAnswered(true); expect( (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, ).not.toBeCalled(); }); - it('should apply the call-start unmute when the target arrives after the call', async () => { + it('should apply the call-answer unmute when the target arrives after the call', async () => { const controller = new MediaActionsController(); controller.setOptions({ @@ -806,9 +806,9 @@ describe('MediaActionsController', () => { const children = createPlayerSlideNodes(); controller.setRoot(createParent({ children: children })); - // The call becomes active before any target is selected: with no - // target, the unmute cannot be applied yet. - controller.setCallActive(true); + // The call is answered before any target is selected: with no target, + // the unmute cannot be applied yet. + controller.setCallAnswered(true); await flushPromises(); expect( (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, @@ -821,7 +821,7 @@ describe('MediaActionsController', () => { ).toBeCalled(); }); - it('should unmute when the call is already active on the first call-state signal', async () => { + it('should unmute when the call is already answered on the first call-state signal', async () => { const controller = new MediaActionsController(); controller.setOptions({ @@ -833,24 +833,25 @@ describe('MediaActionsController', () => { controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - // `setCallActive(true)` is the first call-state signal, with no preceding - // `false` -- as for a carousel that loads while a call is already - // active. The initial state must not be swallowed as a baseline. - controller.setCallActive(true); + // `setCallAnswered(true)` is the first call-state signal, with no + // preceding `false` -- as for a carousel that loads while an answered + // call is already active. The initial state must not be swallowed as + // a baseline. + controller.setCallAnswered(true); expect( (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, ).toBeCalled(); }); - it('should defer the call-start unmute until the media player is ready', async () => { + it('should defer the call-answer unmute until the media player is ready', async () => { const controller = new MediaActionsController(); controller.setOptions({ autoUnmuteConditions: ['call' as const], playerSelector: 'video', }); - controller.setCallActive(false); + controller.setCallAnswered(false); // A player whose media player controller is not ready on first request. const mediaPlayerController = mock(); @@ -865,8 +866,8 @@ describe('MediaActionsController', () => { controller.setRoot(createParent({ children: [child] })); await controller.setTarget(0, true); - // The call starts while the player is still not ready: no unmute yet. - controller.setCallActive(true); + // The call is answered while the player is still not ready: no unmute yet. + controller.setCallAnswered(true); await flushPromises(); expect(mediaPlayerController.unmute).not.toBeCalled(); diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 7390673e..af621e04 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -3,6 +3,7 @@ import { mock } from 'vitest-mock-extended'; import { INTERNAL_CALLBACK_ACTION } from '../../src/config/schema/actions/custom/internal.js'; import { ActionConfig } from '../../src/config/schema/actions/types.js'; import { + createCallAnswerAction, createCallEndAction, createCallStartAction, createCameraAction, @@ -430,6 +431,23 @@ describe('createSubstreamOffAction', () => { }); }); +describe('createCallAnswerAction', () => { + it('should create call answer action', () => { + expect(createCallAnswerAction()).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'call_answer', + }); + }); + + it('should create call answer action with a cardID', () => { + expect(createCallAnswerAction({ cardID: 'card_id' })).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'call_answer', + card_id: 'card_id', + }); + }); +}); + describe('createCallEndAction', () => { it('should create call end action', () => { expect(createCallEndAction()).toEqual({