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
+67 -14
View File
@@ -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`<div class="overlay">
<div
class=${classMap({ panel: true, exiting: this._exiting })}
@click=${(ev: Event) => stopEventFromActivatingCardWideActions(ev)}
@animationend=${this._handleAnimationEnd}
>
${this._type === 'answered'
? this._renderPostAnswerButtons()
: this._renderPreAnswerButtons()}
</div>
</div>`;
}
private _renderPreAnswerButtons(): TemplateResult {
return html`
<div class="buttons">
${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(),
})}
</div>
`;
}
private _renderPostAnswerButtons(): TemplateResult {
const microphoneMuted = this.microphoneState?.muted ?? true;
const audioAvailable = this.muted !== undefined;
const audioMuted = this.muted ?? true;
return html`
<div class="buttons">
${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 {
},
)}
</div>
</div>`;
`;
}
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();
+16 -10
View File
@@ -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 {
</advanced-camera-card-ptz>
<advanced-camera-card-call-controls
.active=${isCallActive}
.answered=${this.call?.answered ?? true}
.microphoneState=${this.microphoneState}
.muted=${callMediaPlayerController?.isMuted()}
.buttonSize=${this.liveConfig.controls.call.button_size}
+1 -1
View File
@@ -75,7 +75,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
?triggered=${carouselCameraID &&
!!this.triggeredCameraIDs?.has(carouselCameraID)}
?transmitting=${this.microphoneState?.muted === false &&
!!this.call &&
!!this.call?.answered &&
this.call.cameraID === carouselCameraID}
>
</advanced-camera-card-live-carousel>
+5 -1
View File
@@ -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);
}
}