feat: Explicit answer/reject for inbound calls (#2504)
This commit is contained in:
committed by
dermotduffy
parent
b9f09b7c4e
commit
f397596ed1
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface CardAutomationsAPI {
|
||||
|
||||
export interface CardCallAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof callAnswerActionConfigSchema>;
|
||||
@@ -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<StatusBarActionConfig> =
|
||||
});
|
||||
|
||||
const advancedCameraCardCustomActionSchema = z.union([
|
||||
callAnswerActionConfigSchema,
|
||||
callEndActionConfigSchema,
|
||||
callStartActionConfigSchema,
|
||||
cameraSelectActionConfigSchema,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
******/
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user