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 | 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 | null = null; private keepaliveTimer: ReturnType | null = null; private durationTimer: ReturnType | 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 = { 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`
${this.renderDialer()} ${this.renderPhoneStatus()}
`; } 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`
${selectedContact ? html`
${selectedContact.name.charAt(0).toUpperCase()}
${selectedContact.name}
${selectedContact.number}
${selectedContact.company ? html`
${selectedContact.company}
` : ''}
` : html` { this.dialNumber = (e.target as any).value; }} > `} ({ 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; }} > ({ 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; }} >
${this.dialStatus ? html`
${this.dialStatus}
` : ''} ${starredContacts.length ? html`
Quick Dial
${starredContacts.map((c) => html` `)}
` : ''}
`; } 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`
${this.registered ? this.getStateLabel() : 'Connecting...'} ${this.appData.browserDeviceId ? html` ID: ${this.appData.browserDeviceId.slice(0, 12)}... ` : ''}
${this.rtcState === 'connected' || this.calling ? html`
Active Call ${this.fmtDuration(this.callDuration)}
${this.dialNumber ? html`
${this.dialNumber}
` : ''}
` : ''} ${this.rtcState === 'connected' ? html`
Microphone
Speaker
` : ''} ({ 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); }} > ({ 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); }} >
Incoming Calls
${this.incomingCalls.length ? this.incomingCalls.map((call) => html`
RINGING ${call.from}
`) : html`
No incoming calls
`}
`; } }