353 lines
11 KiB
JavaScript
353 lines
11 KiB
JavaScript
/**
|
|
* Shared WebRTC session — signaling via WebHub WS with HTTP fallback.
|
|
*/
|
|
window.BackOneWebRtcSession = (function () {
|
|
let config = {
|
|
icePreferOverlay: true,
|
|
iceFallbackTimeoutMs: 8000,
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
};
|
|
let pc = null;
|
|
let localStream = null;
|
|
let remoteStream = null;
|
|
let activeCall = null;
|
|
let iceFallbackTimer = null;
|
|
let httpIcePollTimer = null;
|
|
let httpAnswerPollTimer = null;
|
|
const listeners = new Set();
|
|
|
|
function emit(event, detail) {
|
|
listeners.forEach(fn => {
|
|
try { fn(event, detail); } catch (_) { /* ignore */ }
|
|
});
|
|
}
|
|
|
|
function subscribe(fn) {
|
|
listeners.add(fn);
|
|
return () => listeners.delete(fn);
|
|
}
|
|
|
|
function clearTimers() {
|
|
if (iceFallbackTimer) { clearTimeout(iceFallbackTimer); iceFallbackTimer = null; }
|
|
if (httpIcePollTimer) { clearInterval(httpIcePollTimer); httpIcePollTimer = null; }
|
|
if (httpAnswerPollTimer) { clearInterval(httpAnswerPollTimer); httpAnswerPollTimer = null; }
|
|
}
|
|
|
|
function overlayIceServers() {
|
|
return config.icePreferOverlay ? [] : (config.iceServers || []);
|
|
}
|
|
|
|
function fullIceServers() {
|
|
return config.iceServers || [];
|
|
}
|
|
|
|
function createPeerConnection(peer) {
|
|
clearTimers();
|
|
if (pc) {
|
|
try { pc.close(); } catch (_) { /* ignore */ }
|
|
}
|
|
pc = new RTCPeerConnection({ iceServers: overlayIceServers() });
|
|
remoteStream = new MediaStream();
|
|
pc.ontrack = (ev) => {
|
|
ev.streams[0].getTracks().forEach(t => remoteStream.addTrack(t));
|
|
emit('remote-track', { stream: remoteStream });
|
|
};
|
|
pc.onicecandidate = (ev) => {
|
|
if (!ev.candidate || !activeCall) return;
|
|
relayIce(activeCall.callId, {
|
|
candidate: ev.candidate.candidate,
|
|
sdpMid: ev.candidate.sdpMid,
|
|
sdpMLineIndex: ev.candidate.sdpMLineIndex
|
|
});
|
|
};
|
|
pc.onconnectionstatechange = () => {
|
|
emit('connection-state', { state: pc.connectionState });
|
|
if (pc.connectionState === 'connected') clearTimers();
|
|
};
|
|
|
|
if (config.icePreferOverlay) {
|
|
iceFallbackTimer = setTimeout(() => {
|
|
if (!pc || pc.connectionState === 'connected') return;
|
|
try {
|
|
pc.setConfiguration({ iceServers: fullIceServers() });
|
|
pc.restartIce();
|
|
} catch (_) { /* ignore */ }
|
|
}, config.iceFallbackTimeoutMs || 8000);
|
|
}
|
|
return pc;
|
|
}
|
|
|
|
async function relayIce(callId, candidate) {
|
|
if (BackOneWebHubClient.isConnected()) {
|
|
BackOneWebHubClient.send({
|
|
type: 'ice_candidate',
|
|
callId,
|
|
...candidate
|
|
});
|
|
return;
|
|
}
|
|
const peerId = BackOneWebHubClient.getSelfPeerId()
|
|
|| ((BackOneNetworkSession.getCoreStatus().address || '').toLowerCase());
|
|
await BackOneAuth.fetchWithAuth(`/controller/comms/call/${encodeURIComponent(callId)}/ice`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...candidate, fromPeerId: peerId })
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function startHttpIcePoll(callId) {
|
|
let since = 0;
|
|
httpIcePollTimer = setInterval(async () => {
|
|
if (!pc || !activeCall || activeCall.callId !== callId) return;
|
|
const peerId = BackOneWebHubClient.getSelfPeerId()
|
|
|| ((BackOneNetworkSession.getCoreStatus().address || '').toLowerCase());
|
|
try {
|
|
const res = await BackOneAuth.fetchWithAuth(
|
|
`/controller/comms/call/${encodeURIComponent(callId)}/ice?since=${since}&peerId=${encodeURIComponent(peerId)}`
|
|
);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const list = Array.isArray(data.candidates) ? data.candidates : [];
|
|
for (const c of list) {
|
|
try {
|
|
await pc.addIceCandidate({
|
|
candidate: c.candidate,
|
|
sdpMid: c.sdpMid,
|
|
sdpMLineIndex: c.sdpMLineIndex
|
|
});
|
|
since = Math.max(since, c.at || 0);
|
|
} catch (_) { /* ignore */ }
|
|
}
|
|
} catch (_) { /* ignore */ }
|
|
}, 1500);
|
|
}
|
|
|
|
function watchHubCallState() {
|
|
return BackOneWebHubClient.subscribe('call_state', async (msg) => {
|
|
if (!activeCall || msg.callId !== activeCall.callId) return;
|
|
if (msg.state === 'accepted' && msg.sdp && activeCall.role === 'caller') {
|
|
try {
|
|
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
|
|
emit('call-accepted', activeCall);
|
|
} catch (_) { /* ignore */ }
|
|
}
|
|
if (msg.state === 'ended' || msg.state === 'rejected') {
|
|
hangUp(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
let unsubHub = [];
|
|
|
|
async function waitForOutgoingCallId() {
|
|
return new Promise((resolve) => {
|
|
const unsub = BackOneWebHubClient.subscribe('call_state', (msg) => {
|
|
if (msg.callId && msg.state === 'ringing') {
|
|
unsub();
|
|
resolve(msg.callId);
|
|
}
|
|
});
|
|
setTimeout(() => { unsub(); resolve(null); }, 5000);
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
try {
|
|
const res = await BackOneAuth.fetchWithAuth('/app/api/webrtc-config');
|
|
if (res.ok) {
|
|
const cfg = await res.json();
|
|
config = { ...config, ...cfg };
|
|
}
|
|
} catch (_) { /* ignore */ }
|
|
|
|
unsubHub.forEach(fn => fn && fn());
|
|
unsubHub = [
|
|
watchHubCallState(),
|
|
BackOneWebHubClient.subscribe('ice_candidate', async (msg) => {
|
|
if (!pc || !activeCall || msg.callId !== activeCall.callId) return;
|
|
try {
|
|
await pc.addIceCandidate({
|
|
candidate: msg.candidate,
|
|
sdpMid: msg.sdpMid,
|
|
sdpMLineIndex: msg.sdpMLineIndex
|
|
});
|
|
} catch (_) { /* ignore */ }
|
|
})
|
|
];
|
|
}
|
|
|
|
async function getMedia(callType) {
|
|
const audio = true;
|
|
const video = callType === 'video';
|
|
localStream = await navigator.mediaDevices.getUserMedia({ audio, video });
|
|
return localStream;
|
|
}
|
|
|
|
function attachLocalTracks() {
|
|
if (!pc || !localStream) return;
|
|
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
|
}
|
|
|
|
async function startOutgoingCall(peer, callType) {
|
|
if (!peer || activeCall) throw new Error('Already in a call');
|
|
const networkId = BackOneNetworkSession.getNetworkId();
|
|
const selfId = ((BackOneNetworkSession.getCoreStatus().address || '').toLowerCase());
|
|
createPeerConnection(peer);
|
|
localStream = await getMedia(callType);
|
|
attachLocalTracks();
|
|
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
|
|
const payload = {
|
|
type: 'call_offer',
|
|
callType,
|
|
networkId,
|
|
targetId: peer.peerId,
|
|
callerName: peer.displayName || peer.peerId,
|
|
sdp: offer.sdp,
|
|
overlayIps: peer.ip ? [peer.ip] : []
|
|
};
|
|
|
|
let callId = null;
|
|
if (BackOneWebHubClient.isConnected()) {
|
|
BackOneWebHubClient.send(payload);
|
|
callId = await waitForOutgoingCallId();
|
|
if (!callId) throw new Error('Call signaling timeout');
|
|
} else {
|
|
const res = await BackOneAuth.fetchWithAuth('/controller/comms/call', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
callType,
|
|
type: callType,
|
|
networkId,
|
|
targetId: peer.peerId,
|
|
callerId: selfId,
|
|
callerName: peer.displayName || peer.peerId,
|
|
sdp: offer.sdp
|
|
})
|
|
});
|
|
if (!res.ok) throw new Error('Call signaling failed');
|
|
const data = await res.json();
|
|
callId = data.callId;
|
|
startHttpAnswerPoll(callId);
|
|
}
|
|
|
|
activeCall = {
|
|
callId,
|
|
role: 'caller',
|
|
callType,
|
|
peer,
|
|
networkId
|
|
};
|
|
if (!BackOneWebHubClient.isConnected()) startHttpIcePoll(activeCall.callId);
|
|
emit('call-started', activeCall);
|
|
return activeCall;
|
|
}
|
|
|
|
function startHttpAnswerPoll(callId) {
|
|
httpAnswerPollTimer = setInterval(async () => {
|
|
if (!pc || !activeCall || activeCall.callId !== callId) return;
|
|
try {
|
|
const res = await BackOneAuth.fetchWithAuth(`/controller/comms/call/${encodeURIComponent(callId)}`);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
if (data.state === 'accepted' && data.answerSdp) {
|
|
await pc.setRemoteDescription({ type: 'answer', sdp: data.answerSdp });
|
|
clearInterval(httpAnswerPollTimer);
|
|
httpAnswerPollTimer = null;
|
|
emit('call-accepted', activeCall);
|
|
}
|
|
} catch (_) { /* ignore */ }
|
|
}, 1500);
|
|
}
|
|
|
|
async function acceptIncomingCall(incoming) {
|
|
if (!incoming || activeCall) throw new Error('Already in a call');
|
|
const callType = incoming.callType || 'voice';
|
|
const callId = incoming.callId;
|
|
createPeerConnection({ peerId: incoming.callerId });
|
|
localStream = await getMedia(callType);
|
|
attachLocalTracks();
|
|
|
|
await pc.setRemoteDescription({ type: 'offer', sdp: incoming.sdp });
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
|
|
if (BackOneWebHubClient.isConnected()) {
|
|
BackOneWebHubClient.send({
|
|
type: 'call_answer',
|
|
callId,
|
|
sdp: answer.sdp
|
|
});
|
|
} else {
|
|
await BackOneAuth.fetchWithAuth(`/controller/comms/call/${encodeURIComponent(callId)}/answer`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sdp: answer.sdp })
|
|
});
|
|
startHttpIcePoll(callId);
|
|
}
|
|
|
|
activeCall = {
|
|
callId,
|
|
role: 'callee',
|
|
callType,
|
|
peer: { peerId: incoming.callerId, displayName: incoming.callerName },
|
|
networkId: incoming.networkId
|
|
};
|
|
emit('call-started', activeCall);
|
|
return activeCall;
|
|
}
|
|
|
|
async function rejectIncomingCall(callId) {
|
|
if (BackOneWebHubClient.isConnected()) {
|
|
BackOneWebHubClient.send({ type: 'call_reject', callId });
|
|
} else {
|
|
await BackOneAuth.fetchWithAuth(`/controller/comms/call/${encodeURIComponent(callId)}/reject`, {
|
|
method: 'POST'
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function hangUp(signalRemote = true) {
|
|
const callId = activeCall?.callId;
|
|
clearTimers();
|
|
if (signalRemote && callId) {
|
|
if (BackOneWebHubClient.isConnected()) {
|
|
BackOneWebHubClient.send({ type: 'call_end', callId });
|
|
} else {
|
|
await BackOneAuth.fetchWithAuth(`/controller/comms/call/${encodeURIComponent(callId)}/end`, {
|
|
method: 'POST'
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
if (pc) {
|
|
try { pc.close(); } catch (_) { /* ignore */ }
|
|
pc = null;
|
|
}
|
|
if (localStream) {
|
|
localStream.getTracks().forEach(t => t.stop());
|
|
localStream = null;
|
|
}
|
|
remoteStream = null;
|
|
activeCall = null;
|
|
emit('call-ended', {});
|
|
}
|
|
|
|
return {
|
|
init,
|
|
subscribe,
|
|
startOutgoingCall,
|
|
acceptIncomingCall,
|
|
rejectIncomingCall,
|
|
hangUp,
|
|
getPeerConnection: () => pc,
|
|
getLocalStream: () => localStream,
|
|
getRemoteStream: () => remoteStream,
|
|
getActiveCall: () => activeCall,
|
|
isInCall: () => !!activeCall
|
|
};
|
|
})();
|