From 0f8593f9fad6dd378032d6672693147003f22f7d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 26 Jul 2026 14:24:14 -0700 Subject: [PATCH] feat: Answer or reject inbound calls from menu (#2620) - For: #2587 --- docs/configuration/menu.md | 2 +- docs/examples.md | 25 ++------- docs/usage/2-way-audio.md | 6 ++ src/components-lib/menu-button-controller.ts | 44 +++++++++++---- src/scss/menu.scss | 16 ++++++ src/scss/themes/base.scss | 5 ++ .../menu-button-controller.test.ts | 56 ++++++++++++++++++- 7 files changed, 120 insertions(+), 34 deletions(-) diff --git a/docs/configuration/menu.md b/docs/configuration/menu.md index c6ca451a..efb4bd9a 100644 --- a/docs/configuration/menu.md +++ b/docs/configuration/menu.md @@ -29,7 +29,7 @@ menu: | Button name | Description | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `call` | The `call` menu button: starts or ends a [two-way audio](../usage/2-way-audio.md) call. | +| `call` | The `call` menu button: starts a [two-way audio](../usage/2-way-audio.md) call, answers an inbound (ringing) call (hold to reject it instead), and ends an active call. | | `camera_ui` | The `camera_ui` menu button: brings the user to a context-appropriate page on the UI of their camera engine (e.g. the Frigate camera homepage). Will only appear if the camera engine supports a camera UI (e.g. if `frigate.url` option is set for `frigate` engine users). | | `cameras` | The camera selection submenu. Will only appear if multiple cameras are configured. | | `clips` | The `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. | diff --git a/docs/examples.md b/docs/examples.md index d7bc6aa6..676239e7 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -570,10 +570,10 @@ By default on-screen controls will appear mid-card to handle a call. Setting controls so the call can be driven via some other mechanism. In this example, the card is configured to allow calls to be driven from the menu instead. -This wires up the menu equivalents of every overlay control. The menu `call` -button starts, ends, and (while ringing) rejects calls, but it cannot _answer_ -an inbound ring -- so a conditional answer button is added that appears only -while ringing. +The single menu `call` button handles the whole call: it starts a call, answers +an inbound ring (hold to reject it instead), and hangs up. It is shown by +default, so only the microphone and audio buttons need enabling below to cover +the full functionality of the (in this example: disabled) call controls. ```yaml type: custom:advanced-camera-card @@ -595,9 +595,6 @@ menu: # controls stay reachable. auto_hide: [] buttons: - # Starts a call, and becomes a hang-up button for the duration of a call. - call: - enabled: true # Mutes/unmutes your outbound microphone during a call. microphone: enabled: true @@ -605,20 +602,6 @@ menu: # Mutes/unmutes the inbound (caller's) audio during a call. mute: enabled: true -elements: - # The menu `call` button cannot answer an inbound (ringing) call, so this - # answer button is shown only while ringing to provide that control. - - type: custom:advanced-camera-card-conditional - conditions: - - condition: call - call: ringing - elements: - - type: custom:advanced-camera-card-menu-icon - icon: mdi:phone - title: Answer call - tap_action: - action: custom:advanced-camera-card-action - advanced_camera_card_action: call_answer ``` ## Events from other cameras diff --git a/docs/usage/2-way-audio.md b/docs/usage/2-way-audio.md index 8d14ddbc..057c0fdb 100644 --- a/docs/usage/2-way-audio.md +++ b/docs/usage/2-way-audio.md @@ -53,6 +53,12 @@ enabled by default and appears in the `live` view whenever the selected camera [`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**. +- The **call** menu button itself tracks the call state: tap it to start or + answer a call and to hang up an active one, and while an inbound call is + ringing **hold** it to reject. This lets you drive the whole call from the + menu when the standard call controls are hidden with + [`live.controls.call.enabled: false`](../configuration/live.md?id=call) -- see + [Driving calls from the menu](../examples.md?id=driving-calls-from-the-menu). - 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 diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index b8304e0a..a39c7ad2 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -19,6 +19,7 @@ import type { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize.js'; import type { MediaLoadedInfo } from '../types'; import { + createCallAnswerAction, createCallEndAction, createCallStartAction, createCameraAction, @@ -507,15 +508,26 @@ export class MenuButtonController { return null; } - // In a call: a single hang-up button, regardless of target count. - if (callManager?.isActive()) { + // In a call, a single button regardless of target count. An unanswered + // inbound call (ringing) answers; an answered or outbound call hangs up. + const call = callManager?.getCall(); + if (call) { + const ringing = call.inbound && !call.answered; return { - icon: 'mdi:phone-hangup', - title: localize('config.live.controls.call.end'), - style: this._getEmphasizedStyle(true), + icon: ringing ? 'mdi:phone-ring' : 'mdi:phone-hangup', + title: ringing + ? localize('config.live.controls.call.answer') + : localize('config.live.controls.call.end'), + style: ringing + ? this._getPulsingStyle( + 'var(--advanced-camera-card-menu-button-positive-color)', + ) + : this._getEmphasizedStyle(true), ...config.menu.buttons.call, type: 'custom:advanced-camera-card-menu-icon', - tap_action: createCallEndAction(), + tap_action: ringing ? createCallAnswerAction() : createCallEndAction(), + // While ringing, tap answers and hold rejects. + ...(ringing && { hold_action: createCallEndAction() }), }; } @@ -915,16 +927,28 @@ export class MenuButtonController { */ private _getEmphasizedStyle(critical?: boolean): StyleInfo { if (critical) { - return { - animation: 'pulse 3s infinite', - color: 'var(--advanced-camera-card-menu-button-critical-color)', - }; + return this._getPulsingStyle( + 'var(--advanced-camera-card-menu-button-critical-color)', + ); } return { color: 'var(--advanced-camera-card-menu-button-active-color)', }; } + /** + * Get a pulsing style in the given color, e.g. to draw attention to a + * critical or a ringing button. + * @param color The CSS color to pulse. + * @returns A StyleInfo. + */ + private _getPulsingStyle(color: string): StyleInfo { + return { + animation: 'pulse 3s infinite', + color, + }; + } + /** * Given a button determine if the style should be emphasized by examining all * of the actions sequentially. diff --git a/src/scss/menu.scss b/src/scss/menu.scss index 9c47ed0a..36c93eca 100644 --- a/src/scss/menu.scss +++ b/src/scss/menu.scss @@ -141,3 +141,19 @@ div.opposing { // Further theme related styling is dynamically applied by `menu.ts`, see // `_renderPerInstanceStyle`. + +/***************** + * Pulse animation + *****************/ + +// Referenced by the inline `animation` on emphasized button styles in +// `menu-button-controller` (e.g. the ringing call button). +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/src/scss/themes/base.scss b/src/scss/themes/base.scss index 302f5d38..8c3850b4 100644 --- a/src/scss/themes/base.scss +++ b/src/scss/themes/base.scss @@ -105,6 +105,11 @@ var(--advanced-camera-card-warning-color) ); + // The color of a positive icon in the menu (e.g. answer an inbound call). + --advanced-camera-card-menu-button-positive-color: var( + --advanced-camera-card-success-color + ); + // The background color of the button "circles" in the menu. --advanced-camera-card-menu-button-background: color-mix( in oklab, diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 702f5d0c..f82938c1 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -1289,7 +1289,7 @@ describe('MenuButtonController', () => { ); }); - it('when a call is active', () => { + it('when an answered call is active', () => { const cameraManager = createCameraManager( createStore([ { @@ -1299,7 +1299,12 @@ describe('MenuButtonController', () => { ]), ); const callManager = mock(); - vi.mocked(callManager.isActive).mockReturnValue(true); + vi.mocked(callManager.getCall).mockReturnValue({ + cameraID: 'camera-1', + inbound: false, + answered: true, + previousView: createView({ camera: 'camera-1' }), + }); const buttons = calculateButtons(controller, { cameraManager, callManager, @@ -1325,6 +1330,53 @@ describe('MenuButtonController', () => { }, }); }); + + it('when an inbound call is ringing', () => { + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), + }, + ]), + ); + const callManager = mock(); + vi.mocked(callManager.getCall).mockReturnValue({ + cameraID: 'camera-1', + inbound: true, + answered: false, + previousView: createView({ camera: 'camera-1' }), + }); + + const buttons = calculateButtons(controller, { + cameraManager, + callManager, + view: createView({ camera: 'camera-1' }), + }); + + expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, + icon: 'mdi:phone-ring', + enabled: true, + priority: 50, + type: 'custom:advanced-camera-card-menu-icon', + title: 'Answer call', + style: { + animation: 'pulse 3s infinite', + color: 'var(--advanced-camera-card-menu-button-positive-color)', + }, + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_answer', + }, + hold_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_end', + }, + }); + }); }); describe('should have microphone button', () => {