<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIP Phone(jssip version 3.10.0)</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1100px;
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 320px;
display: flex;
flex-direction: column;
gap: 20px;
}
.section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
}
.section h3 {
margin-top: 0;
color: #333;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"],
input[type="password"],
input[type="url"],
input[type="tel"],
input[type="number"],
textarea,
button {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
input[type="checkbox"] {
width: auto;
margin-right: 8px;
}
input::placeholder,
textarea::placeholder {
color: rgba(148, 163, 184, 0.7);
opacity: 1;
}
label.checkbox-inline {
display: inline-flex;
align-items: center;
gap: 8px;
}
button {
background: #007bff;
color: white;
cursor: pointer;
margin-top: 10px;
}
button:hover {
background: #0056b3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 4px;
}
.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;
}
.call-controls {
display: none;
}
.call-controls.active {
display: block;
}
.logs {
height: 300px;
overflow-y: auto;
background: #f8f9fa;
padding: 10px;
border: 1px solid #ddd;
font-family: monospace;
font-size: 12px;
}
.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;
}
@media (max-width: 768px) {
body {
max-width: 100%;
}
.layout {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="container">
<h1>SIP Phone(jssip version 3.10.0)</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>
<textarea id="iceServers" rows="3"
style="width:100%;">[{"urls":"stun:stun.l.google.com:19302"}]</textarea>
</div>
<button id="registerBtn">Register</button>
<button id="unregisterBtn" disabled>Unregister</button>
<div id="registrationStatus"></div>
</div>
<div class="section">
<h3>Logs</h3>
<div class="logs" id="logs"></div>
<div style="margin-top: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>
<button id="callBtn" disabled>Call</button>
<div class="call-controls" id="callControls">
<div class="call-info" id="callInfo"></div>
<button id="hangupBtn">Hang Up</button>
<button id="muteBtn">Mute</button>
<button id="holdBtn">Hold</button>
</div>
</div>
<div class="incoming-call" id="incomingCall">
<h3>Incoming Call</h3>
<div id="callerInfo"></div>
<button id="answerBtn">Answer</button>
<button id="rejectBtn">Reject</button>
</div>
<div class="section">
<h3>ICE Stats</h3>
<div class="logs" id="iceStats" style="height:220px;">
<em>No ICE data yet.</em>
</div>
<audio id="remoteAudio" autoplay style="display:none;"></audio>
<audio id="localAudio" muted autoplay style="display:none;"></audio>
</div>
</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 isMuted = false;
let isOnHold = false;
let isRegistered = false;
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 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 logs = document.getElementById('logs');
const iceStatsPanel = document.getElementById('iceStats');
const iceEnable = document.getElementById('iceEnable');
const iceServersInput = document.getElementById('iceServers');
let lastOutgoingCallTime = null;
let lastOutgoingCallLabel = null;
let iceServersManuallyEdited = false;
if (iceServersInput) {
iceServersInput.addEventListener('input', () => {
iceServersManuallyEdited = true;
});
}
const defaultServerUrl = (() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host || 'localhost:8080';
const pathRaw = (serverInput && serverInput.dataset && serverInput.dataset.defaultPath) || '/ws';
const path = pathRaw.startsWith('/') ? pathRaw : `/${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 formatMs(ms) {
if (ms == null) return '--';
if (ms < 1000) return ms + ' ms';
return (ms / 1000).toFixed(2) + ' s';
}
function resetIceStats(message = '<em>No ICE data yet.</em>') {
if (iceStatsPanel) {
iceStatsPanel.innerHTML = message;
}
}
resetIceStats();
async function loadDefaultIceServers() {
if (!iceServersInput || iceServersManuallyEdited) {
return;
}
try {
const response = await fetch('/iceservers', { 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 /iceservers');
} catch (error) {
log(`Failed to load /iceservers: ${error.message}`);
}
}
const ICE_INACTIVITY_TIMEOUT = 2500;
function getIceTracker(session, label = 'session') {
if (!session) {
return null;
}
if (!session._iceTracker) {
session._iceTracker = {
label,
candidates: [],
startedAt: null,
endedAt: null,
inactivityTimer: null,
gatherDuration: null,
status: 'waiting for candidates'
};
} else if (label && session._iceTracker.label !== label) {
session._iceTracker.label = label;
}
session._iceCandidates = session._iceTracker.candidates;
return session._iceTracker;
}
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> ${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 `${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 concludeIceGathering(session, reason) {
const tracker = session && session._iceTracker;
if (!tracker || tracker.endedAt) {
return;
}
tracker.endedAt = Date.now();
tracker.gatherDuration = tracker.startedAt ? (tracker.endedAt - tracker.startedAt) : null;
const readableReason = reason ? reason.replace(/_/g, ' ') : 'completed';
tracker.status = readableReason;
session._iceGatherDuration = tracker.gatherDuration;
log(`${tracker.label} - ICE gathering finished (${readableReason})${tracker.gatherDuration != null ? `, duration=${formatMs(tracker.gatherDuration)}` : ''}`);
if (tracker.inactivityTimer) {
clearTimeout(tracker.inactivityTimer);
tracker.inactivityTimer = null;
}
updateIceStatsDisplay(session);
}
function scheduleIceInactivityTimeout(session) {
const tracker = session && session._iceTracker;
if (!tracker) {
return;
}
if (tracker.inactivityTimer) {
clearTimeout(tracker.inactivityTimer);
}
tracker.inactivityTimer = setTimeout(() => concludeIceGathering(session, 'inactivity timeout'), ICE_INACTIVITY_TIMEOUT);
}
function handleSessionIceCandidate(session, candidateObj) {
if (!session || !candidateObj) {
concludeIceGathering(session, 'no candidate');
return;
}
const label = session._iceLabel || `${session.direction || 'session'}`;
const tracker = getIceTracker(session, label);
const candidateString = candidateObj.candidate || '';
if (!candidateString) {
concludeIceGathering(session, 'null candidate');
return;
}
const now = Date.now();
if (!tracker.startedAt) {
tracker.startedAt = now;
tracker.status = 'gathering';
log(`${tracker.label} - ICE gathering started`);
}
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)) {
scheduleIceInactivityTimeout(session);
updateIceStatsDisplay(session, 'gathering');
return;
}
tracker.candidates.push(entry);
const addrText = parsed.address ? `${parsed.address}${parsed.port ? `:${parsed.port}` : ''}` : 'address unavailable';
log(`${tracker.label} - candidate #${tracker.candidates.length}: ${parsed.type || 'unknown'} ${addrText} (+${formatMs(entry.ts)})`);
updateIceStatsDisplay(session, 'gathering');
scheduleIceInactivityTimeout(session);
}
registerBtn.addEventListener('click', register);
unregisterBtn.addEventListener('click', unregister);
callBtn.addEventListener('click', makeCall);
hangupBtn.addEventListener('click', hangup);
muteBtn.addEventListener('click', toggleMute);
holdBtn.addEventListener('click', toggleHold);
answerBtn.addEventListener('click', answerCall);
rejectBtn.addEventListener('click', rejectCall);
function log(message) {
const timestamp = new Date().toLocaleTimeString();
logs.innerHTML += `[${timestamp}] ${message}<br>`;
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 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
};
try {
if (iceEnable && iceEnable.checked) {
let parsed = null;
try {
parsed = JSON.parse(iceServersInput.value);
} catch (e) {
parsed = [{ urls: 'stun:stun.l.google.com:19302' }];
}
configuration.pcConfig = { iceServers: parsed };
log(`Using custom ICE servers: ${JSON.stringify(configuration.pcConfig)}`);
} else {
log('ICE servers disabled (using default)');
}
} catch (e) {
log(`Error applying ICE servers for UA: ${e.message}`);
}
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) {
log('Already handling a session, 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();
}
currentSession = session;
bindSessionEvents(session, isIncoming);
if (isIncoming) {
const caller = incomingName;
callerInfo.textContent = `Incoming call from: ${caller}`;
incomingCallDiv.classList.add('active');
log(`Incoming call from ${caller}`);
} else {
callInfo.textContent = `Calling ${outgoingName}...`;
callControls.classList.add('active');
}
});
ua.start();
}
function monitorIceGatheringJs(session, pc, label = 'session') {
try {
if (!pc || session._iceMonitorAttached) return;
session._iceMonitorAttached = true;
const tracker = getIceTracker(session, label);
const statsLabel = tracker.label;
updateIceStatsDisplay(session, tracker.status);
pc.addEventListener('icegatheringstatechange', () => {
const state = pc.iceGatheringState;
log(`${statsLabel} - iceGatheringState -> ${state}`);
if (state === 'gathering' && !tracker.startedAt) {
tracker.startedAt = Date.now();
tracker.status = 'gathering';
updateIceStatsDisplay(session);
}
if (state === 'complete' && tracker.startedAt && !tracker.endedAt) {
concludeIceGathering(session, 'ice gathering complete');
}
});
pc.addEventListener('iceconnectionstatechange', () => {
const state = pc.iceConnectionState;
log(`${statsLabel} - iceConnectionState -> ${state}`);
if ((state === 'connected' || state === 'completed') && tracker.startedAt && !tracker.endedAt) {
concludeIceGathering(session, 'ice connection completed');
}
});
pc.addEventListener('connectionstatechange', () => {
const state = pc.connectionState;
log(`${statsLabel} - connectionState -> ${state}`);
});
updateIceStatsDisplay(session, tracker.status);
} catch (e) {
log(`monitorIceGatheringJs error: ${e.message}`);
}
}
function unregister() {
if (!ua) {
return;
}
log('Unregistering from server...');
if (currentSession) {
hangup();
}
ua.unregister();
ua.stop();
resetRegistrationState();
showStatus('Unregistered', 'info');
}
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 options = {
mediaConstraints: { audio: true, video: false },
rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false }
};
lastOutgoingCallTime = Date.now();
lastOutgoingCallLabel = target;
ua.call(destination, options);
log('Invite sent successfully');
callBtn.disabled = true;
}
function answerCall() {
if (!currentSession) {
return;
}
log('Answering incoming call');
const options = {
mediaConstraints: { audio: true, video: false },
rtcAnswerConstraints: { mandatory: { OfferToReceiveAudio: true, OfferToReceiveVideo: false } }
};
try {
currentSession._timing = currentSession._timing || {};
currentSession._timing.answerTime = Date.now();
currentSession.answer(options);
incomingCallDiv.classList.remove('active');
callControls.classList.add('active');
} catch (error) {
log(`Failed to answer call: ${error.message}`);
endCall();
}
}
function rejectCall() {
if (!currentSession) {
return;
}
log('Rejecting incoming call');
currentSession.terminate({ status_code: 486, reason_phrase: 'Busy Here' });
endCall();
}
function hangup() {
if (!currentSession) {
return;
}
log('Hanging up call');
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 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}`;
});
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) {
const dur = Date.now() - t.callSent;
log(`Outgoing handshake time to ${remoteUser}: ${formatMs(dur)}`);
} else {
const base = t.answerTime || t.incomingReceived;
if (base) {
const dur = Date.now() - base;
log(`Incoming handshake time from ${remoteUser}: ${formatMs(dur)}`);
}
}
} 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');
});
session.on('unhold', () => {
isOnHold = false;
holdBtn.textContent = 'Hold';
log('Call is resumed from hold');
});
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('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);
} catch (error) {
log(`handleSessionIceCandidate error: ${error.message}`);
}
});
}
function attachMedia(peerconnection) {
const remoteStream = new MediaStream();
peerconnection.addEventListener('track', (event) => {
if (event.track.kind !== 'audio') {
return;
}
if (event.streams && event.streams.length > 0) {
remoteAudio.srcObject = event.streams[0];
} else {
remoteStream.addTrack(event.track);
remoteAudio.srcObject = remoteStream;
}
log('Remote audio stream attached');
});
const senders = peerconnection.getSenders();
if (senders && senders.length > 0) {
const localStream = new MediaStream();
senders.forEach((sender) => {
if (sender.track && sender.track.kind === 'audio') {
localStream.addTrack(sender.track);
}
});
if (localStream.getTracks().length > 0) {
localAudio.srcObject = localStream;
log('Local audio stream attached');
}
}
}
function endCall() {
callControls.classList.remove('active');
incomingCallDiv.classList.remove('active');
callInfo.textContent = '';
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;
}
currentSession = null;
isMuted = false;
isOnHold = false;
muteBtn.textContent = 'Mute';
holdBtn.textContent = 'Hold';
callBtn.disabled = !isRegistered;
resetIceStats();
}
function resetRegistrationState() {
isRegistered = false;
registerBtn.disabled = false;
unregisterBtn.disabled = true;
callBtn.disabled = true;
endCall();
ua = null;
}
window.addEventListener('beforeunload', () => {
if (ua) {
ua.stop();
}
});
loadDefaultIceServers();
log('SIP Phone initialized');
</script>
</body>
</html>