Files
2026-07-02 09:12:57 +07:00

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
};
})();