<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIP Phone(SIP.js version 0.20.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;
}
button {
background: #007bff;
color: white;
cursor: pointer;
margin-top: 10px;
}
button:hover {
background: #0056b3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
label.checkbox-inline {
display: inline-flex;
align-items: center;
gap: 8px;
}
.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(SIP.js version 0.20.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="alice" placeholder="alice">
</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="Alice" placeholder="Alice">
</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="bob" placeholder="bob">
</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="//cdnjs.cloudflare.com/ajax/libs/sip.js/0.20.0/sip.min.js"></script>
<script>
let userAgent = null;
let registerer = null;
let currentSession = null;
let isMuted = false;
let isOnHold = 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 lastOutgoingCallLabel = null;
let iceServersManuallyEdited = false;
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);
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 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 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}`);
}
}
function formatMs(ms) {
if (ms == null) return '--';
if (ms < 1000) return ms + ' ms';
return (ms / 1000).toFixed(2) + ' s';
}
function monitorIceGathering(session, label = 'session') {
try {
if (!session || session._iceMonitorAttached) return;
const sdh = session.sessionDescriptionHandler;
if (!sdh || !sdh.peerConnection) {
return;
}
const pc = sdh.peerConnection;
session._iceMonitorAttached = true;
const statsLabel = session._iceLabel || label;
session._iceCandidates = [];
session._iceGatherDuration = null;
if (iceStatsPanel) {
iceStatsPanel.innerHTML = `<div><strong>Call:</strong> ${statsLabel}</div><div><strong>Candidates:</strong> 0</div><div><strong>Status:</strong> waiting for candidates</div>`;
}
let start = null;
let end = null;
let inactivityTimer = null;
function renderStats(statusText) {
if (!iceStatsPanel) return;
const parts = [
`<div><strong>Call:</strong> ${statsLabel}</div>`,
`<div><strong>Candidates:</strong> ${session._iceCandidates.length}</div>`
];
if (session._iceGatherDuration != null) {
parts.push(`<div><strong>Gathering time:</strong> ${formatMs(session._iceGatherDuration)}</div>`);
}
if (statusText) {
parts.push(`<div><strong>Status:</strong> ${statusText}</div>`);
}
if (session._iceCandidates.length) {
const rows = session._iceCandidates.map((c, i) => {
const addressPort = [c.address || '', c.port ? `:${c.port}` : ''].join('');
return `${i + 1}. ${c.type || 'unknown'} ${addressPort.trim()} (+${formatMs(c.ts)})`;
});
parts.push('<hr>');
parts.push(rows.join('<br>'));
}
iceStatsPanel.innerHTML = parts.join('');
}
function concludeGathering(reason) {
if (end) return;
end = Date.now();
session._iceGatherDuration = start ? (end - start) : null;
const readableReason = reason ? reason.replace(/_/g, ' ') : 'completed';
log(`${statsLabel} - ICE gathering finished (${readableReason})${session._iceGatherDuration != null ? `, duration=${formatMs(session._iceGatherDuration)}` : ''}`);
renderStats(readableReason);
if (inactivityTimer) {
clearTimeout(inactivityTimer);
inactivityTimer = null;
}
}
function parseCandidate(candStr, candObj) {
const out = { raw: candStr };
try {
if (candObj) {
out.address = candObj.address || candObj.ip || null;
out.port = candObj.port || null;
out.type = candObj.type || null;
out.protocol = candObj.protocol || null;
}
if (!out.address && typeof candStr === 'string') {
const ipMatch = candStr.match(/ (\d+\.\d+\.\d+\.\d+) (\d+) /);
if (ipMatch) {
out.address = ipMatch[1];
out.port = ipMatch[2];
}
const typeMatch = candStr.match(/ typ (\w+)/);
if (typeMatch) {
out.type = typeMatch[1];
}
}
} catch (error) {
}
return out;
}
pc.addEventListener('icecandidate', (event) => {
if (event && event.candidate) {
const now = Date.now();
if (!start) {
start = now;
log(`${statsLabel} - ICE gathering started`);
}
const parsed = parseCandidate(event.candidate.candidate, event.candidate);
const entry = {
ts: start ? now - start : 0,
candidate: parsed.raw,
type: parsed.type,
address: parsed.address,
port: parsed.port
};
session._iceCandidates.push(entry);
log(`${statsLabel} - candidate #${session._iceCandidates.length}: ${parsed.type || 'unknown'} ${parsed.address || ''}${parsed.port ? `:${parsed.port}` : ''} (+${formatMs(entry.ts)})`);
renderStats('gathering');
if (inactivityTimer) clearTimeout(inactivityTimer);
inactivityTimer = setTimeout(() => concludeGathering('inactivity timeout'), 2500);
} else {
if (!end && start) {
concludeGathering('null candidate');
} else if (!start) {
concludeGathering('no candidates');
}
}
});
pc.addEventListener('icegatheringstatechange', () => {
const state = pc.iceGatheringState;
log(`${statsLabel} - iceGatheringState -> ${state}`);
if (state === 'gathering' && !start) {
start = Date.now();
renderStats('gathering');
}
if (state === 'complete' && start && !end) {
concludeGathering('ice gathering complete');
}
});
pc.addEventListener('iceconnectionstatechange', () => {
const state = pc.iceConnectionState;
log(`${statsLabel} - iceConnectionState -> ${state}`);
if ((state === 'connected' || state === 'completed') && start && !end) {
concludeGathering('ice connection completed');
}
});
pc.addEventListener('connectionstatechange', () => {
const state = pc.connectionState;
log(`${statsLabel} - connectionState -> ${state}`);
});
renderStats('waiting for candidates');
} catch (e) {
log(`monitorIceGathering error: ${e.message}`);
}
}
function setupMediaElements(session) {
try {
if (!session.sessionDescriptionHandler || !session.sessionDescriptionHandler.peerConnection) {
log('Session description handler not available yet');
return;
}
const peerConnection = session.sessionDescriptionHandler.peerConnection;
peerConnection.ontrack = (event) => {
log(`Received remote track: kind=${event.track.kind} id=${event.track.id} readyState=${event.track.readyState}`);
if (event.track.kind === 'audio') {
const remoteStream = new MediaStream([event.track]);
remoteAudio.srcObject = remoteStream;
log('Remote audio stream attached via ontrack event');
}
};
const remoteStream = new MediaStream();
peerConnection.getReceivers().forEach(receiver => {
if (receiver.track && receiver.track.kind === 'audio') {
remoteStream.addTrack(receiver.track);
log(`Found existing receiver track: id=${receiver.track.id} readyState=${receiver.track.readyState}`);
}
});
if (remoteStream.getTracks().length > 0) {
remoteAudio.srcObject = remoteStream;
log('Remote audio stream attached from existing receivers');
}
const senders = peerConnection.getSenders();
if (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');
}
}
} catch (error) {
log(`Error setting up media elements: ${error.message}`);
}
}
function register() {
const server = serverInput.value;
const username = usernameInput.value;
const password = passwordInput.value;
const displayName = displayNameInput.value;
if (!server || !username) {
showStatus('Please enter server and username', 'error');
return;
}
log('Attempting to register...');
const uri = SIP.UserAgent.makeURI(`sip:${username}@${server.replace('ws://', '').replace('wss://', '').split('/')[0]}`);
const userAgentOptions = {
uri: uri,
transportOptions: {
server: server
},
authorizationPassword: password,
displayName: displayName,
delegate: {
onConnect: () => {
log('Connected to server');
showStatus('Connected', 'success');
},
onDisconnect: (error) => {
log(`Disconnected: ${error ? error.message : 'Unknown error'}`);
showStatus('Disconnected', 'error');
resetUI();
},
onInvite: (invitation) => {
log(`Incoming call from ${invitation.remoteIdentity.displayName || invitation.remoteIdentity.uri.user}`);
showIncomingCall(invitation);
}
}
};
try {
if (iceEnable && iceEnable.checked) {
let parsed = null;
try {
parsed = JSON.parse(iceServersInput.value);
} catch (e) {
parsed = [{ urls: 'stun:stun.l.google.com:19302' }];
}
userAgentOptions.sessionDescriptionHandlerFactoryOptions = {
peerConnectionConfiguration: { iceServers: parsed }
};
log(`Using custom ICE servers: ${JSON.stringify(userAgentOptions.sessionDescriptionHandlerFactoryOptions.peerConnectionConfiguration)}`);
} else {
log('ICE servers disabled (using default)');
}
} catch (e) {
log(`Error applying ICE servers config: ${e.message}`);
}
userAgent = new SIP.UserAgent(userAgentOptions);
const registererOptions = {};
registerer = new SIP.Registerer(userAgent, registererOptions);
registerer.stateChange.addListener((newState) => {
log(`Registration state: ${newState}`);
switch (newState) {
case SIP.RegistererState.Registered:
showStatus('Registered', 'success');
registerBtn.disabled = true;
unregisterBtn.disabled = false;
callBtn.disabled = false;
break;
case SIP.RegistererState.Unregistered:
showStatus('Unregistered', 'info');
break;
case SIP.RegistererState.Terminated:
showStatus('Registration terminated', 'error');
resetUI();
break;
}
});
userAgent.start().then(() => {
log('UserAgent started, attempting registration...');
return registerer.register();
}).then(() => {
log('Registration request sent');
}).catch((error) => {
log(`Registration failed: ${error.message}`);
showStatus('Registration failed', 'error');
userAgent = null;
});
}
function unregister() {
if (userAgent) {
log('Unregistering...');
if (currentSession) {
hangup();
}
userAgent.stop().then(() => {
log('UserAgent stopped successfully');
showStatus('Unregistered', 'info');
resetUI();
}).catch((error) => {
log(`Unregister failed: ${error.message}`);
resetUI();
});
}
}
function resetUI() {
registerBtn.disabled = false;
unregisterBtn.disabled = true;
callBtn.disabled = true;
callControls.classList.remove('active');
incomingCallDiv.classList.remove('active');
currentSession = null;
registerer = null;
userAgent = null;
lastOutgoingCallLabel = null;
resetIceStats();
}
function makeCall() {
const target = callTargetInput.value;
if (!target || !userAgent) {
log('Please enter a call target and ensure you are registered');
return;
}
let targetURI = undefined;
if (target.startsWith('sip:')) {
targetURI = SIP.UserAgent.makeURI(target);
} else {
targetURI = SIP.UserAgent.makeURI(`sip:${target}@${serverInput.value.replace('ws://', '').replace('wss://', '').split('/')[0]}`);
}
const inviteOptions = {
sessionDescriptionHandlerOptions: {
constraints: {
audio: true,
video: false
}
}
};
const inviter = new SIP.Inviter(userAgent, targetURI, inviteOptions);
log(`Calling ${targetURI.toString()}`);
const outgoingLabel = (targetURI && targetURI.user) ? targetURI.user : target.replace(/^sip:/, '');
lastOutgoingCallLabel = outgoingLabel || 'unknown';
inviter._iceLabel = `outgoing:${lastOutgoingCallLabel}`;
resetIceStats(`<div><strong>Call:</strong> ${inviter._iceLabel}</div><div><strong>Status:</strong> waiting for candidates</div>`);
inviter._timing = { inviteSent: Date.now() };
inviter.delegate = {
onUpdate: (request) => {
log('Received in-dialog UPDATE (outgoing call)');
request.accept();
},
onBye: (bye) => {
log('Call ended by remote party');
endCall();
},
onSessionDescriptionHandler: () => {
setupMediaElements(inviter);
try { monitorIceGathering(inviter, inviter._iceLabel); } catch (e) { }
}
};
inviter.stateChange.addListener((newState) => {
log(`Call state: ${newState}`);
switch (newState) {
case SIP.SessionState.Initial:
log('Call initializing...');
break;
case SIP.SessionState.Establishing:
callInfo.textContent = `Calling ${target}...`;
callControls.classList.add('active');
break;
case SIP.SessionState.Established:
callInfo.textContent = `Connected to ${target}`;
setupMediaElements(inviter);
try {
if (inviter._timing && inviter._timing.inviteSent) {
const dur = Date.now() - inviter._timing.inviteSent;
const handshakePeer = lastOutgoingCallLabel || target;
log(`Outgoing handshake time to ${handshakePeer}: ${formatMs(dur)}`);
}
} catch (e) { log(`handshake timing error: ${e.message}`); }
break;
case SIP.SessionState.Terminating:
log('Call terminating...');
break;
case SIP.SessionState.Terminated:
log('Call terminated');
endCall();
break;
}
});
inviter.invite().then(() => {
currentSession = inviter;
log('Invite sent successfully');
}).catch((error) => {
log(`Failed to send invite: ${error.message}`);
endCall();
});
}
function showIncomingCall(invitation) {
currentSession = invitation;
invitation._timing = invitation._timing || {};
invitation._timing.incomingReceived = Date.now();
const caller = invitation.remoteIdentity.displayName || invitation.remoteIdentity.uri.user;
callerInfo.textContent = `Incoming call from: ${caller}`;
incomingCallDiv.classList.add('active');
invitation._iceLabel = `incoming:${caller || 'unknown'}`;
resetIceStats(`<div><strong>Call:</strong> ${invitation._iceLabel}</div><div><strong>Status:</strong> waiting for candidates</div>`);
invitation.delegate = {
onSessionDescriptionHandler: () => {
setupMediaElements(invitation);
try { monitorIceGathering(invitation, invitation._iceLabel); } catch (e) { }
}
};
invitation.stateChange.addListener((newState) => {
log(`Incoming call state: ${newState}`);
switch (newState) {
case SIP.SessionState.Established:
incomingCallDiv.classList.remove('active');
callInfo.textContent = `Connected to ${caller}`;
callControls.classList.add('active');
setupMediaElements(invitation);
try {
const t = invitation._timing || {};
const base = t.answerTime || t.incomingReceived;
if (base) {
const dur = Date.now() - base;
log(`Incoming handshake time from ${caller}: ${formatMs(dur)}`);
}
} catch (e) { log(`handshake timing error: ${e.message}`); }
break;
case SIP.SessionState.Terminated:
endCall();
break;
}
});
}
function answerCall() {
if (currentSession) {
log('Answering incoming call');
const options = {
sessionDescriptionHandlerOptions: {
constraints: {
audio: true,
video: false
}
}
};
currentSession._timing = currentSession._timing || {};
currentSession._timing.answerTime = Date.now();
currentSession.accept(options);
}
}
function rejectCall() {
if (currentSession) {
log('Rejecting incoming call');
currentSession.reject();
incomingCallDiv.classList.remove('active');
currentSession = null;
}
}
function hangup() {
if (currentSession) {
log('Hanging up call');
try {
switch (currentSession.state) {
case SIP.SessionState.Initial:
case SIP.SessionState.Establishing:
if (currentSession instanceof SIP.Inviter) {
currentSession.cancel();
} else {
currentSession.reject();
}
break;
case SIP.SessionState.Established:
currentSession.bye();
break;
default:
log(`Cannot hangup call in state: ${currentSession.state}`);
break;
}
} catch (error) {
log(`Error hanging up: ${error.message}`);
endCall();
}
}
}
function endCall() {
callControls.classList.remove('active');
incomingCallDiv.classList.remove('active');
currentSession = null;
isMuted = false;
isOnHold = false;
muteBtn.textContent = 'Mute';
holdBtn.textContent = 'Hold';
lastOutgoingCallLabel = null;
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;
}
resetIceStats();
}
function toggleMute() {
if (currentSession && currentSession.sessionDescriptionHandler) {
try {
const senders = currentSession.sessionDescriptionHandler.peerConnection.getSenders();
senders.forEach(sender => {
if (sender.track && sender.track.kind === 'audio') {
sender.track.enabled = isMuted;
}
});
isMuted = !isMuted;
muteBtn.textContent = isMuted ? 'Unmute' : 'Mute';
log(isMuted ? 'Audio muted' : 'Audio unmuted');
} catch (error) {
log(`Error toggling mute: ${error.message}`);
}
} else {
log('No active session for mute operation');
}
}
function toggleHold() {
if (currentSession) {
if (isOnHold) {
currentSession.unhold().then(() => {
isOnHold = false;
holdBtn.textContent = 'Hold';
log('Call is resumed from hold');
}).catch(error => {
log(`Failed to unhold: ${error.message}`);
});
} else {
currentSession.hold().then(() => {
isOnHold = true;
holdBtn.textContent = 'Unhold';
log('Call is on hold');
}).catch(error => {
log(`Failed to hold: ${error.message}`);
});
}
}
}
loadDefaultIceServers();
log('SIP Phone initialized');
</script>
</body>
</html>