feat: Explicit answer/reject for inbound calls (#2504)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent b9f09b7c4e
commit f397596ed1
26 changed files with 503 additions and 362 deletions
@@ -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<CallAnswerActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getCallManager().answer();
}
}
+5 -2
View File
@@ -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':
+26 -27
View File
@@ -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 ||
+1
View File
@@ -79,6 +79,7 @@ export interface CardAutomationsAPI {
export interface CardCallAPI {
getCameraManager(): CameraManager;
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getMicrophoneManager(): MicrophoneManager;