<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CC SIP Phone (jssip 3.10.0)</title>
<style>
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.layout {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.left-panel, .right-panel {
flex: 1 1 380px;
display: flex;
flex-direction: column;
gap: 20px;
}
.section {
margin-bottom: 20px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
}
.section h3 {
margin-top: 0;
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 8px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
font-size: 13px;
}
input[type="text"], input[type="password"], input[type="url"], input[type="tel"], input[type="number"], textarea, button, select {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
font-size: 13px;
}
input[type="checkbox"] { width: auto; margin-right: 8px; }
label.checkbox-inline {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: normal;
}
button {
background: #007bff;
color: white;
cursor: pointer;
margin-top: 10px;
font-weight: 600;
}
button:hover { background: #0056b3; }
button:disabled { background: #ccc; cursor: not-allowed; }
button.secondary { background: #6c757d; }
button.secondary:hover { background: #545b62; }
button.danger { background: #dc3545; }
button.danger:hover { background: #c82333; }
button.success { background: #28a745; }
button.success:hover { background: #218838; }
button.warning { background: #ffc107; color: #212529; }
button.warning:hover { background: #e0a800; }
.status {
padding: 10px;
margin: 10px 0;
border-radius: 4px;
font-size: 13px;
}
.status.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.status.info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
.status.warning { background: #fff3cd; color: #856404; border: 1px solid #ffeaa7; }
.call-controls { display: none; }
.call-controls.active { display: block; }
.logs {
height: 280px;
overflow-y: auto;
overflow-x: hidden;
background: #f8f9fa;
padding: 10px;
border: 1px solid #ddd;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
word-break: break-all;
border-radius: 4px;
}
.incoming-call {
display: none;
background: #fff3cd;
border: 1px solid #ffeaa7;
padding: 20px;
border-radius: 5px;
margin: 10px 0;
}
.incoming-call.active { display: block; }
.call-info { margin: 10px 0; font-weight: bold; }
.video-container {
display: none;
gap: 10px;
flex-wrap: wrap;
}
.video-container.active { display: flex; }
.video-box {
flex: 1 1 200px;
display: flex;
flex-direction: column;
align-items: center;
}
.video-box label { font-size: 12px; color: #666; margin-bottom: 4px; }
.video-box video {
width: 100%;
max-width: 320px;
background: #000;
border-radius: 4px;
border: 1px solid #ddd;
aspect-ratio: 16/9;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
.stat-box {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 10px;
text-align: center;
}
.stat-box .stat-label {
font-size: 11px;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-box .stat-value {
font-size: 18px;
font-weight: bold;
color: #212529;
margin-top: 4px;
}
.stat-box .stat-value.good { color: #28a745; }
.stat-box .stat-value.warning { color: #ffc107; }
.stat-box .stat-value.bad { color: #dc3545; }
.quality-bar {
width: 100%;
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin-top: 6px;
}
.quality-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease, background-color 0.5s ease;
}
.quality-bar-fill.good { background: #28a745; }
.quality-bar-fill.warning { background: #ffc107; }
.quality-bar-fill.bad { background: #dc3545; }
.cc-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 10px;
}
.cc-controls button {
margin-top: 0;
padding: 8px;
font-size: 12px;
}
.debug-panel {
background: #1e1e1e;
color: #d4d4d4;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
padding: 10px;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
}
.debug-panel .debug-entry {
margin-bottom: 4px;
padding: 2px 0;
border-bottom: 1px solid #333;
}
.debug-panel .debug-time { color: #858585; }
.debug-panel .debug-info { color: #4fc1ff; }
.debug-panel .debug-warn { color: #ffcc00; }
.debug-panel .debug-error { color: #f48771; }
.tabs {
display: flex;
border-bottom: 2px solid #dee2e6;
margin-bottom: 15px;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border: none;
background: none;
color: #6c757d;
font-weight: 600;
margin: 0;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab:hover { color: #495057; }
.tab.active {
color: #007bff;
border-bottom-color: #007bff;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.transfer-dialog {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.transfer-dialog.active { display: flex; }
.transfer-dialog-content {
background: white;
padding: 30px;
border-radius: 8px;
max-width: 400px;
width: 90%;
}
@media (max-width: 768px) {
body { max-width: 100%; padding: 10px; }
.layout { flex-direction: column; }
.stats-grid { grid-template-columns: 1fr; }
.cc-controls { grid-template-columns: 1fr; }
}
.dtmf-section {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #e9ecef;
}
.dtmf-section h4 {
margin: 0 0 8px 0;
font-size: 13px;
color: #495057;
font-weight: 600;
}
.dtmf-display {
font-family: 'Consolas', 'Monaco', monospace;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 6px 10px;
min-height: 32px;
margin-bottom: 8px;
font-size: 15px;
letter-spacing: 3px;
color: #212529;
}
.dtmf-keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.dtmf-btn {
background: #f8f9fa;
color: #212529;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 10px 4px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1.2;
transition: background 0.1s, transform 0.05s;
width: 100%;
}
.dtmf-btn:hover { background: #e2e6ea; border-color: #adb5bd; }
.dtmf-btn:active { background: #adb5bd; transform: scale(0.95); }
.dtmf-btn small {
font-size: 9px;
font-weight: normal;
color: #6c757d;
letter-spacing: 0.5px;
}
.dtmf-actions {
display: flex;
gap: 6px;
margin-top: 6px;
}
</style>
</head>
<body>
<div class="container">
<h1>CC SIP Phone <small style="font-size:14px;color:#6c757d;">(jssip 3.10.0 + CC Debug)</small></h1>
<div class="layout">
<div class="left-panel">
<div class="section">
<h3>Registration</h3>
<div class="form-group">
<label for="server">SIP Server (WebSocket URL):</label>
<input type="text" id="server" data-default-path="/ws" placeholder="ws://localhost:8080/ws">
</div>
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" value="bob" placeholder="bob">
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" value="123456" placeholder="password">
</div>
<div class="form-group">
<label for="displayName">Display Name:</label>
<input type="text" id="displayName" value="Bob" placeholder="Bob">
</div>
<div class="form-group">
<label class="checkbox-inline"><input type="checkbox" id="iceEnable" checked> Enable ICE servers</label>
<label class="checkbox-inline" style="margin-left:16px;"><input type="checkbox" id="relayOnly"> Relay Only</label>
<textarea id="iceServers" rows="3" style="width:100%;margin-top:6px;">[{"urls":"stun:stun.l.google.com:19302"}]</textarea>
</div>
<button id="registerBtn">Register</button>
<button id="unregisterBtn" disabled class="secondary">Unregister</button>
<div id="registrationStatus"></div>
</div>
<div class="section">
<h3>Call Quality & Stats</h3>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-label">Quality (MOS)</div>
<div class="stat-value" id="mosValue">--</div>
<div class="quality-bar">
<div class="quality-bar-fill" id="mosBar" style="width:0%"></div>
</div>
</div>
<div class="stat-box">
<div class="stat-label">RTT (ms)</div>
<div class="stat-value" id="rttValue">--</div>
</div>
<div class="stat-box">
<div class="stat-label">Packet Loss</div>
<div class="stat-value" id="lossValue">--</div>
</div>
<div class="stat-box">
<div class="stat-label">Jitter (ms)</div>
<div class="stat-value" id="jitterValue">--</div>
</div>
<div class="stat-box">
<div class="stat-label">RX Packets</div>
<div class="stat-value" id="rxPackets">--</div>
</div>
<div class="stat-box">
<div class="stat-label">TX Packets</div>
<div class="stat-value" id="txPackets">--</div>
</div>
<div class="stat-box">
<div class="stat-label">RX Bytes</div>
<div class="stat-value" id="rxBytes">--</div>
</div>
<div class="stat-box">
<div class="stat-label">TX Bytes</div>
<div class="stat-value" id="txBytes">--</div>
</div>
</div>
<div style="margin-top:10px;">
<button type="button" id="refreshStatsBtn" style="width:auto;">Refresh Stats</button>
<button type="button" id="resetStatsBtn" class="secondary" style="width:auto;">Reset</button>
</div>
</div>
<div class="section">
<h3>Logs</h3>
<div class="logs" id="logs"></div>
<div style="margin-top:8px;display:flex;gap:8px;">
<button onclick="clearLogs()">Clear Logs</button>
</div>
</div>
</div>
<div class="right-panel">
<div class="section">
<h3>Make Call</h3>
<div class="form-group">
<label for="callTarget">Call To:</label>
<input type="text" id="callTarget" value="alice" placeholder="alice">
</div>
<div class="form-group">
<label class="checkbox-inline"><input type="checkbox" id="enableVideo"> Enable Video</label>
</div>
<div class="form-group" id="videoSourceGroup" style="display:none;">
<label>Video Source:</label>
<div style="margin-bottom:6px;">
<label class="checkbox-inline" style="margin-right:16px;">
<input type="radio" name="videoSource" id="videoSourceCamera" value="camera" checked> Camera
</label>
<label class="checkbox-inline">
<input type="radio" name="videoSource" id="videoSourceClock" value="clock"> Clock (Virtual)
</label>
</div>
<div id="cameraSelectorDiv">
<div style="display:flex;gap:6px;align-items:center;">
<select id="cameraSelect" style="flex:1;padding:8px;border:1px solid #ddd;border-radius:4px;font-size:13px;">
<option value="">Default Camera</option>
</select>
<button type="button" id="refreshCamerasBtn" style="width:auto;flex-shrink:0;margin:0;padding:8px 10px;" title="Refresh camera list">↻</button>
</div>
</div>
<div id="videoPreviewDiv" style="display:none;margin-top:8px;">
<canvas id="clockCanvas" width="320" height="240" style="width:100%;border-radius:4px;display:block;background:#111;"></canvas>
</div>
</div>
<button id="callBtn" disabled>Call</button>
<div class="call-controls" id="callControls">
<div class="call-info" id="callInfo"></div>
<button id="hangupBtn" class="danger">Hang Up</button>
<button id="muteBtn">Mute</button>
<button id="holdBtn">Hold</button>
<button id="transferBtn" class="warning">Transfer</button>
<button id="consultBtn" class="success">Consult (BC)</button>
<div class="dtmf-section" id="dtmfSection" style="display:none;">
<h4>DTMF Keypad</h4>
<div class="dtmf-display" id="dtmfDisplay"> </div>
<div class="dtmf-keypad">
<button type="button" class="dtmf-btn" data-key="1">1</button>
<button type="button" class="dtmf-btn" data-key="2">2<small>ABC</small></button>
<button type="button" class="dtmf-btn" data-key="3">3<small>DEF</small></button>
<button type="button" class="dtmf-btn" data-key="4">4<small>GHI</small></button>
<button type="button" class="dtmf-btn" data-key="5">5<small>JKL</small></button>
<button type="button" class="dtmf-btn" data-key="6">6<small>MNO</small></button>
<button type="button" class="dtmf-btn" data-key="7">7<small>PQRS</small></button>
<button type="button" class="dtmf-btn" data-key="8">8<small>TUV</small></button>
<button type="button" class="dtmf-btn" data-key="9">9<small>WXYZ</small></button>
<button type="button" class="dtmf-btn" data-key="*">*</button>
<button type="button" class="dtmf-btn" data-key="0">0<small>+</small></button>
<button type="button" class="dtmf-btn" data-key="#">#</button>
</div>
<div class="dtmf-actions">
<label for="dtmfTransport" style="display:flex;align-items:center;gap:6px;margin:0;font-size:12px;color:#555;">
Transport:
<select id="dtmfTransport" style="padding:4px 6px;font-size:12px;border:1px solid #ddd;border-radius:4px;">
<option value="RFC2833" selected>RFC2833</option>
<option value="INFO">INFO</option>
</select>
</label>
<button type="button" id="dtmfClearBtn" class="secondary" style="width:auto;padding:6px 14px;font-size:12px;margin-top:0;">Clear</button>
</div>
</div>
</div>
</div>
<div class="incoming-call" id="incomingCall">
<h3>Incoming Call</h3>
<div id="callerInfo"></div>
<button id="answerBtn" class="success">Answer</button>
<button id="rejectBtn" class="danger">Reject</button>
</div>
<div class="section">
<h3>CC Call Control</h3>
<div id="ccStatus" class="status info">No active CC call</div>
<div class="cc-controls">
<button id="ccHoldBtn" disabled>Hold</button>
<button id="ccUnholdBtn" disabled>Unhold</button>
<button id="ccTransferBtn" disabled>Blind Transfer</button>
<button id="ccConsultBtn" disabled>Consult</button>
<button id="ccMergeBtn" disabled>Merge (3-way)</button>
<button id="ccSwapBtn" disabled>Swap</button>
</div>
<div class="form-group" style="margin-top:10px;">
<label for="transferTarget">Transfer/Consult Target:</label>
<input type="text" id="transferTarget" placeholder="sip:user@domain or extension">
</div>
</div>
<div class="section">
<h3>ICE Stats</h3>
<div class="logs" id="iceStats" style="height:180px;">
<em>No ICE data yet.</em>
</div>
<div style="margin-top:8px;display:flex;gap:8px;">
<button type="button" id="runIceDiagBtn" style="width:auto;">Run ICE Diagnostics</button>
<button type="button" id="clearIceDiagBtn" style="width:auto;">Clear ICE Diagnostics</button>
</div>
<div class="logs" id="iceDiagResults" style="height:140px;margin-top:8px;">
<em>No diagnostics yet.</em>
</div>
<div class="video-container" id="videoContainer">
<div class="video-box">
<label>Remote Video</label>
<video id="remoteVideo" autoplay playsinline></video>
</div>
<div class="video-box">
<label>Local Video</label>
<video id="localVideo" autoplay playsinline muted></video>
</div>
</div>
<audio id="remoteAudio" autoplay style="display:none;"></audio>
<audio id="localAudio" muted autoplay style="display:none;"></audio>
</div>
<div class="section">
<h3>CC Debug & Test</h3>
<div class="tabs">
<button class="tab active" data-tab="debug-ws">WebSocket</button>
<button class="tab" data-tab="debug-state">Call State</button>
<button class="tab" data-tab="debug-sip">SIP Messages</button>
</div>
<div class="tab-content active" id="debug-ws">
<div class="form-group">
<label>CC WebSocket URL:</label>
<input type="text" id="ccWsUrl" placeholder="ws://localhost:8080/ws/ccphone">
</div>
<div class="form-group">
<label>CC Raw Command (JSON):</label>
<textarea id="ccRawCommand" rows="3">{"action":"status.update","status":"idle"}</textarea>
</div>
<button type="button" id="connectCcWsBtn" style="width:auto;">Connect CC WS</button>
<button type="button" id="disconnectCcWsBtn" class="secondary" style="width:auto;">Disconnect</button>
<button type="button" id="sendCcCmdBtn" class="warning" style="width:auto;">Send Command</button>
<div id="ccWsStatus" class="status info" style="margin-top:10px;">CC WS: disconnected</div>
<div class="debug-panel" id="ccWsLog" style="margin-top:10px;height:150px;"></div>
</div>
<div class="tab-content" id="debug-state">
<div class="debug-panel" id="callStateDebug" style="height:200px;">
<div class="debug-entry">No active call</div>
</div>
<button type="button" id="refreshStateBtn" style="width:auto;margin-top:8px;">Refresh State</button>
</div>
<div class="tab-content" id="debug-sip">
<div class="debug-panel" id="sipMessagesLog" style="height:200px;"></div>
<button type="button" id="clearSipLogBtn" class="secondary" style="width:auto;margin-top:8px;">Clear SIP Log</button>
</div>
</div>
</div>
</div>
</div>
<div class="transfer-dialog" id="transferDialog">
<div class="transfer-dialog-content">
<h3>Transfer Call</h3>
<div class="form-group">
<label>Transfer To:</label>
<input type="text" id="dialogTransferTarget" placeholder="Extension or SIP URI">
</div>
<div style="display:flex;gap:10px;">
<button id="confirmTransferBtn" class="success" style="flex:1;">Transfer</button>
<button id="cancelTransferBtn" class="secondary" style="flex:1;">Cancel</button>
</div>
</div>
</div>
<script src="//jssip.net/download/releases/jssip-3.10.0.min.js"></script>
<script>
'use strict';
let ua = null;
let currentSession = null;
let dtmfHistory = '';
let consultSession = null; let isMuted = false;
let isOnHold = false;
let isConsultOnHold = false;
let isRegistered = false;
let statsInterval = null;
let ccWs = null;
let sipMessageLog = [];
const pageInstanceId = (window.crypto && typeof window.crypto.randomUUID === 'function')
? window.crypto.randomUUID()
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
const serverInput = document.getElementById('server');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const displayNameInput = document.getElementById('displayName');
const registerBtn = document.getElementById('registerBtn');
const unregisterBtn = document.getElementById('unregisterBtn');
const registrationStatus = document.getElementById('registrationStatus');
const callTargetInput = document.getElementById('callTarget');
const callBtn = document.getElementById('callBtn');
const callControls = document.getElementById('callControls');
const callInfo = document.getElementById('callInfo');
const hangupBtn = document.getElementById('hangupBtn');
const muteBtn = document.getElementById('muteBtn');
const holdBtn = document.getElementById('holdBtn');
const transferBtn = document.getElementById('transferBtn');
const consultBtn = document.getElementById('consultBtn');
const dtmfTransportSelect = document.getElementById('dtmfTransport');
const incomingCallDiv = document.getElementById('incomingCall');
const callerInfo = document.getElementById('callerInfo');
const answerBtn = document.getElementById('answerBtn');
const rejectBtn = document.getElementById('rejectBtn');
const remoteAudio = document.getElementById('remoteAudio');
const localAudio = document.getElementById('localAudio');
const remoteVideo = document.getElementById('remoteVideo');
const localVideo = document.getElementById('localVideo');
const videoContainer = document.getElementById('videoContainer');
const enableVideoCheckbox = document.getElementById('enableVideo');
const videoSourceGroup = document.getElementById('videoSourceGroup');
const cameraSelectorDiv = document.getElementById('cameraSelectorDiv');
const cameraSelect = document.getElementById('cameraSelect');
const refreshCamerasBtn = document.getElementById('refreshCamerasBtn');
const clockCanvas = document.getElementById('clockCanvas');
const videoPreviewDiv = document.getElementById('videoPreviewDiv');
let localMediaStream = null;
let videoCanvasInterval = null;
const logs = document.getElementById('logs');
const iceStatsPanel = document.getElementById('iceStats');
const copyLogsBtn = document.getElementById('copyLogsBtn');
const copyIceStatsBtn = document.getElementById('copyIceStatsBtn');
const runIceDiagBtn = document.getElementById('runIceDiagBtn');
const clearIceDiagBtn = document.getElementById('clearIceDiagBtn');
const iceDiagResults = document.getElementById('iceDiagResults');
const iceEnable = document.getElementById('iceEnable');
const iceServersInput = document.getElementById('iceServers');
let lastOutgoingCallTime = null;
let lastOutgoingCallLabel = null;
let iceServersManuallyEdited = false;
let iceDiagInProgress = false;
const ccStatus = document.getElementById('ccStatus');
const ccHoldBtn = document.getElementById('ccHoldBtn');
const ccUnholdBtn = document.getElementById('ccUnholdBtn');
const ccTransferBtn = document.getElementById('ccTransferBtn');
const ccConsultBtn = document.getElementById('ccConsultBtn');
const ccMergeBtn = document.getElementById('ccMergeBtn');
const ccSwapBtn = document.getElementById('ccSwapBtn');
const transferTargetInput = document.getElementById('transferTarget');
const transferDialog = document.getElementById('transferDialog');
const dialogTransferTarget = document.getElementById('dialogTransferTarget');
const confirmTransferBtn = document.getElementById('confirmTransferBtn');
const cancelTransferBtn = document.getElementById('cancelTransferBtn');
const ccWsUrlInput = document.getElementById('ccWsUrl');
const connectCcWsBtn = document.getElementById('connectCcWsBtn');
const disconnectCcWsBtn = document.getElementById('disconnectCcWsBtn');
const ccWsStatus = document.getElementById('ccWsStatus');
const ccWsLog = document.getElementById('ccWsLog');
const ccRawCommandInput = document.getElementById('ccRawCommand');
const sendCcCmdBtn = document.getElementById('sendCcCmdBtn');
const callStateDebug = document.getElementById('callStateDebug');
const refreshStateBtn = document.getElementById('refreshStateBtn');
const sipMessagesLog = document.getElementById('sipMessagesLog');
const clearSipLogBtn = document.getElementById('clearSipLogBtn');
const mosValue = document.getElementById('mosValue');
const mosBar = document.getElementById('mosBar');
const rttValue = document.getElementById('rttValue');
const lossValue = document.getElementById('lossValue');
const jitterValue = document.getElementById('jitterValue');
const rxPackets = document.getElementById('rxPackets');
const txPackets = document.getElementById('txPackets');
const rxBytes = document.getElementById('rxBytes');
const txBytes = document.getElementById('txBytes');
registerBtn.addEventListener('click', register);
unregisterBtn.addEventListener('click', unregister);
callBtn.addEventListener('click', makeCall);
hangupBtn.addEventListener('click', hangup);
muteBtn.addEventListener('click', toggleMute);
holdBtn.addEventListener('click', toggleHold);
transferBtn.addEventListener('click', showTransferDialog);
consultBtn.addEventListener('click', startConsultation);
answerBtn.addEventListener('click', answerCall);
rejectBtn.addEventListener('click', rejectCall);
document.querySelectorAll('.dtmf-btn').forEach(btn => {
btn.addEventListener('click', () => sendDtmf(btn.dataset.key));
});
document.getElementById('dtmfClearBtn').addEventListener('click', () => {
dtmfHistory = '';
const dtmfDisplay = document.getElementById('dtmfDisplay');
if (dtmfDisplay) dtmfDisplay.innerHTML = ' ';
});
ccHoldBtn.addEventListener('click', () => ccHold(true));
ccUnholdBtn.addEventListener('click', () => ccHold(false));
ccTransferBtn.addEventListener('click', () => ccBlindTransfer());
ccConsultBtn.addEventListener('click', () => startConsultation());
ccMergeBtn.addEventListener('click', () => mergeCalls());
ccSwapBtn.addEventListener('click', () => swapCalls());
confirmTransferBtn.addEventListener('click', doTransfer);
cancelTransferBtn.addEventListener('click', hideTransferDialog);
connectCcWsBtn.addEventListener('click', connectCcWebSocket);
disconnectCcWsBtn.addEventListener('click', disconnectCcWebSocket);
if (sendCcCmdBtn) sendCcCmdBtn.addEventListener('click', sendRawCcCommand);
refreshStateBtn.addEventListener('click', updateCallStateDebug);
clearSipLogBtn.addEventListener('click', () => {
sipMessageLog = [];
sipMessagesLog.innerHTML = '';
});
if (iceServersInput) {
iceServersInput.addEventListener('input', () => {
iceServersManuallyEdited = true;
});
}
document.getElementById('refreshStatsBtn').addEventListener('click', refreshStats);
document.getElementById('resetStatsBtn').addEventListener('click', resetStats);
if (runIceDiagBtn) runIceDiagBtn.addEventListener('click', runIceDiagnostics);
if (clearIceDiagBtn) clearIceDiagBtn.addEventListener('click', () => resetIceDiagnostics());
if (enableVideoCheckbox) {
enableVideoCheckbox.addEventListener('change', () => {
if (videoSourceGroup) videoSourceGroup.style.display = enableVideoCheckbox.checked ? '' : 'none';
if (enableVideoCheckbox.checked) {
enumerateCameras();
const isClock = document.getElementById('videoSourceClock') && document.getElementById('videoSourceClock').checked;
if (isClock) startClockPreview();
} else {
stopClockPreview();
}
});
}
document.querySelectorAll('input[name="videoSource"]').forEach((radio) => {
radio.addEventListener('change', () => {
const isCam = document.getElementById('videoSourceCamera').checked;
if (cameraSelectorDiv) cameraSelectorDiv.style.display = isCam ? '' : 'none';
if (isCam) stopClockPreview(); else startClockPreview();
});
});
if (refreshCamerasBtn) refreshCamerasBtn.addEventListener('click', enumerateCameras);
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.dataset.tab).classList.add('active');
});
});
function log(message) {
const timestamp = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.textContent = `[${timestamp}] ${message}`;
logs.appendChild(entry);
logs.scrollTop = logs.scrollHeight;
console.log(message);
}
function clearLogs() {
logs.innerHTML = '';
resetIceStats();
}
function showStatus(message, type = 'info') {
registrationStatus.className = `status ${type}`;
registrationStatus.textContent = message;
}
function getDomain(serverUrl) {
try {
const url = new URL(serverUrl);
return url.hostname;
} catch (error) {
log(`Invalid server URL: ${error.message}`);
throw error;
}
}
function getApiBasePath() {
const path = window.location.pathname || '/';
const staticIndex = path.indexOf('/static/');
if (staticIndex === -1) return '';
const base = path.slice(0, staticIndex).replace(/\/+$/, '');
return base === '/' ? '' : base;
}
function withApiBase(pathname) {
const base = getApiBasePath();
const normalizedPath = (pathname || '/').startsWith('/') ? pathname : `/${pathname}`;
if (!base) return normalizedPath;
if (normalizedPath === base || normalizedPath.startsWith(`${base}/`)) return normalizedPath;
return `${base}${normalizedPath}`;
}
let _phoneConfig = { wsPath: '/ws', iceServersPath: '/iceservers', amiPath: '/ami/v1', staticPath: '/static' };
async function detectServerConfig() {
try {
const response = await fetch(withApiBase('/api/config/phone'));
if (response.ok) {
const cfg = await response.json();
_phoneConfig = cfg;
return cfg;
}
} catch (e) {}
return _phoneConfig;
}
const defaultServerUrl = (() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host || 'localhost:8080';
const urlParams = new URLSearchParams(window.location.search);
const customWsPath = urlParams.get('ws') || localStorage.getItem('phone_ws_path');
if (customWsPath && /^wss?:\/\//i.test(customWsPath)) return customWsPath;
const pathRaw = customWsPath || (serverInput && serverInput.dataset && serverInput.dataset.defaultPath) || _phoneConfig.wsPath;
const path = withApiBase(pathRaw);
return `${protocol}//${host}${path}`;
})();
if (serverInput) {
serverInput.placeholder = defaultServerUrl;
if (!serverInput.value || serverInput.value === 'ws://localhost:8080/ws' || serverInput.value === 'wss://localhost:8080/ws') {
serverInput.value = defaultServerUrl;
}
}
(function applyUrlParams() {
const params = new URLSearchParams(window.location.search);
const caller = params.get('caller');
const callee = params.get('callee');
if (caller) {
if (usernameInput) usernameInput.value = caller;
if (displayNameInput) displayNameInput.value = caller;
}
if (callee) {
if (callTargetInput) callTargetInput.value = callee;
}
})();
if (ccWsUrlInput) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host || 'localhost:8080';
ccWsUrlInput.value = `${protocol}//${host}${withApiBase(_phoneConfig.wsPath + '/ccphone')}`;
}
function formatMs(ms) {
if (ms == null) return '--';
if (ms < 1000) return ms + ' ms';
return (ms / 1000).toFixed(2) + ' s';
}
function formatBytes(bytes) {
if (bytes == null) return '--';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function resetIceStats(message = '<em>No ICE data yet.</em>') {
if (iceStatsPanel) iceStatsPanel.innerHTML = message;
}
function resetIceDiagnostics(message = '<em>No diagnostics yet.</em>') {
if (iceDiagResults) iceDiagResults.innerHTML = message;
}
function appendIceDiagnostic(message) {
if (!iceDiagResults) return;
if (iceDiagResults.querySelector('em')) iceDiagResults.innerHTML = '';
const line = document.createElement('div');
const timestamp = new Date().toLocaleTimeString();
line.textContent = `[${timestamp}] ${message}`;
iceDiagResults.appendChild(line);
iceDiagResults.scrollTop = iceDiagResults.scrollHeight;
}
async function writeTextToClipboard(text) {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text);
return;
}
const helper = document.createElement('textarea');
helper.value = text;
helper.setAttribute('readonly', '');
helper.style.position = 'fixed';
helper.style.opacity = '0';
helper.style.pointerEvents = 'none';
document.body.appendChild(helper);
helper.focus();
helper.select();
const success = document.execCommand('copy');
document.body.removeChild(helper);
if (!success) {
throw new Error('clipboard API unavailable');
}
}
function setButtonFeedback(button, stateClass, label) {
if (!button) {
return;
}
const originalTitle = button.dataset.originalTitle || button.title || '';
const originalAriaLabel = button.dataset.originalAriaLabel || button.getAttribute('aria-label') || '';
button.dataset.originalTitle = originalTitle;
button.dataset.originalAriaLabel = originalAriaLabel;
button.classList.remove('copied', 'copy-failed');
if (stateClass) {
button.classList.add(stateClass);
}
if (label) {
button.title = label;
button.setAttribute('aria-label', label);
}
window.setTimeout(() => {
button.classList.remove('copied', 'copy-failed');
button.title = originalTitle;
button.setAttribute('aria-label', originalAriaLabel || originalTitle);
}, 1200);
}
async function copyPanelText(panel, button, emptyMessage, successLabel) {
const text = panel && typeof panel.innerText === 'string'
? panel.innerText.trim()
: '';
if (!text) {
setButtonFeedback(button, 'copy-failed', emptyMessage);
return;
}
try {
await writeTextToClipboard(text);
setButtonFeedback(button, 'copied', successLabel);
} catch (error) {
setButtonFeedback(button, 'copy-failed', 'Copy Failed');
console.error('Copy to clipboard failed:', error);
}
}
function parseIceServersConfig(rawValue) {
const parsed = JSON.parse(rawValue);
const list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.iceServers)) ? parsed.iceServers : parsed ? [parsed] : [];
const normalized = list.filter((item) => item && item.urls);
if (!normalized.length) throw new Error('iceServers is empty or invalid');
return normalized;
}
const relayOnlyCheckbox = document.getElementById('relayOnly');
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function getIceServerUrls(server) {
if (!server || !server.urls) return [];
return Array.isArray(server.urls) ? server.urls : [server.urls];
}
function hasTurnIceServers(iceServers) {
return Array.isArray(iceServers) && iceServers.some((server) => {
return getIceServerUrls(server).some((url) => typeof url === 'string' && /^turns?:/i.test(url));
});
}
function getEnabledPcConfigFromUi() {
if (!iceEnable || !iceEnable.checked) return null;
if (!iceServersInput) throw new Error('ICE servers input not found');
const iceServers = parseIceServersConfig(iceServersInput.value);
const config = { iceServers };
if (relayOnlyCheckbox && relayOnlyCheckbox.checked) config.iceTransportPolicy = 'relay';
return config;
}
function getSessionPcConfig(session) {
if (session && session._icePcConfig) return session._icePcConfig;
try {
return getEnabledPcConfigFromUi();
} catch (error) {
return null;
}
}
function buildIceWaitPolicy(pcConfig) {
const iceServers = Array.isArray(pcConfig && pcConfig.iceServers) ? pcConfig.iceServers : [];
const relayOnly = Boolean(pcConfig && pcConfig.iceTransportPolicy === 'relay');
const hasTurn = hasTurnIceServers(iceServers);
if (relayOnly) {
return {
kind: 'relay',
description: 'waiting for relay candidate (relay-only mode)',
allowPublicFallback: false
};
}
if (hasTurn) {
return {
kind: 'relay',
description: 'waiting for relay candidate (TURN configured)',
allowPublicFallback: true
};
}
if (iceServers.length) {
return {
kind: 'public',
description: 'waiting for public candidate (srflx/relay)',
allowPublicFallback: false
};
}
return {
kind: 'complete',
description: 'waiting for browser ICE completion',
allowPublicFallback: false
};
}
async function runIceDiagnostics() {
if (iceDiagInProgress) { appendIceDiagnostic('Diagnostics already running'); return; }
if (!window.RTCPeerConnection) { appendIceDiagnostic('RTCPeerConnection is not supported'); return; }
let pcConfig;
try { pcConfig = getEnabledPcConfigFromUi(); } catch (error) {
appendIceDiagnostic(`ICE config invalid: ${error.message}`); return;
}
if (!pcConfig) { appendIceDiagnostic('ICE servers are disabled'); return; }
const startedAt = Date.now();
const candidates = [];
let closed = false, timeoutId = null, pc = null;
const finish = (reason) => {
if (closed) return;
closed = true; iceDiagInProgress = false;
if (runIceDiagBtn) runIceDiagBtn.disabled = false;
if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
if (pc) { try { pc.close(); } catch (e) {} }
const elapsed = Date.now() - startedAt;
const typeCounts = candidates.reduce((acc, c) => { acc[c.type || 'unknown'] = (acc[c.type || 'unknown'] || 0) + 1; return acc; }, {});
const summary = Object.keys(typeCounts).length ? Object.keys(typeCounts).map((k) => `${k}:${typeCounts[k]}`).join(', ') : 'none';
appendIceDiagnostic(`Finished (${reason}) in ${formatMs(elapsed)}; candidates=${candidates.length}; types=${summary}`);
if (!candidates.some((c) => c.type === 'relay')) appendIceDiagnostic('No relay candidate found');
};
resetIceDiagnostics('<em>Running ICE diagnostics...</em>');
appendIceDiagnostic(`Using ${pcConfig.iceServers.length} configured ICE server(s)`);
iceDiagInProgress = true;
if (runIceDiagBtn) runIceDiagBtn.disabled = true;
try {
pc = new RTCPeerConnection(pcConfig);
pc.createDataChannel('ice-diagnostics');
pc.addEventListener('icegatheringstatechange', () => {
appendIceDiagnostic(`iceGatheringState=${pc.iceGatheringState}`);
if (pc.iceGatheringState === 'complete') finish('ice gathering complete');
});
pc.addEventListener('icecandidateerror', (event) => {
appendIceDiagnostic(`icecandidateerror url=${event && event.url || 'unknown'} code=${event && event.errorCode || 'unknown'}`);
});
pc.addEventListener('icecandidate', (event) => {
if (!event || !event.candidate) { finish('null candidate'); return; }
const parsed = parseIceCandidate(event.candidate.candidate, event.candidate);
candidates.push(parsed);
const addrText = parsed.address ? `${parsed.address}${parsed.port ? `:${parsed.port}` : ''}` : 'address unavailable';
appendIceDiagnostic(`candidate #${candidates.length}: ${parsed.type || 'unknown'} ${addrText}`);
});
timeoutId = setTimeout(() => finish('timeout'), 10000);
const offer = await pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false });
await pc.setLocalDescription(offer);
} catch (error) {
appendIceDiagnostic(`Diagnostics failed: ${error.message}`);
finish('error');
}
}
async function loadDefaultIceServers() {
if (!iceServersInput || iceServersManuallyEdited) return;
try {
const icePath = _phoneConfig.iceServersPath || '/iceservers';
const response = await fetch(withApiBase(icePath), { cache: 'no-store' });
if (!response.ok) throw new Error(`status ${response.status}`);
const payload = await response.json();
const servers = Array.isArray(payload) ? payload : (payload && Array.isArray(payload.iceServers)) ? payload.iceServers : payload ? [payload] : null;
if (!servers || !servers.length) throw new Error('empty ICE server list');
if (iceServersManuallyEdited) return;
iceServersInput.value = JSON.stringify(servers);
if (iceEnable) iceEnable.checked = true;
log(`Loaded ICE servers from ${withApiBase(icePath)}`);
} catch (error) {
const icePath = _phoneConfig.iceServersPath || '/iceservers';
log(`Failed to load ${withApiBase(icePath)}: ${error.message}`);
}
}
const ICE_READY_SETTLE_MS = 250;
const ICE_READY_MAX_WAIT_MS = 2000;
const WEBRTC_STATS_INTERVAL = 1000;
function getIceTracker(session, label = 'session') {
if (!session) return null;
if (!session._iceTracker) {
session._iceTracker = {
label,
candidates: [],
startedAt: null,
endedAt: null,
gatherDuration: null,
status: 'waiting for candidates',
readyCallback: null,
readyTimer: null,
maxWaitTimer: null,
initialSdpSent: false,
publicSeen: false,
relaySeen: false,
policy: null
};
} else if (label && session._iceTracker.label !== label) session._iceTracker.label = label;
session._iceCandidates = session._iceTracker.candidates;
refreshIceTrackerPolicy(session, session._iceTracker);
return session._iceTracker;
}
function refreshIceTrackerPolicy(session, tracker) {
if (!tracker) return null;
const nextPolicy = buildIceWaitPolicy(getSessionPcConfig(session));
const previous = tracker.policy;
tracker.policy = nextPolicy;
if (!previous || previous.kind !== nextPolicy.kind || previous.description !== nextPolicy.description) {
if (!tracker.startedAt && !tracker.initialSdpSent) tracker.status = nextPolicy.description;
}
return nextPolicy;
}
function clearIceTrackerTimers(tracker) {
if (!tracker) return;
if (tracker.readyTimer) {
clearTimeout(tracker.readyTimer);
tracker.readyTimer = null;
}
if (tracker.maxWaitTimer) {
clearTimeout(tracker.maxWaitTimer);
tracker.maxWaitTimer = null;
}
}
function isPublicCandidateType(type) {
return type === 'relay' || type === 'srflx';
}
function describeIceWaitReason(tracker, source) {
const policy = tracker && tracker.policy ? tracker.policy : { kind: 'complete', allowPublicFallback: false };
if (source === 'settle') {
if (policy.kind === 'relay') return 'target relay candidate gathered';
if (policy.kind === 'public') return 'target public candidate gathered';
}
if (policy.kind === 'relay') {
if (tracker && tracker.relaySeen) return `${source}: relay candidate available`;
if (tracker && tracker.publicSeen && policy.allowPublicFallback) return `${source}: no relay candidate, falling back to public candidate`;
if (tracker && tracker.candidates.length) return `${source}: no relay/public candidate, sending gathered host candidates`;
return `${source}: no relay candidate gathered`;
}
if (policy.kind === 'public') {
if (tracker && tracker.publicSeen) return `${source}: public candidate available`;
if (tracker && tracker.candidates.length) return `${source}: no public candidate, sending gathered host candidates`;
return `${source}: no public candidate gathered`;
}
if (tracker && tracker.candidates.length) return `${source}: browser ICE completion not observed`;
return `${source}: no ICE candidate gathered`;
}
function startInitialSdpWait(session, tracker) {
if (!tracker) return;
const policy = refreshIceTrackerPolicy(session, tracker);
if (!tracker.startedAt) {
tracker.startedAt = Date.now();
tracker.status = policy ? policy.description : 'waiting for candidates';
}
if (!tracker.maxWaitTimer && !tracker.initialSdpSent && typeof tracker.readyCallback === 'function') {
tracker.maxWaitTimer = setTimeout(() => {
tracker.maxWaitTimer = null;
releaseInitialSdpWait(session, describeIceWaitReason(tracker, 'max wait reached'));
}, ICE_READY_MAX_WAIT_MS);
}
updateIceStatsDisplay(session);
}
function concludeIceWaitWithoutReady(session, reason) {
const tracker = session && session._iceTracker;
if (!tracker || tracker.initialSdpSent || tracker.endedAt) return;
tracker.endedAt = Date.now();
tracker.gatherDuration = tracker.startedAt ? (tracker.endedAt - tracker.startedAt) : null;
tracker.status = reason;
clearIceTrackerTimers(tracker);
updateIceStatsDisplay(session);
}
function releaseInitialSdpWait(session, reason) {
const tracker = session && session._iceTracker;
if (!tracker || tracker.initialSdpSent || typeof tracker.readyCallback !== 'function') return false;
clearIceTrackerTimers(tracker);
tracker.initialSdpSent = true;
tracker.endedAt = Date.now();
tracker.gatherDuration = tracker.startedAt ? (tracker.endedAt - tracker.startedAt) : null;
tracker.status = `sending SDP (${reason})`;
updateIceStatsDisplay(session);
try {
tracker.readyCallback();
return true;
} catch (error) {
tracker.initialSdpSent = false;
tracker.status = `ready() failed: ${error.message}`;
updateIceStatsDisplay(session);
return false;
}
}
function scheduleInitialSdpRelease(session, reason) {
const tracker = session && session._iceTracker;
if (!tracker || tracker.initialSdpSent || typeof tracker.readyCallback !== 'function') return;
if (tracker.readyTimer) return;
tracker.status = 'candidate matched, settling before SDP send';
updateIceStatsDisplay(session);
tracker.readyTimer = setTimeout(() => {
tracker.readyTimer = null;
releaseInitialSdpWait(session, reason);
}, ICE_READY_SETTLE_MS);
}
function updateIceStatsDisplay(session, statusText) {
if (!iceStatsPanel) return;
const tracker = getIceTracker(session, session && (session._iceLabel || `${session.direction || 'session'}`));
if (!tracker) return;
if (statusText) tracker.status = statusText;
const parts = [
`<div><strong>Call:</strong> ${escapeHtml(tracker.label)}</div>`,
`<div><strong>Candidates:</strong> ${tracker.candidates.length}</div>`
];
if (tracker.gatherDuration != null) parts.push(`<div><strong>Gathering time:</strong> ${formatMs(tracker.gatherDuration)}</div>`);
if (tracker.status) parts.push(`<div><strong>Status:</strong> ${tracker.status}</div>`);
if (tracker.candidates.length) {
const rows = tracker.candidates.map((c, i) => {
const primaryAddress = c.address ? `${c.address}${c.port ? `:${c.port}` : ''}` : 'address unavailable';
const relayInfo = c.relatedAddress ? ` via ${c.relatedAddress}${c.relatedPort ? `:${c.relatedPort}` : ''}` : '';
return escapeHtml(`${i + 1}. ${c.type || 'unknown'} ${primaryAddress}${relayInfo ? ` (${relayInfo.trim()})` : ''} (+${formatMs(c.ts)})`);
});
parts.push('<hr>');
parts.push(rows.join('<br>'));
}
iceStatsPanel.innerHTML = parts.join('');
}
function parseIceCandidate(candidateString, candidateObject) {
const out = { raw: candidateString };
try {
if (candidateObject) {
out.address = candidateObject.address || candidateObject.ip || null;
out.port = typeof candidateObject.port === 'number' ? candidateObject.port : parseInt(candidateObject.port, 10) || null;
out.type = candidateObject.type || null;
out.protocol = candidateObject.protocol || null;
}
if (typeof candidateString === 'string' && candidateString.length) {
const parts = candidateString.trim().split(/\s+/);
if (!out.address && parts.length >= 6) {
out.address = parts[4] || null;
const parsedPort = parseInt(parts[5], 10);
out.port = out.port || (Number.isNaN(parsedPort) ? null : parsedPort);
}
if (!out.type) {
const typeIndex = parts.indexOf('typ');
if (typeIndex > -1 && parts[typeIndex + 1]) out.type = parts[typeIndex + 1];
}
if (!out.protocol && parts.length >= 3) out.protocol = parts[2];
const raddrIndex = parts.indexOf('raddr');
const rportIndex = parts.indexOf('rport');
if (!out.relatedAddress && raddrIndex > -1 && parts[raddrIndex + 1]) out.relatedAddress = parts[raddrIndex + 1];
if (!out.relatedPort && rportIndex > -1 && parts[rportIndex + 1]) {
const parsedRport = parseInt(parts[rportIndex + 1], 10);
out.relatedPort = Number.isNaN(parsedRport) ? null : parsedRport;
}
}
} catch (error) {}
return out;
}
function handleSessionIceCandidate(session, candidateObj, readyCallback) {
if (!session || !candidateObj) return;
const label = session._iceLabel || `${session.direction || 'session'}`;
const tracker = getIceTracker(session, label);
if (typeof readyCallback === 'function') tracker.readyCallback = readyCallback;
const candidateString = candidateObj.candidate || '';
if (!candidateString) return;
const now = Date.now();
if (!tracker.startedAt) startInitialSdpWait(session, tracker);
const parsed = parseIceCandidate(candidateString, candidateObj);
const entry = { ts: tracker.startedAt ? now - tracker.startedAt : 0, candidate: parsed.raw, type: parsed.type, address: parsed.address, port: parsed.port, relatedAddress: parsed.relatedAddress, relatedPort: parsed.relatedPort };
if (entry.candidate && tracker.candidates.some((c) => c.candidate === entry.candidate)) { updateIceStatsDisplay(session); return; }
tracker.candidates.push(entry);
if (parsed.type === 'relay') tracker.relaySeen = true;
if (isPublicCandidateType(parsed.type)) tracker.publicSeen = true;
if (tracker.policy && tracker.policy.kind === 'relay' && tracker.relaySeen) {
scheduleInitialSdpRelease(session, describeIceWaitReason(tracker, 'preferred candidate gathered'));
} else if (tracker.policy && tracker.policy.kind === 'public' && tracker.publicSeen) {
scheduleInitialSdpRelease(session, describeIceWaitReason(tracker, 'preferred candidate gathered'));
} else {
startInitialSdpWait(session, tracker);
}
updateIceStatsDisplay(session);
}
function register() {
if (ua) { log('Reusing existing UA, stopping first.'); ua.stop(); resetRegistrationState(); }
const server = serverInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
const displayName = displayNameInput.value.trim();
if (!server || !username) { showStatus('Please enter server and username', 'error'); return; }
let domain;
try { domain = getDomain(server); } catch (error) { showStatus('Server URL is invalid', 'error'); return; }
registerBtn.disabled = true;
unregisterBtn.disabled = true;
showStatus('Connecting...', 'info');
log('Attempting to register via JsSIP...');
const socket = new JsSIP.WebSocketInterface(server);
const configuration = { sockets: [socket], uri: `sip:${username}@${domain}`, password: password, display_name: displayName || username, register: true, instance_id: pageInstanceId };
try {
const pcConfig = getEnabledPcConfigFromUi();
if (pcConfig) { configuration.pcConfig = pcConfig; log(`Using custom ICE servers for UA: ${JSON.stringify(configuration.pcConfig)}`); }
else log('ICE servers disabled (using default)');
} catch (e) {
showStatus(`ICE servers config invalid: ${e.message}`, 'error');
log(`Error applying ICE servers for UA: ${e.message}`);
registerBtn.disabled = false; unregisterBtn.disabled = true; return;
}
ua = new JsSIP.UA(configuration);
ua.on('connected', () => { log('WebSocket connected'); showStatus('Connected', 'success'); });
ua.on('disconnected', () => {
log('WebSocket disconnected');
if (isRegistered) showStatus('Disconnected', 'error');
resetRegistrationState();
});
ua.on('registered', () => {
isRegistered = true;
log('Registered successfully');
showStatus('Registered', 'success');
registerBtn.disabled = true;
unregisterBtn.disabled = false;
callBtn.disabled = false;
});
ua.on('unregistered', () => { log('Unregistered from server'); showStatus('Unregistered', 'info'); resetRegistrationState(); });
ua.on('registrationFailed', (data) => {
log(`Registration failed: ${data.cause}`);
showStatus(`Registration failed: ${data.cause}`, 'error');
ua.stop(); resetRegistrationState();
});
ua.on('newRTCSession', (data) => {
const session = data.session;
if (currentSession && consultSession) {
log('Already handling two sessions, rejecting the new one');
session.terminate({ status_code: 486, reason_phrase: 'Busy Here' });
return;
}
const isIncoming = data.originator === 'remote';
incomingCallDiv.classList.remove('active');
const remoteIdentity = session.remote_identity || {};
const remoteUser = remoteIdentity.display_name || (remoteIdentity.uri && remoteIdentity.uri.user) || '';
const outgoingName = lastOutgoingCallLabel || callTargetInput.value.trim() || remoteUser || 'unknown';
const incomingName = remoteUser || 'unknown';
session._iceLabel = isIncoming ? `incoming:${incomingName}` : `outgoing:${outgoingName}`;
resetIceStats(`<div><strong>Call:</strong> ${session._iceLabel}</div><div><strong>Status:</strong> waiting for candidates</div>`);
session._timing = {};
if (data.originator === 'local') session._timing.callSent = lastOutgoingCallTime || Date.now();
else session._timing.incomingReceived = Date.now();
if (currentSession && !consultSession) {
consultSession = session;
bindConsultSessionEvents(session);
log(`Consult call started to ${outgoingName}`);
updateCcStatus('Consult call in progress');
} else {
currentSession = session;
bindSessionEvents(session, isIncoming);
if (isIncoming) {
callerInfo.textContent = `Incoming call from: ${incomingName}`;
incomingCallDiv.classList.add('active');
log(`Incoming call from ${incomingName}`);
} else {
callInfo.textContent = `Calling ${outgoingName}...`;
callControls.classList.add('active');
}
}
});
ua.start();
}
function unregister() {
if (!ua) return;
log('Unregistering from server...');
if (currentSession) hangup();
ua.unregister(); ua.stop(); resetRegistrationState(); showStatus('Unregistered', 'info');
}
function resetRegistrationState() {
isRegistered = false;
registerBtn.disabled = false;
unregisterBtn.disabled = true;
callBtn.disabled = true;
endCall();
ua = null;
}
async function makeCall() {
if (!ua || !isRegistered) { log('UA is not registered'); return; }
const target = callTargetInput.value.trim();
if (!target) { showStatus('Please enter a call target', 'error'); return; }
let domain;
try { domain = getDomain(serverInput.value); } catch (error) { showStatus('Server URL is invalid', 'error'); return; }
const destination = target.startsWith('sip:') ? target : `sip:${target}@${domain}`;
log(`Calling ${destination}`);
const videoEnabled = enableVideoCheckbox && enableVideoCheckbox.checked;
let callOptions;
if (videoEnabled) {
try {
localMediaStream = await getVideoSourceStream();
if (localVideo) { localVideo.srcObject = localMediaStream; videoContainer.classList.add('active'); }
callOptions = { mediaStream: localMediaStream, rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: true } };
log('Video source stream acquired');
} catch (e) {
log(`Failed to get video stream: ${e.message} - falling back to audio-only`);
callOptions = { mediaConstraints: { audio: true, video: false }, rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false } };
}
} else {
callOptions = { mediaConstraints: { audio: true, video: false }, rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false } };
}
try {
const pcConfig = getEnabledPcConfigFromUi();
if (pcConfig) {
callOptions.pcConfig = pcConfig;
const relayNote = pcConfig.iceTransportPolicy === 'relay' ? ' [relay only]' : '';
log(`Applied ICE servers for outgoing call: ${pcConfig.iceServers.length} configured${relayNote}`);
}
} catch (error) {
showStatus(`ICE servers config invalid: ${error.message}`, 'error');
log(`Cannot place call with invalid ICE config: ${error.message}`);
return;
}
lastOutgoingCallTime = Date.now();
lastOutgoingCallLabel = target;
ua.call(destination, callOptions);
log(`Invite sent successfully (video: ${videoEnabled})`);
callBtn.disabled = true;
}
async function answerCall() {
if (!currentSession) return;
log('Answering incoming call');
const offerHasVideo = currentSession && currentSession.request && currentSession.request.body && currentSession.request.body.includes('m=video');
const videoEnabled = offerHasVideo || (enableVideoCheckbox && enableVideoCheckbox.checked);
let options;
if (videoEnabled) {
try {
localMediaStream = await getVideoSourceStream();
if (localVideo) { localVideo.srcObject = localMediaStream; videoContainer.classList.add('active'); }
options = { mediaStream: localMediaStream, rtcAnswerConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: true } };
log('Video source stream acquired');
} catch (e) {
log(`Failed to get video stream: ${e.message} - answering audio-only`);
options = { mediaConstraints: { audio: true, video: false }, rtcAnswerConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false } };
}
} else {
options = { mediaConstraints: { audio: true, video: false }, rtcAnswerConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false } };
}
try {
const pcConfig = getEnabledPcConfigFromUi();
if (pcConfig) {
options.pcConfig = pcConfig;
const relayNote = pcConfig.iceTransportPolicy === 'relay' ? ' [relay only]' : '';
log(`Applied ICE servers for incoming answer: ${pcConfig.iceServers.length} configured${relayNote}`);
}
} catch (error) {
showStatus(`ICE servers config invalid: ${error.message}`, 'error');
log(`Cannot answer call with invalid ICE config: ${error.message}`);
return;
}
try {
currentSession._timing = currentSession._timing || {};
currentSession._timing.answerTime = Date.now();
currentSession.answer(options);
incomingCallDiv.classList.remove('active');
callControls.classList.add('active');
notifyCcCallAction('call.answer', currentSession);
} catch (error) {
log(`Failed to answer call: ${error.message}`);
endCall();
}
}
function rejectCall() {
if (!currentSession) return;
log('Rejecting incoming call');
notifyCcCallAction('call.reject', currentSession);
currentSession.terminate({ status_code: 486, reason_phrase: 'Busy Here' });
endCall();
}
function hangup() {
if (!currentSession) return;
log('Hanging up call');
notifyCcCallAction('call.hangup', currentSession);
try { currentSession.terminate(); } catch (error) { log(`Error terminating session: ${error.message}`); }
endCall();
}
function toggleMute() {
if (!currentSession) { log('No active session to mute'); return; }
try {
if (isMuted) { currentSession.unmute({ audio: true }); muteBtn.textContent = 'Mute'; log('Audio unmuted'); }
else { currentSession.mute({ audio: true }); muteBtn.textContent = 'Unmute'; log('Audio muted'); }
isMuted = !isMuted;
} catch (error) { log(`Error toggling mute: ${error.message}`); }
}
function toggleHold() {
if (!currentSession) { log('No active session to hold'); return; }
try {
if (typeof currentSession.isEstablished === 'function' && !currentSession.isEstablished()) { log('Call is not yet established, cannot toggle hold'); return; }
if (isOnHold) { currentSession.unhold(); holdBtn.textContent = 'Hold'; log('Call resumed'); }
else { currentSession.hold(); holdBtn.textContent = 'Unhold'; log('Call placed on hold'); }
isOnHold = !isOnHold;
} catch (error) { log(`Error toggling hold: ${error.message}`); }
}
function updateCcStatus(message, type = 'info') {
ccStatus.className = `status ${type}`;
ccStatus.textContent = message;
}
function updateCcControls() {
const hasPrimary = !!currentSession;
const hasConsult = !!consultSession;
const primaryEstablished = hasPrimary && currentSession.isEstablished && currentSession.isEstablished();
const consultEstablished = hasConsult && consultSession.isEstablished && consultSession.isEstablished();
ccHoldBtn.disabled = !primaryEstablished || isOnHold;
ccUnholdBtn.disabled = !primaryEstablished || !isOnHold;
ccTransferBtn.disabled = !primaryEstablished;
ccConsultBtn.disabled = !primaryEstablished || hasConsult;
ccMergeBtn.disabled = !(primaryEstablished && consultEstablished);
ccSwapBtn.disabled = !(primaryEstablished && consultEstablished);
}
function ccHold(hold) {
if (!currentSession) return;
try {
if (hold) { currentSession.hold(); isOnHold = true; log('CC: Primary call held'); }
else { currentSession.unhold(); isOnHold = false; log('CC: Primary call resumed'); }
updateCcControls();
} catch (error) { log(`CC Hold error: ${error.message}`); }
}
function showTransferDialog() {
if (!currentSession) { log('No active call to transfer'); return; }
transferDialog.classList.add('active');
dialogTransferTarget.value = transferTargetInput.value || '';
dialogTransferTarget.focus();
}
function hideTransferDialog() {
transferDialog.classList.remove('active');
}
function doTransfer() {
const target = dialogTransferTarget.value.trim();
if (!target) { log('Please enter transfer target'); return; }
hideTransferDialog();
ccBlindTransfer(target);
}
function ccBlindTransfer(target) {
if (!currentSession) { log('No active call to transfer'); return; }
const transferTo = target || transferTargetInput.value.trim();
if (!transferTo) { log('Please enter transfer target'); return; }
try {
let domain;
try { domain = getDomain(serverInput.value); } catch (e) { domain = 'localhost'; }
const referTo = transferTo.startsWith('sip:') ? transferTo : `sip:${transferTo}@${domain}`;
log(`Initiating blind transfer to ${referTo}`);
const referToUri = new JsSIP.URI('sip', transferTo.startsWith('sip:') ? transferTo.replace('sip:', '').split('@')[0] : transferTo, domain);
currentSession.refer(referTo, {
eventHandlers: {
requestSucceeded: () => { log('Transfer REFER accepted'); },
requestFailed: (data) => { log(`Transfer REFER failed: ${data.cause}`); },
trying: () => { log('Transfer: trying...'); },
progress: () => { log('Transfer: in progress...'); },
accepted: () => { log('Transfer: accepted by target'); endCall(); },
failed: (data) => { log(`Transfer failed: ${data.cause}`); }
}
});
updateCcStatus('Transfer in progress...', 'warning');
} catch (error) {
log(`Transfer error: ${error.message}`);
updateCcStatus(`Transfer failed: ${error.message}`, 'error');
}
}
function startConsultation() {
if (!currentSession) { log('No active primary call'); return; }
if (consultSession) { log('Consult call already in progress'); return; }
const target = transferTargetInput.value.trim();
if (!target) { log('Please enter consult target in Transfer/Consult Target field'); return; }
try {
if (!isOnHold) { currentSession.hold(); isOnHold = true; log('Primary call held for consultation'); }
} catch (e) { log(`Error holding primary: ${e.message}`); }
let domain;
try { domain = getDomain(serverInput.value); } catch (e) { domain = 'localhost'; }
const destination = target.startsWith('sip:') ? target : `sip:${target}@${domain}`;
log(`Starting consultation call to ${destination}`);
const callOptions = {
mediaConstraints: { audio: true, video: false },
rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false }
};
try {
const pcConfig = getEnabledPcConfigFromUi();
if (pcConfig) callOptions.pcConfig = pcConfig;
} catch (e) {}
lastOutgoingCallTime = Date.now();
lastOutgoingCallLabel = target;
ua.call(destination, callOptions);
updateCcStatus('Consultation call in progress...', 'warning');
}
function mergeCalls() {
if (!currentSession || !consultSession) { log('Need both primary and consult calls to merge'); return; }
log('Merging calls for 3-way conference (sending REFER with Replaces)');
try {
const primaryRemote = currentSession.remote_identity && currentSession.remote_identity.uri;
if (!primaryRemote) { log('Cannot get primary call remote URI'); return; }
const callId = currentSession.dialog && currentSession.dialog.call_id || '';
const localTag = currentSession.dialog && currentSession.dialog.local_tag || '';
const remoteTag = currentSession.dialog && currentSession.dialog.remote_tag || '';
const replaces = encodeURIComponent(`${callId};to-tag=${remoteTag};from-tag=${localTag}`);
const referTo = `sip:${primaryRemote.user}@${primaryRemote.host}?Replaces=${replaces}`;
consultSession.refer(referTo, {
eventHandlers: {
requestSucceeded: () => { log('Merge REFER accepted'); },
requestFailed: (data) => { log(`Merge REFER failed: ${data.cause}`); },
accepted: () => {
log('3-way conference established');
updateCcStatus('3-way conference active', 'success');
},
failed: (data) => { log(`Merge failed: ${data.cause}`); }
}
});
} catch (error) {
log(`Merge error: ${error.message}`);
}
}
function swapCalls() {
if (!currentSession || !consultSession) { log('Need both calls to swap'); return; }
log('Swapping active call...');
try {
if (isOnHold) { currentSession.unhold(); isOnHold = false; }
else { currentSession.hold(); isOnHold = true; }
if (isConsultOnHold) { consultSession.unhold(); isConsultOnHold = false; }
else { consultSession.hold(); isConsultOnHold = true; }
log('Calls swapped');
} catch (error) { log(`Swap error: ${error.message}`); }
}
function bindConsultSessionEvents(session) {
session.on('progress', () => { log('Consult call: Establishing'); });
session.on('accepted', () => { log('Consult call: Accepted'); });
session.on('confirmed', () => {
log('Consult call: Established');
updateCcStatus('Consult call connected', 'success');
updateCcControls();
startStatsMonitoring();
});
session.on('ended', (data) => {
log(`Consult call ended: ${data.cause}`);
clearIceTrackerTimers(session._iceTracker);
consultSession = null;
isConsultOnHold = false;
updateCcControls();
updateCcStatus('Consult call ended', 'info');
});
session.on('failed', (data) => {
log(`Consult call failed: ${data.cause}`);
clearIceTrackerTimers(session._iceTracker);
consultSession = null;
isConsultOnHold = false;
updateCcControls();
updateCcStatus(`Consult failed: ${data.cause}`, 'error');
});
session.on('hold', () => { isConsultOnHold = true; log('Consult call held'); });
session.on('unhold', () => { isConsultOnHold = false; log('Consult call resumed'); });
session.on('peerconnection', (data) => {
attachMedia(data.peerconnection);
try {
const fallbackRemote = (session.remote_identity && session.remote_identity.uri && session.remote_identity.uri.user) || 'peer';
const label = session._iceLabel || `consult:${fallbackRemote}`;
monitorIceGatheringJs(session, data.peerconnection, label);
} catch (e) {}
});
if (session.connection) {
attachMedia(session.connection);
try {
const fallbackRemote = (session.remote_identity && session.remote_identity.uri && session.remote_identity.uri.user) || 'peer';
const label = session._iceLabel || `consult:${fallbackRemote}`;
monitorIceGatheringJs(session, session.connection, label);
} catch (e) {}
}
session.on('icecandidate', (event) => {
try { handleSessionIceCandidate(session, event ? event.candidate : null, event ? event.ready : null); } catch (error) {}
});
}
function bindSessionEvents(session, isIncoming) {
callBtn.disabled = true;
session.on('progress', () => {
log('Call state: Establishing');
if (!isIncoming) callInfo.textContent = 'Ringing...';
});
session.on('accepted', () => { log('Call state: Accepted'); });
session.on('confirmed', () => {
log('Call state: Established');
callInfo.textContent = `Connected to ${session.remote_identity.display_name || session.remote_identity.uri.user}`;
updateCcStatus('Call connected', 'success');
updateCcControls();
startStatsMonitoring();
dtmfHistory = '';
const dtmfSection = document.getElementById('dtmfSection');
const dtmfDisplay = document.getElementById('dtmfDisplay');
if (dtmfSection) dtmfSection.style.display = '';
if (dtmfDisplay) dtmfDisplay.innerHTML = ' ';
});
session.on('confirmed', () => {
try {
const t = session._timing || {};
const remoteUser = (session.remote_identity && session.remote_identity.uri && session.remote_identity.uri.user) || 'peer';
if (t.callSent) log(`Outgoing handshake time to ${remoteUser}: ${formatMs(Date.now() - t.callSent)}`);
else {
const base = t.answerTime || t.incomingReceived;
if (base) log(`Incoming handshake time from ${remoteUser}: ${formatMs(Date.now() - base)}`);
}
} catch (e) { log(`handshake timing error: ${e.message}`); }
});
session.on('ended', (data) => {
const directionLabel = isIncoming ? 'Incoming' : 'Outgoing';
const cause = data && data.cause ? ` (${data.cause})` : '';
log(`${directionLabel} call state: Terminated${cause}`);
endCall();
});
session.on('failed', (data) => {
const directionLabel = isIncoming ? 'Incoming' : 'Outgoing';
const cause = data && data.cause ? ` (${data.cause})` : '';
log(`${directionLabel} call state: Failed${cause}`);
showStatus(`Call failed: ${data.cause}`, 'error');
endCall();
});
session.on('hold', () => { isOnHold = true; holdBtn.textContent = 'Unhold'; log('Call is on hold'); updateCcControls(); });
session.on('unhold', () => { isOnHold = false; holdBtn.textContent = 'Hold'; log('Call is resumed from hold'); updateCcControls(); });
session.on('muted', () => { isMuted = true; muteBtn.textContent = 'Unmute'; log('Call audio muted'); });
session.on('unmuted', () => { isMuted = false; muteBtn.textContent = 'Mute'; log('Call audio unmuted'); });
session.on('refer', (data) => {
log(`Received REFER to ${data.refer_to}`);
updateCcStatus(`Received transfer request to ${data.refer_to}`, 'warning');
});
session.on('reinvite', (data) => {
log('Received re-INVITE');
logSipMessage('RECEIVED', 're-INVITE', JSON.stringify(data.request && data.request.body ? data.request.body.substring(0, 200) : 'no body'));
});
session.on('peerconnection', (data) => {
attachMedia(data.peerconnection);
try {
const fallbackRemote = (session.remote_identity && session.remote_identity.uri && session.remote_identity.uri.user) || 'peer';
const label = session._iceLabel || `${session.direction || 'session'}:${fallbackRemote}`;
monitorIceGatheringJs(session, data.peerconnection, label);
} catch (e) {}
});
if (session.connection) {
log('bindSessionEvents: PeerConnection already exists, attaching media immediately');
attachMedia(session.connection);
try {
const fallbackRemote = (session.remote_identity && session.remote_identity.uri && session.remote_identity.uri.user) || 'peer';
const label = session._iceLabel || `${session.direction || 'session'}:${fallbackRemote}`;
monitorIceGatheringJs(session, session.connection, label);
} catch (e) {}
}
session.on('icecandidate', (event) => {
try { handleSessionIceCandidate(session, event ? event.candidate : null, event ? event.ready : null); } catch (error) {}
});
session.on('sdp', (data) => {
logSipMessage(data.originator === 'local' ? 'SENT' : 'RECEIVED', 'SDP', data.sdp.substring(0, 300));
});
}
function attachMedia(peerconnection) {
const remoteAudioStream = new MediaStream();
const remoteVideoStream = new MediaStream();
peerconnection.addEventListener('track', (event) => {
const { track } = event;
if (track.kind === 'audio') { remoteAudioStream.addTrack(track); remoteAudio.srcObject = remoteAudioStream; log('Remote audio track attached'); }
else if (track.kind === 'video') { remoteVideoStream.addTrack(track); remoteVideo.srcObject = remoteVideoStream; videoContainer.classList.add('active'); log('Remote video track attached'); }
});
}
function sdpHasTelephoneEvent(sdp) {
if (!sdp || typeof sdp !== 'string') return false;
return /a=rtpmap:\d+\s+telephone-event\/\d+/i.test(sdp);
}
async function sendDtmfRfc2833Verified(session, tone) {
const pc = session && session.connection;
if (!pc) throw new Error('No RTCPeerConnection available');
const localSdp = (pc.localDescription && pc.localDescription.sdp) || '';
const remoteSdp = (pc.remoteDescription && pc.remoteDescription.sdp) || '';
const localHasTelEvent = sdpHasTelephoneEvent(localSdp);
const remoteHasTelEvent = sdpHasTelephoneEvent(remoteSdp);
if (!localHasTelEvent || !remoteHasTelEvent) {
throw new Error(`SDP telephone-event not fully negotiated (local=${localHasTelEvent}, remote=${remoteHasTelEvent})`);
}
const audioSender = pc.getSenders().find(s => s.track && s.track.kind === 'audio');
if (!audioSender) throw new Error('No audio sender found');
const dtmfSender = audioSender.dtmf;
if (!dtmfSender) throw new Error('RTCDTMFSender not available on this sender');
if (!dtmfSender.canInsertDTMF) throw new Error('RTCDTMFSender cannot insert DTMF currently');
await new Promise((resolve, reject) => {
let settled = false;
const originalOnToneChange = dtmfSender.ontonechange;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
dtmfSender.ontonechange = originalOnToneChange;
reject(new Error('No tonechange confirmation from RTCDTMFSender'));
}, 1200);
dtmfSender.ontonechange = (event) => {
try {
if (typeof originalOnToneChange === 'function') originalOnToneChange(event);
} catch (e) {}
if (!settled && event && event.tone === tone) {
settled = true;
clearTimeout(timer);
dtmfSender.ontonechange = originalOnToneChange;
resolve();
}
};
try {
dtmfSender.insertDTMF(tone, 160, 50);
} catch (err) {
if (settled) return;
settled = true;
clearTimeout(timer);
dtmfSender.ontonechange = originalOnToneChange;
reject(err);
}
});
}
function sendDtmfInfo(session, tone) {
session.sendDTMF(tone, {
duration: 160,
interToneGap: 50
});
}
function shouldFallbackToInfo(error) {
const msg = (error && error.message ? error.message : '').toLowerCase();
return (
msg.includes('sdp telephone-event not fully negotiated') ||
msg.includes('rtcdtmfsender not available') ||
msg.includes('rtcdtmfsender cannot insert dtmf')
);
}
async function sendDtmf(tone) {
if (!currentSession) { log('No active call for DTMF'); return; }
try {
const preferredTransport = (dtmfTransportSelect && dtmfTransportSelect.value) || 'RFC2833';
let usedTransport = preferredTransport;
console.warn(`[DTMF] Attempting to send: tone=${tone}, transport=${preferredTransport}, session=${currentSession.id}`);
if (preferredTransport === 'RFC2833') {
try {
await sendDtmfRfc2833Verified(currentSession, tone);
} catch (rfcError) {
if (shouldFallbackToInfo(rfcError)) {
console.warn(`[DTMF] RFC2833 unsupported, fallback to INFO: ${rfcError.message}`);
log(`! RFC2833 unsupported (${rfcError.message}), fallback to INFO`);
sendDtmfInfo(currentSession, tone);
usedTransport = 'INFO(fallback)';
} else {
throw rfcError;
}
}
} else {
sendDtmfInfo(currentSession, tone);
}
dtmfHistory += tone;
const dtmfDisplay = document.getElementById('dtmfDisplay');
if (dtmfDisplay) dtmfDisplay.textContent = dtmfHistory || '\u00a0';
log(`✓ DTMF sent (${usedTransport}): ${tone}`);
console.warn(`[DTMF] ✓ Successfully sent DTMF: ${tone}, used=${usedTransport}`);
} catch (error) {
log(`✗ DTMF error: ${error.message}`);
console.error(`[DTMF] ✗ Error: ${error.message}`, error);
}
}
function endCall() {
clearIceTrackerTimers(currentSession && currentSession._iceTracker);
clearIceTrackerTimers(consultSession && consultSession._iceTracker);
callControls.classList.remove('active');
dtmfHistory = '';
const dtmfSection = document.getElementById('dtmfSection');
const dtmfDisplay = document.getElementById('dtmfDisplay');
if (dtmfSection) dtmfSection.style.display = 'none';
if (dtmfDisplay) dtmfDisplay.innerHTML = ' ';
incomingCallDiv.classList.remove('active');
callInfo.textContent = '';
videoContainer.classList.remove('active');
stopLocalVideoSource();
if (remoteAudio.srcObject) { remoteAudio.srcObject.getTracks().forEach((track) => track.stop()); remoteAudio.srcObject = null; }
if (localAudio.srcObject) { localAudio.srcObject.getTracks().forEach((track) => track.stop()); localAudio.srcObject = null; }
if (remoteVideo.srcObject) { remoteVideo.srcObject.getTracks().forEach((track) => track.stop()); remoteVideo.srcObject = null; }
if (localVideo.srcObject) { localVideo.srcObject.getTracks().forEach((track) => track.stop()); localVideo.srcObject = null; }
currentSession = null;
consultSession = null;
isMuted = false;
isOnHold = false;
isConsultOnHold = false;
muteBtn.textContent = 'Mute';
holdBtn.textContent = 'Hold';
callBtn.disabled = !isRegistered;
resetIceStats();
stopStatsMonitoring();
resetStats();
updateCcStatus('No active CC call', 'info');
updateCcControls();
}
function startStatsMonitoring() {
if (statsInterval) clearInterval(statsInterval);
statsInterval = setInterval(refreshStats, 2000);
}
function stopStatsMonitoring() {
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
}
async function refreshStats() {
const session = currentSession || consultSession;
if (!session || !session.connection) return;
try {
const stats = await session.connection.getStats();
let rxAudio = null, txAudio = null;
stats.forEach((report) => {
if (report.type === 'inbound-rtp' && (report.mediaType === 'audio' || report.kind === 'audio')) rxAudio = report;
if (report.type === 'outbound-rtp' && (report.mediaType === 'audio' || report.kind === 'audio')) txAudio = report;
});
if (rxAudio) {
rxPackets.textContent = rxAudio.packetsReceived || 0;
rxBytes.textContent = formatBytes(rxAudio.bytesReceived);
const jitter = rxAudio.jitter ? (rxAudio.jitter * 1000).toFixed(2) : '--';
jitterValue.textContent = jitter;
const packetsLost = rxAudio.packetsLost || 0;
const totalPackets = (rxAudio.packetsReceived || 0) + packetsLost;
const lossPercent = totalPackets > 0 ? ((packetsLost / totalPackets) * 100).toFixed(2) : '0.00';
lossValue.textContent = `${lossPercent}%`;
if (parseFloat(lossPercent) < 1) lossValue.className = 'stat-value good';
else if (parseFloat(lossPercent) < 5) lossValue.className = 'stat-value warning';
else lossValue.className = 'stat-value bad';
}
if (txAudio) {
txPackets.textContent = txAudio.packetsSent || 0;
txBytes.textContent = formatBytes(txAudio.bytesSent);
}
const mos = calculateMOS(rxAudio);
if (mos) {
mosValue.textContent = mos.toFixed(2);
const mosPercent = (mos / 4.5) * 100;
mosBar.style.width = `${mosPercent}%`;
if (mos >= 4) { mosValue.className = 'stat-value good'; mosBar.className = 'quality-bar-fill good'; }
else if (mos >= 3) { mosValue.className = 'stat-value warning'; mosBar.className = 'quality-bar-fill warning'; }
else { mosValue.className = 'stat-value bad'; mosBar.className = 'quality-bar-fill bad'; }
}
stats.forEach((report) => {
if (report.type === 'candidate-pair' && report.currentRoundTripTime) {
const rtt = (report.currentRoundTripTime * 1000).toFixed(0);
rttValue.textContent = rtt;
if (rtt < 150) rttValue.className = 'stat-value good';
else if (rtt < 300) rttValue.className = 'stat-value warning';
else rttValue.className = 'stat-value bad';
}
});
} catch (error) {
log(`Stats error: ${error.message}`);
}
}
function calculateMOS(rxAudio) {
if (!rxAudio) return null;
const jitter = rxAudio.jitter ? rxAudio.jitter * 1000 : 0;
const packetsLost = rxAudio.packetsLost || 0;
const packetsReceived = rxAudio.packetsReceived || 0;
const totalPackets = packetsReceived + packetsLost;
const lossRate = totalPackets > 0 ? packetsLost / totalPackets : 0;
const delay = jitter * 2; const Id = delay > 177.3 ? 0.024 * delay + 0.11 * (delay - 177.3) : delay * 0.024;
const Ie = 0; const Bpl = 4.3; const Ie_eff = Ie + (95 - Ie) * (lossRate / (lossRate + Bpl));
const R = 93.2 - Id - Ie_eff;
let mos;
if (R < 0) mos = 1;
else if (R > 100) mos = 4.5;
else mos = 1 + 0.035 * R + R * (R - 60) * (100 - R) * 7 * 0.000001;
return Math.max(1, Math.min(4.5, mos));
}
function resetStats() {
mosValue.textContent = '--';
mosBar.style.width = '0%';
rttValue.textContent = '--';
lossValue.textContent = '--';
jitterValue.textContent = '--';
rxPackets.textContent = '--';
txPackets.textContent = '--';
rxBytes.textContent = '--';
txBytes.textContent = '--';
[mosValue, rttValue, lossValue, jitterValue, rxPackets, txPackets, rxBytes, txBytes].forEach(el => el.className = 'stat-value');
}
function monitorIceGatheringJs(session, pc, label = 'session') {
try {
if (!pc || session._iceMonitorAttached) return;
session._iceMonitorAttached = true;
try {
if (typeof pc.getConfiguration === 'function') session._icePcConfig = pc.getConfiguration();
} catch (error) {}
const tracker = getIceTracker(session, label);
updateIceStatsDisplay(session, tracker.status);
pc.addEventListener('icegatheringstatechange', () => {
const state = pc.iceGatheringState;
if (state === 'gathering' && !tracker.startedAt && typeof tracker.readyCallback === 'function') startInitialSdpWait(session, tracker);
if (state === 'complete') concludeIceWaitWithoutReady(session, describeIceWaitReason(tracker, 'browser ICE complete'));
else updateIceStatsDisplay(session);
});
updateIceStatsDisplay(session, tracker.status);
} catch (e) { log(`monitorIceGatheringJs error: ${e.message}`); }
}
function startClockPreview() {
if (!clockCanvas) return;
if (videoCanvasInterval) { clearInterval(videoCanvasInterval); videoCanvasInterval = null; }
const ctx = clockCanvas.getContext('2d');
drawTestPattern(ctx, clockCanvas.width, clockCanvas.height);
videoCanvasInterval = setInterval(() => drawTestPattern(ctx, clockCanvas.width, clockCanvas.height), 100);
if (videoPreviewDiv) videoPreviewDiv.style.display = '';
}
function stopClockPreview() {
if (videoCanvasInterval) { clearInterval(videoCanvasInterval); videoCanvasInterval = null; }
if (videoPreviewDiv) videoPreviewDiv.style.display = 'none';
}
function stopLocalVideoSource() {
if (localMediaStream) { localMediaStream.getTracks().forEach((t) => t.stop()); localMediaStream = null; }
if (localVideo) localVideo.srcObject = null;
}
function drawTestPattern(ctx, width, height) {
const grad = ctx.createLinearGradient(0, 0, 0, height);
grad.addColorStop(0, '#0d1b2a');
grad.addColorStop(1, '#1b2838');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, width, height);
const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
ctx.font = `bold ${Math.floor(height * 0.22)}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#00e5ff';
ctx.shadowColor = '#00e5ff';
ctx.shadowBlur = 14;
ctx.fillText(timeStr, width / 2, height * 0.42);
ctx.shadowBlur = 0;
ctx.font = `${Math.floor(height * 0.09)}px monospace`;
ctx.fillStyle = '#90a4ae';
ctx.fillText(now.toLocaleDateString(), width / 2, height * 0.65);
ctx.font = `${Math.floor(height * 0.07)}px sans-serif`;
ctx.fillStyle = '#546e7a';
ctx.fillText('rustpbx virtual camera', width / 2, height * 0.88);
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.shadowBlur = 0;
}
async function getVideoSourceStream() {
const source = document.querySelector('input[name="videoSource"]:checked');
const sourceValue = source ? source.value : 'camera';
if (sourceValue === 'camera') {
const deviceId = cameraSelect && cameraSelect.value;
const videoConstraint = deviceId ? { deviceId: { exact: deviceId } } : true;
return navigator.mediaDevices.getUserMedia({ audio: true, video: videoConstraint });
}
if (!clockCanvas) throw new Error('Clock canvas element not found');
if (!videoCanvasInterval) startClockPreview();
const canvasStream = clockCanvas.captureStream(15);
const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
return new MediaStream([...audioStream.getAudioTracks(), ...canvasStream.getVideoTracks()]);
}
async function enumerateCameras() {
if (!cameraSelect) return;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter((d) => d.kind === 'videoinput');
cameraSelect.innerHTML = '';
if (cameras.length === 0) { cameraSelect.innerHTML = '<option value="">No cameras found</option>'; return; }
cameras.forEach((cam, i) => { const opt = document.createElement('option'); opt.value = cam.deviceId; opt.textContent = cam.label || `Camera ${i + 1}`; cameraSelect.appendChild(opt); });
log(`Found ${cameras.length} camera(s)`);
} catch (e) { log(`Failed to enumerate cameras: ${e.message}`); }
}
function connectCcWebSocket() {
if (ccWs) { log('CC WebSocket already connected'); return; }
const rawUrl = ccWsUrlInput.value.trim();
if (!rawUrl) { log('Please enter CC WebSocket URL'); return; }
const agentId = usernameInput.value.trim();
if (!agentId) { log('Please input username(agent_id) first'); return; }
let finalUrl = rawUrl;
try {
const u = new URL(rawUrl, window.location.href);
if (!u.searchParams.get('agent_id')) {
u.searchParams.set('agent_id', agentId);
}
finalUrl = u.toString();
} catch (e) {
const glue = rawUrl.includes('?') ? '&' : '?';
if (!rawUrl.includes('agent_id=')) {
finalUrl = `${rawUrl}${glue}agent_id=${encodeURIComponent(agentId)}`;
}
}
ccWsUrlInput.value = finalUrl;
log(`Connecting to CC WebSocket: ${finalUrl}`);
ccWs = new WebSocket(finalUrl);
ccWs.onopen = () => {
log('CC WebSocket connected');
ccWsStatus.className = 'status success';
ccWsStatus.textContent = 'CC WS: connected';
logCcWs('Connected', 'info');
const registerMsg = { action: 'agent.register', skills: [], max_concurrency: 1 };
ccWs.send(JSON.stringify(registerMsg));
logCcWs(`Sent: ${JSON.stringify(registerMsg)}`, 'info');
const statusMsg = { action: 'status.update', status: 'idle' };
ccWs.send(JSON.stringify(statusMsg));
logCcWs(`Sent: ${JSON.stringify(statusMsg)}`, 'info');
};
ccWs.onmessage = (event) => {
logCcWs(`Received: ${event.data}`, 'info');
try {
const msg = JSON.parse(event.data);
handleCcMessage(msg);
} catch (e) {
logCcWs(`Raw message: ${event.data}`, 'warn');
}
};
ccWs.onerror = (error) => {
log('CC WebSocket error');
ccWsStatus.className = 'status error';
ccWsStatus.textContent = 'CC WS: error';
logCcWs('Error occurred', 'error');
};
ccWs.onclose = () => {
log('CC WebSocket disconnected');
ccWsStatus.className = 'status info';
ccWsStatus.textContent = 'CC WS: disconnected';
logCcWs('Disconnected', 'warn');
ccWs = null;
};
}
function disconnectCcWebSocket() {
if (!ccWs) return;
ccWs.close();
ccWs = null;
}
function logCcWs(message, type = 'info') {
const entry = document.createElement('div');
entry.className = 'debug-entry';
const timestamp = new Date().toLocaleTimeString();
entry.innerHTML = `<span class="debug-time">[${timestamp}]</span> <span class="debug-${type}">${message}</span>`;
ccWsLog.appendChild(entry);
ccWsLog.scrollTop = ccWsLog.scrollHeight;
}
function handleCcMessage(msg) {
const eventType = msg.event || msg.type;
if (eventType === 'call.ringing') {
updateCcStatus(`CC: Call ringing - ${msg.call_id || '-'}`, 'warning');
} else if (eventType === 'call.connected') {
updateCcStatus(`CC: Call connected - ${msg.call_id || '-'}`, 'success');
} else if (eventType === 'call.ended') {
updateCcStatus(`CC: Call ended - ${msg.call_id || '-'}`, 'info');
} else if (eventType === 'agent.status_changed') {
log(`CC Agent status: ${msg.status || 'unknown'}`);
} else if (eventType === 'error') {
updateCcStatus(`CC error: ${msg.message || 'unknown error'}`, 'error');
}
}
function sendRawCcCommand() {
if (!ccWs || ccWs.readyState !== WebSocket.OPEN) {
log('CC WebSocket not connected');
return;
}
const raw = ccRawCommandInput ? ccRawCommandInput.value.trim() : '';
if (!raw) {
log('Please input CC command JSON');
return;
}
try {
const parsed = JSON.parse(raw);
ccWs.send(JSON.stringify(parsed));
logCcWs(`Sent: ${JSON.stringify(parsed)}`, 'info');
} catch (e) {
log(`Invalid CC command JSON: ${e.message}`);
}
}
function getSessionCallId(session) {
if (!session) return null;
if (session.id) return session.id;
if (session._id) return session._id;
if (session.request && session.request.call_id) return session.request.call_id;
if (session.request && session.request.headers && session.request.headers['Call-ID']) {
const hdr = session.request.headers['Call-ID'];
if (Array.isArray(hdr) && hdr[0] && hdr[0].raw) return hdr[0].raw;
}
if (session.dialog && session.dialog.id && session.dialog.id.call_id) return session.dialog.id.call_id;
if (session.dialog && session.dialog.call_id) return session.dialog.call_id;
return null;
}
function notifyCcCallAction(action, session) {
if (!ccWs || ccWs.readyState !== WebSocket.OPEN) return;
const callId = getSessionCallId(session);
if (!callId) return;
const payload = { action, call_id: callId };
ccWs.send(JSON.stringify(payload));
logCcWs(`Sent: ${JSON.stringify(payload)}`, 'info');
}
function updateCallStateDebug() {
const html = [];
if (currentSession) {
html.push(`<div class="debug-entry"><strong>Primary Call:</strong></div>`);
html.push(`<div class="debug-entry"> Direction: ${currentSession.direction || 'unknown'}</div>`);
html.push(`<div class="debug-entry"> State: ${currentSession.status || 'unknown'}</div>`);
html.push(`<div class="debug-entry"> Established: ${currentSession.isEstablished ? currentSession.isEstablished() : 'unknown'}</div>`);
html.push(`<div class="debug-entry"> On Hold: ${isOnHold}</div>`);
html.push(`<div class="debug-entry"> Muted: ${isMuted}</div>`);
if (currentSession.remote_identity) {
const ri = currentSession.remote_identity;
html.push(`<div class="debug-entry"> Remote: ${ri.display_name || ''} <${ri.uri || 'unknown'}></div>`);
}
if (currentSession.connection) {
html.push(`<div class="debug-entry"> ICE State: ${currentSession.connection.iceConnectionState}</div>`);
html.push(`<div class="debug-entry"> Conn State: ${currentSession.connection.connectionState}</div>`);
}
} else {
html.push(`<div class="debug-entry">No primary call</div>`);
}
if (consultSession) {
html.push(`<div class="debug-entry"><strong>Consult Call:</strong></div>`);
html.push(`<div class="debug-entry"> Direction: ${consultSession.direction || 'unknown'}</div>`);
html.push(`<div class="debug-entry"> State: ${consultSession.status || 'unknown'}</div>`);
html.push(`<div class="debug-entry"> Established: ${consultSession.isEstablished ? consultSession.isEstablished() : 'unknown'}</div>`);
html.push(`<div class="debug-entry"> On Hold: ${isConsultOnHold}</div>`);
}
callStateDebug.innerHTML = html.join('');
}
function logSipMessage(direction, method, body) {
const entry = document.createElement('div');
entry.className = 'debug-entry';
const timestamp = new Date().toLocaleTimeString();
const dirClass = direction === 'SENT' ? 'debug-info' : 'debug-warn';
entry.innerHTML = `<span class="debug-time">[${timestamp}]</span> <span class="${dirClass}">${direction} ${method}</span> ${body}`;
sipMessagesLog.appendChild(entry);
sipMessagesLog.scrollTop = sipMessagesLog.scrollHeight;
sipMessageLog.push({ time: timestamp, direction, method, body });
if (sipMessageLog.length > 100) sipMessageLog.shift();
}
window.addEventListener('beforeunload', () => { if (ua) ua.stop(); if (ccWs) ccWs.close(); });
loadDefaultIceServers();
enumerateCameras();
log('CC SIP Phone initialized');
</script>
</body>
</html>