initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
Full-featured SIP router with multi-provider trunking, browser softphone via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML noise suppression, Kokoro neural TTS announcements, and a Lit-based web dashboard with live call monitoring and REST API.
This commit is contained in:
815
ts_web/elements/sipproxy-view-phone.ts
Normal file
815
ts_web/elements/sipproxy-view-phone.ts
Normal file
@@ -0,0 +1,815 @@
|
||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||
import { deesCatalog } from '../plugins.js';
|
||||
import { appState, type IAppState, type IContact } from '../state/appstate.js';
|
||||
import { WebRtcClient, getAudioDevices, type IAudioDevices } from '../state/webrtc-client.js';
|
||||
import { viewHostCss } from './shared/index.js';
|
||||
|
||||
interface IIncomingCall {
|
||||
callId: string;
|
||||
from: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
// Module-level singleton — survives view mount/unmount cycles.
|
||||
let sharedRtcClient: WebRtcClient | null = null;
|
||||
let sharedKeepAliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let sharedRegistered = false;
|
||||
|
||||
@customElement('sipproxy-view-phone')
|
||||
export class SipproxyViewPhone extends DeesElement {
|
||||
@state() accessor appData: IAppState = appState.getState();
|
||||
|
||||
// WebRTC state
|
||||
@state() accessor rtcState: string = 'idle';
|
||||
@state() accessor registered = false;
|
||||
@state() accessor incomingCalls: IIncomingCall[] = [];
|
||||
@state() accessor activeCallId: string | null = null;
|
||||
@state() accessor audioDevices: IAudioDevices = { inputs: [], outputs: [] };
|
||||
@state() accessor selectedInput: string = '';
|
||||
@state() accessor selectedOutput: string = '';
|
||||
@state() accessor localLevel: number = 0;
|
||||
@state() accessor remoteLevel: number = 0;
|
||||
|
||||
// Dialer state
|
||||
@state() accessor dialNumber = '';
|
||||
@state() accessor dialStatus = '';
|
||||
@state() accessor calling = false;
|
||||
@state() accessor currentCallId: string | null = null;
|
||||
@state() accessor selectedDeviceId: string = '';
|
||||
@state() accessor selectedProviderId: string = '';
|
||||
@state() accessor callDuration: number = 0;
|
||||
|
||||
private rtcClient: WebRtcClient | null = null;
|
||||
private levelTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private durationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ---------- Two-column layout ---------- */
|
||||
.phone-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ---------- Tile content padding ---------- */
|
||||
.tile-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ---------- Dialer inputs ---------- */
|
||||
.dialer-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.call-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.btn:active:not(:disabled) { transform: scale(0.97); }
|
||||
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.btn-call {
|
||||
background: linear-gradient(135deg, #16a34a, #15803d);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(22, 163, 74, 0.3);
|
||||
}
|
||||
.btn-hangup {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
.dial-status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--dees-color-text-secondary, #94a3b8);
|
||||
min-height: 1.2em;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Contact card (passport-style) */
|
||||
.contact-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.10), rgba(56, 189, 248, 0.06));
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.contact-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #2563eb, #0ea5e9, #38bdf8);
|
||||
}
|
||||
.contact-card-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #2563eb, #0ea5e9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
.contact-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.contact-card-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.contact-card-number {
|
||||
font-size: 0.9rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #38bdf8;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.contact-card-company {
|
||||
font-size: 0.75rem;
|
||||
color: var(--dees-color-text-secondary, #64748b);
|
||||
}
|
||||
.contact-card-clear {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.contact-card-clear:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Starred contacts grid */
|
||||
.contacts-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--dees-color-border-subtle, #334155);
|
||||
}
|
||||
.contacts-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dees-color-text-secondary, #64748b);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.contacts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-contact {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dees-color-border-default, #1e3a5f);
|
||||
border-radius: 8px;
|
||||
background: rgba(30, 58, 95, 0.4);
|
||||
color: #38bdf8;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-contact:hover:not(:disabled) {
|
||||
background: rgba(37, 99, 235, 0.25);
|
||||
border-color: #2563eb;
|
||||
}
|
||||
.btn-contact:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.btn-contact .contact-name { display: block; margin-bottom: 2px; }
|
||||
.btn-contact .contact-number {
|
||||
display: block; font-size: 0.7rem; color: #64748b;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.btn-contact .contact-company {
|
||||
display: block; font-size: 0.65rem; color: #475569; margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Phone status ---------- */
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--dees-color-bg-primary, #0f172a);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--dees-color-border-default, #334155);
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.on { background: #4ade80; box-shadow: 0 0 8px #4ade80; }
|
||||
.dot.off { background: #f87171; box-shadow: 0 0 8px #f87171; }
|
||||
.dot.pending { background: #fbbf24; box-shadow: 0 0 8px #fbbf24; animation: dotPulse 1.5s ease-in-out infinite; }
|
||||
@keyframes dotPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
.status-label { font-size: 0.9rem; font-weight: 500; }
|
||||
.status-detail { font-size: 0.75rem; color: var(--dees-color-text-secondary, #64748b); margin-left: auto; }
|
||||
|
||||
/* Active call banner */
|
||||
.active-call-banner {
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, rgba(22, 163, 74, 0.15), rgba(16, 185, 129, 0.1));
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.active-call-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.active-call-pulse { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; animation: dotPulse 1s ease-in-out infinite; }
|
||||
.active-call-label { font-size: 0.85rem; font-weight: 600; color: #4ade80; }
|
||||
.active-call-duration { font-size: 0.8rem; color: #94a3b8; margin-left: auto; font-family: 'JetBrains Mono', monospace; }
|
||||
.active-call-number { font-size: 1rem; font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
/* Audio device dropdowns spacing */
|
||||
dees-input-dropdown {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Level meters */
|
||||
.levels {
|
||||
display: flex; gap: 16px; margin-bottom: 16px; padding: 12px 16px;
|
||||
background: var(--dees-color-bg-primary, #0f172a); border-radius: 10px;
|
||||
border: 1px solid var(--dees-color-border-default, #334155);
|
||||
}
|
||||
.level-group { flex: 1; }
|
||||
.level-label { font-size: 0.65rem; color: #64748b; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.level-bar-bg { height: 6px; border-radius: 3px; background: #1e293b; overflow: hidden; }
|
||||
.level-bar { height: 100%; border-radius: 3px; transition: width 60ms linear; }
|
||||
.level-bar.mic { background: linear-gradient(90deg, #4ade80, #22c55e); }
|
||||
.level-bar.spk { background: linear-gradient(90deg, #38bdf8, #0ea5e9); }
|
||||
|
||||
/* Incoming calls */
|
||||
.incoming-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--dees-color-border-subtle, #334155); }
|
||||
.incoming-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--dees-color-text-secondary, #94a3b8); margin-bottom: 10px; }
|
||||
.incoming-row {
|
||||
display: flex; align-items: center; gap: 10px; padding: 12px 14px;
|
||||
background: rgba(251, 191, 36, 0.06); border-radius: 10px; margin-bottom: 8px;
|
||||
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
.incoming-ring { font-size: 0.7rem; font-weight: 700; color: #fbbf24; animation: dotPulse 1s infinite; letter-spacing: 0.04em; }
|
||||
.incoming-from { flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; }
|
||||
.btn-sm { padding: 8px 16px; border: none; border-radius: 8px; font-weight: 600; font-size: 0.8rem; cursor: pointer; touch-action: manipulation; }
|
||||
.btn-accept { background: #16a34a; color: #fff; }
|
||||
.btn-accept:active { background: #166534; }
|
||||
.btn-reject { background: #dc2626; color: #fff; }
|
||||
.btn-reject:active { background: #991b1b; }
|
||||
.no-incoming { font-size: 0.8rem; color: #475569; padding: 8px 0; }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 768px) {
|
||||
.phone-layout { grid-template-columns: 1fr; }
|
||||
.call-actions { flex-direction: column; }
|
||||
.btn { padding: 14px 20px; font-size: 1rem; }
|
||||
.incoming-row { flex-wrap: wrap; }
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||
} as any);
|
||||
this.tryAutoRegister();
|
||||
this.loadAudioDevices();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.durationTimer) clearInterval(this.durationTimer);
|
||||
this.stopLevelMeter();
|
||||
}
|
||||
|
||||
// ---------- Audio device loading ----------
|
||||
|
||||
private async loadAudioDevices() {
|
||||
this.audioDevices = await getAudioDevices();
|
||||
if (!this.selectedInput && this.audioDevices.inputs.length) {
|
||||
this.selectedInput = this.audioDevices.inputs[0].deviceId;
|
||||
}
|
||||
if (!this.selectedOutput && this.audioDevices.outputs.length) {
|
||||
this.selectedOutput = this.audioDevices.outputs[0].deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- WebRTC registration ----------
|
||||
|
||||
private tryAutoRegister() {
|
||||
if (sharedRtcClient && sharedRegistered) {
|
||||
this.rtcClient = sharedRtcClient;
|
||||
this.registered = true;
|
||||
this.rtcState = sharedRtcClient.state;
|
||||
this.setupSignalingHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
this.registerSoftphone(ws);
|
||||
} else {
|
||||
const timer = setInterval(() => {
|
||||
const ws2 = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||
if (ws2?.readyState === WebSocket.OPEN) {
|
||||
clearInterval(timer);
|
||||
this.registerSoftphone(ws2);
|
||||
}
|
||||
}, 200);
|
||||
setTimeout(() => clearInterval(timer), 10000);
|
||||
}
|
||||
}
|
||||
|
||||
private registerSoftphone(ws: WebSocket) {
|
||||
if (!sharedRtcClient) {
|
||||
sharedRtcClient = new WebRtcClient(() => {});
|
||||
}
|
||||
this.rtcClient = sharedRtcClient;
|
||||
|
||||
(this.rtcClient as any).onStateChange = (s: string) => {
|
||||
this.rtcState = s;
|
||||
if (s === 'connected') this.startLevelMeter();
|
||||
else if (s === 'idle' || s === 'error') this.stopLevelMeter();
|
||||
};
|
||||
|
||||
if (this.selectedInput) this.rtcClient.setInputDevice(this.selectedInput);
|
||||
if (this.selectedOutput) this.rtcClient.setOutputDevice(this.selectedOutput);
|
||||
|
||||
this.rtcClient.setWebSocket(ws);
|
||||
this.setupSignalingHandler();
|
||||
|
||||
if (!sharedRegistered) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'webrtc-register',
|
||||
sessionId: this.rtcClient.id,
|
||||
userAgent: navigator.userAgent,
|
||||
}));
|
||||
|
||||
if (sharedKeepAliveTimer) clearInterval(sharedKeepAliveTimer);
|
||||
sharedKeepAliveTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN && sharedRtcClient) {
|
||||
ws.send(JSON.stringify({ type: 'webrtc-register', sessionId: sharedRtcClient.id }));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
sharedRegistered = true;
|
||||
}
|
||||
|
||||
this.registered = true;
|
||||
this.rtcState = this.rtcClient.state;
|
||||
}
|
||||
|
||||
private setupSignalingHandler() {
|
||||
(window as any).__sipRouterWebRtcHandler = (msg: any) => {
|
||||
this.rtcClient?.handleSignaling(msg);
|
||||
|
||||
if (msg.type === 'webrtc-registered') {
|
||||
const d = msg.data || msg;
|
||||
if (d.deviceId) appState.setBrowserDeviceId(d.deviceId);
|
||||
}
|
||||
|
||||
if (msg.type === 'webrtc-incoming') {
|
||||
const d = msg.data || msg;
|
||||
if (!this.incomingCalls.find((c) => c.callId === d.callId)) {
|
||||
this.incomingCalls = [...this.incomingCalls, {
|
||||
callId: d.callId,
|
||||
from: d.from || 'Unknown',
|
||||
time: Date.now(),
|
||||
}];
|
||||
deesCatalog.DeesToast.show({
|
||||
message: `Incoming call from ${d.from || 'Unknown'}`,
|
||||
type: 'info',
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'webrtc-call-ended') {
|
||||
const d = msg.data || msg;
|
||||
this.incomingCalls = this.incomingCalls.filter((c) => c.callId !== d.callId);
|
||||
if (this.activeCallId === d.callId) {
|
||||
this.rtcClient?.hangup();
|
||||
this.activeCallId = null;
|
||||
this.stopDurationTimer();
|
||||
deesCatalog.DeesToast.info('Call ended');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Level meters ----------
|
||||
|
||||
private startLevelMeter() {
|
||||
this.stopLevelMeter();
|
||||
this.levelTimer = setInterval(() => {
|
||||
if (this.rtcClient) {
|
||||
this.localLevel = this.rtcClient.getLocalLevel();
|
||||
this.remoteLevel = this.rtcClient.getRemoteLevel();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private stopLevelMeter() {
|
||||
if (this.levelTimer) {
|
||||
clearInterval(this.levelTimer);
|
||||
this.levelTimer = null;
|
||||
}
|
||||
this.localLevel = 0;
|
||||
this.remoteLevel = 0;
|
||||
}
|
||||
|
||||
// ---------- Call duration ----------
|
||||
|
||||
private startDurationTimer() {
|
||||
this.stopDurationTimer();
|
||||
this.callDuration = 0;
|
||||
this.durationTimer = setInterval(() => {
|
||||
this.callDuration++;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopDurationTimer() {
|
||||
if (this.durationTimer) {
|
||||
clearInterval(this.durationTimer);
|
||||
this.durationTimer = null;
|
||||
}
|
||||
this.callDuration = 0;
|
||||
}
|
||||
|
||||
private fmtDuration(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ---------- Call actions ----------
|
||||
|
||||
private async acceptCall(callId: string) {
|
||||
this.incomingCalls = this.incomingCalls.filter((c) => c.callId !== callId);
|
||||
this.activeCallId = callId;
|
||||
|
||||
if (this.rtcClient) {
|
||||
if (this.selectedInput) this.rtcClient.setInputDevice(this.selectedInput);
|
||||
if (this.selectedOutput) this.rtcClient.setOutputDevice(this.selectedOutput);
|
||||
await this.rtcClient.startCall();
|
||||
}
|
||||
|
||||
const ws = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'webrtc-accept', callId, sessionId: this.rtcClient?.id }));
|
||||
}
|
||||
|
||||
this.startDurationTimer();
|
||||
}
|
||||
|
||||
private rejectCall(callId: string) {
|
||||
const ws = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'webrtc-reject', callId }));
|
||||
}
|
||||
this.incomingCalls = this.incomingCalls.filter((c) => c.callId !== callId);
|
||||
}
|
||||
|
||||
private selectContact(contact: IContact) {
|
||||
appState.selectContact(contact);
|
||||
this.dialNumber = contact.number;
|
||||
}
|
||||
|
||||
private clearContact() {
|
||||
appState.clearSelectedContact();
|
||||
this.dialNumber = '';
|
||||
}
|
||||
|
||||
private async makeCall(number?: string) {
|
||||
const num = number || this.dialNumber.trim();
|
||||
if (!num) {
|
||||
this.dialStatus = 'Please enter a phone number';
|
||||
return;
|
||||
}
|
||||
this.dialNumber = num;
|
||||
this.calling = true;
|
||||
this.dialStatus = 'Initiating call...';
|
||||
|
||||
try {
|
||||
const res = await appState.apiCall(
|
||||
num,
|
||||
this.selectedDeviceId || undefined,
|
||||
this.selectedProviderId || undefined,
|
||||
);
|
||||
if (res.ok && res.callId) {
|
||||
this.currentCallId = res.callId;
|
||||
this.dialStatus = 'Call initiated';
|
||||
this.startDurationTimer();
|
||||
appState.clearSelectedContact();
|
||||
} else {
|
||||
this.dialStatus = `Error: ${res.error || 'unknown'}`;
|
||||
deesCatalog.DeesToast.error(`Call failed: ${res.error || 'unknown'}`, 4000);
|
||||
this.calling = false;
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.dialStatus = `Failed: ${e.message}`;
|
||||
deesCatalog.DeesToast.error(`Call failed: ${e.message}`, 4000);
|
||||
this.calling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async hangup() {
|
||||
if (!this.currentCallId) return;
|
||||
this.dialStatus = 'Hanging up...';
|
||||
try {
|
||||
await appState.apiHangup(this.currentCallId);
|
||||
this.dialStatus = 'Call ended';
|
||||
this.currentCallId = null;
|
||||
this.calling = false;
|
||||
this.stopDurationTimer();
|
||||
} catch (e: any) {
|
||||
this.dialStatus = `Hangup failed: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private getConnectedDevices() {
|
||||
return this.appData.devices.filter((d) => d.connected);
|
||||
}
|
||||
|
||||
private getRegisteredProviders() {
|
||||
return this.appData.providers.filter((p) => p.registered);
|
||||
}
|
||||
|
||||
private getDotClass(): string {
|
||||
if (this.rtcState === 'connected') return 'on';
|
||||
if (this.rtcState === 'connecting' || this.rtcState === 'requesting-mic') return 'pending';
|
||||
if (this.registered) return 'on';
|
||||
return 'off';
|
||||
}
|
||||
|
||||
private getStateLabel(): string {
|
||||
const labels: Record<string, string> = {
|
||||
idle: 'Registered - Ready',
|
||||
'requesting-mic': 'Requesting Microphone...',
|
||||
connecting: 'Connecting Audio...',
|
||||
connected: 'On Call',
|
||||
error: 'Error',
|
||||
};
|
||||
return labels[this.rtcState] || this.rtcState;
|
||||
}
|
||||
|
||||
updated() {
|
||||
const active = this.appData.calls?.find((c) => c.state !== 'terminated' && c.direction === 'outbound');
|
||||
if (active) {
|
||||
this.currentCallId = active.id;
|
||||
this.calling = true;
|
||||
} else if (this.calling && !active) {
|
||||
this.calling = false;
|
||||
this.currentCallId = null;
|
||||
this.stopDurationTimer();
|
||||
}
|
||||
|
||||
const connected = this.getConnectedDevices();
|
||||
if (this.selectedDeviceId && !connected.find((d) => d.id === this.selectedDeviceId)) {
|
||||
this.selectedDeviceId = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Render ----------
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="phone-layout">
|
||||
${this.renderDialer()}
|
||||
${this.renderPhoneStatus()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDialer(): TemplateResult {
|
||||
const connected = this.getConnectedDevices();
|
||||
const registeredProviders = this.getRegisteredProviders();
|
||||
const selectedContact = this.appData.selectedContact;
|
||||
const starredContacts = this.appData.contacts.filter((c) => c.starred);
|
||||
|
||||
return html`
|
||||
<dees-tile heading="Dialer">
|
||||
<div class="tile-body">
|
||||
<div class="dialer-inputs">
|
||||
${selectedContact ? html`
|
||||
<div class="contact-card">
|
||||
<div class="contact-card-avatar">
|
||||
${selectedContact.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="contact-card-info">
|
||||
<div class="contact-card-name">${selectedContact.name}</div>
|
||||
<div class="contact-card-number">${selectedContact.number}</div>
|
||||
${selectedContact.company ? html`
|
||||
<div class="contact-card-company">${selectedContact.company}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<button
|
||||
class="contact-card-clear"
|
||||
@click=${() => this.clearContact()}
|
||||
title="Clear selection"
|
||||
>×</button>
|
||||
</div>
|
||||
` : html`
|
||||
<dees-input-text
|
||||
.label=${'Phone Number'}
|
||||
.value=${this.dialNumber}
|
||||
@input=${(e: Event) => { this.dialNumber = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
`}
|
||||
|
||||
<dees-input-dropdown
|
||||
.label=${'Call from'}
|
||||
.options=${connected.map((d) => ({
|
||||
option: `${d.displayName}${d.id === this.appData.browserDeviceId ? ' (this browser)' : d.isBrowser ? ' (WebRTC)' : ''}`,
|
||||
key: d.id,
|
||||
}))}
|
||||
.selectedOption=${this.selectedDeviceId ? {
|
||||
option: connected.find((d) => d.id === this.selectedDeviceId)?.displayName || this.selectedDeviceId,
|
||||
key: this.selectedDeviceId,
|
||||
} : null}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectedDeviceId = e.detail.key; }}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<dees-input-dropdown
|
||||
.label=${'Via provider'}
|
||||
.options=${[
|
||||
{ option: 'Default', key: '' },
|
||||
...registeredProviders.map((p) => ({ option: p.displayName, key: p.id })),
|
||||
]}
|
||||
.selectedOption=${{ option: this.selectedProviderId ? (registeredProviders.find((p) => p.id === this.selectedProviderId)?.displayName || this.selectedProviderId) : 'Default', key: this.selectedProviderId || '' }}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectedProviderId = e.detail.key; }}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="call-actions">
|
||||
<button
|
||||
class="btn btn-call"
|
||||
?disabled=${this.calling || !this.selectedDeviceId}
|
||||
@click=${() => this.makeCall(selectedContact?.number)}
|
||||
>Call</button>
|
||||
<button
|
||||
class="btn btn-hangup"
|
||||
?disabled=${!this.currentCallId}
|
||||
@click=${() => this.hangup()}
|
||||
>Hang Up</button>
|
||||
</div>
|
||||
|
||||
${this.dialStatus ? html`<div class="dial-status">${this.dialStatus}</div>` : ''}
|
||||
|
||||
${starredContacts.length ? html`
|
||||
<div class="contacts-section">
|
||||
<div class="contacts-label">Quick Dial</div>
|
||||
<div class="contacts-grid">
|
||||
${starredContacts.map((c) => html`
|
||||
<button
|
||||
class="btn-contact"
|
||||
?disabled=${this.calling}
|
||||
@click=${() => this.selectContact(c)}
|
||||
>
|
||||
<span class="contact-name">${c.name}</span>
|
||||
<span class="contact-number">${c.number}</span>
|
||||
${c.company ? html`<span class="contact-company">${c.company}</span>` : ''}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPhoneStatus(): TemplateResult {
|
||||
const micPct = Math.min(100, Math.round(this.localLevel * 300));
|
||||
const spkPct = Math.min(100, Math.round(this.remoteLevel * 300));
|
||||
|
||||
return html`
|
||||
<dees-tile heading="Phone Status">
|
||||
<div class="tile-body">
|
||||
<!-- Registration status -->
|
||||
<div class="status-indicator">
|
||||
<span class="dot ${this.getDotClass()}"></span>
|
||||
<span class="status-label">${this.registered ? this.getStateLabel() : 'Connecting...'}</span>
|
||||
${this.appData.browserDeviceId ? html`
|
||||
<span class="status-detail">ID: ${this.appData.browserDeviceId.slice(0, 12)}...</span>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Active call banner -->
|
||||
${this.rtcState === 'connected' || this.calling ? html`
|
||||
<div class="active-call-banner">
|
||||
<div class="active-call-header">
|
||||
<span class="active-call-pulse"></span>
|
||||
<span class="active-call-label">Active Call</span>
|
||||
<span class="active-call-duration">${this.fmtDuration(this.callDuration)}</span>
|
||||
</div>
|
||||
${this.dialNumber ? html`
|
||||
<div class="active-call-number">${this.dialNumber}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Level meters -->
|
||||
${this.rtcState === 'connected' ? html`
|
||||
<div class="levels">
|
||||
<div class="level-group">
|
||||
<div class="level-label">Microphone</div>
|
||||
<div class="level-bar-bg"><div class="level-bar mic" style="width:${micPct}%"></div></div>
|
||||
</div>
|
||||
<div class="level-group">
|
||||
<div class="level-label">Speaker</div>
|
||||
<div class="level-bar-bg"><div class="level-bar spk" style="width:${spkPct}%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Audio device selectors -->
|
||||
<dees-input-dropdown
|
||||
.label=${'Microphone'}
|
||||
.enableSearch=${false}
|
||||
.options=${this.audioDevices.inputs.map((d) => ({
|
||||
option: d.label || 'Microphone',
|
||||
key: d.deviceId,
|
||||
}))}
|
||||
.selectedOption=${this.selectedInput ? {
|
||||
option: this.audioDevices.inputs.find((d) => d.deviceId === this.selectedInput)?.label || 'Microphone',
|
||||
key: this.selectedInput,
|
||||
} : null}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectedInput = e.detail.key; this.rtcClient?.setInputDevice(this.selectedInput); }}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
.label=${'Speaker'}
|
||||
.enableSearch=${false}
|
||||
.options=${this.audioDevices.outputs.map((d) => ({
|
||||
option: d.label || 'Speaker',
|
||||
key: d.deviceId,
|
||||
}))}
|
||||
.selectedOption=${this.selectedOutput ? {
|
||||
option: this.audioDevices.outputs.find((d) => d.deviceId === this.selectedOutput)?.label || 'Speaker',
|
||||
key: this.selectedOutput,
|
||||
} : null}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectedOutput = e.detail.key; this.rtcClient?.setOutputDevice(this.selectedOutput); }}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<!-- Incoming calls -->
|
||||
<div class="incoming-section">
|
||||
<div class="incoming-label">Incoming Calls</div>
|
||||
${this.incomingCalls.length ? this.incomingCalls.map((call) => html`
|
||||
<div class="incoming-row">
|
||||
<span class="incoming-ring">RINGING</span>
|
||||
<span class="incoming-from">${call.from}</span>
|
||||
<button class="btn-sm btn-accept" @click=${() => this.acceptCall(call.callId)}>Accept</button>
|
||||
<button class="btn-sm btn-reject" @click=${() => this.rejectCall(call.callId)}>Reject</button>
|
||||
</div>
|
||||
`) : html`
|
||||
<div class="no-incoming">No incoming calls</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user