feat: Answer or reject inbound calls from menu (#2620)

- For: #2587
This commit is contained in:
Dermot Duffy
2026-07-26 14:24:14 -07:00
committed by GitHub
parent ababe133a8
commit 0f8593f9fa
7 changed files with 120 additions and 34 deletions
+1 -1
View File
@@ -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. |
+4 -21
View File
@@ -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
+6
View File
@@ -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
+34 -10
View File
@@ -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.
+16
View File
@@ -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;
}
}
+5
View File
@@ -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,
@@ -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<CallManager>();
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<CallManager>();
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', () => {