Update static/js/12-calls.js
This commit is contained in:
parent
fc4f7472d1
commit
5b3c4c3c55
@ -1,8 +1,14 @@
|
|||||||
// static/js/12-calls.js
|
|
||||||
"use strict";
|
"use strict";
|
||||||
window.Glow = window.Glow || {};
|
window.Glow = window.Glow || {};
|
||||||
Glow.calls = Glow.calls || {};
|
Glow.calls = Glow.calls || {};
|
||||||
|
|
||||||
|
Glow.calls.DEBUG = false;
|
||||||
|
|
||||||
|
Glow.calls.debug = (...args) => {
|
||||||
|
if (!Glow.calls.DEBUG) return;
|
||||||
|
console.debug("[calls]", ...args);
|
||||||
|
};
|
||||||
|
|
||||||
Glow.calls.state = () => Glow.state.calls;
|
Glow.calls.state = () => Glow.state.calls;
|
||||||
|
|
||||||
Glow.calls.ensureState = () => {
|
Glow.calls.ensureState = () => {
|
||||||
@ -33,6 +39,9 @@ Glow.calls.ensureState = () => {
|
|||||||
state.backendParticipants ||= [];
|
state.backendParticipants ||= [];
|
||||||
state.scope ||= null;
|
state.scope ||= null;
|
||||||
state.statusText ||= "Idle";
|
state.statusText ||= "Idle";
|
||||||
|
state.makingOffer ||= new Map();
|
||||||
|
state.ignoreOffer ||= new Map();
|
||||||
|
state.isSettingRemoteAnswerPending ||= new Map();
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.scopeLabel = invite => {
|
Glow.calls.scopeLabel = invite => {
|
||||||
@ -73,7 +82,11 @@ Glow.calls.wsReady = () => {
|
|||||||
|
|
||||||
Glow.calls.sendSignal = payload => {
|
Glow.calls.sendSignal = payload => {
|
||||||
const ws = Glow.calls.state().ws;
|
const ws = Glow.calls.state().ws;
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
Glow.calls.debug("sendSignal skipped, ws not open", payload);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Glow.calls.debug("sendSignal", payload);
|
||||||
ws.send(JSON.stringify(payload));
|
ws.send(JSON.stringify(payload));
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@ -84,9 +97,30 @@ Glow.calls.replaceHttpWs = url =>
|
|||||||
Glow.calls.setStatusText = text => {
|
Glow.calls.setStatusText = text => {
|
||||||
Glow.calls.ensureState();
|
Glow.calls.ensureState();
|
||||||
Glow.calls.state().statusText = String(text || "");
|
Glow.calls.state().statusText = String(text || "");
|
||||||
|
Glow.calls.debug("status:", text);
|
||||||
Glow.renderCallUi();
|
Glow.renderCallUi();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Glow.calls.browserSupportError = () => {
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
return "Voice calls require a secure https connection.";
|
||||||
|
}
|
||||||
|
if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
|
||||||
|
return "This browser does not provide microphone access support.";
|
||||||
|
}
|
||||||
|
if (typeof window.RTCPeerConnection !== "function") {
|
||||||
|
return "This browser does not support WebRTC voice calls.";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
Glow.calls.isPolitePeer = peerId => {
|
||||||
|
const selfPeerId = String(Glow.calls.state().selfPeerId || "");
|
||||||
|
const otherPeerId = String(peerId || "");
|
||||||
|
if (!selfPeerId || !otherPeerId) return true;
|
||||||
|
return selfPeerId.localeCompare(otherPeerId) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
Glow.calls.ensureRemoteAudioElement = peerId => {
|
Glow.calls.ensureRemoteAudioElement = peerId => {
|
||||||
Glow.calls.ensureState();
|
Glow.calls.ensureState();
|
||||||
const state = Glow.calls.state();
|
const state = Glow.calls.state();
|
||||||
@ -101,6 +135,7 @@ Glow.calls.ensureRemoteAudioElement = peerId => {
|
|||||||
audio.dataset.peerId = String(peerId);
|
audio.dataset.peerId = String(peerId);
|
||||||
Glow.els.remoteAudioContainer.append(audio);
|
Glow.els.remoteAudioContainer.append(audio);
|
||||||
state.participantAudioEls.set(peerId, audio);
|
state.participantAudioEls.set(peerId, audio);
|
||||||
|
Glow.calls.debug("created remote audio element for peer", peerId);
|
||||||
return audio;
|
return audio;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -112,6 +147,7 @@ Glow.calls.removeRemoteAudioElement = peerId => {
|
|||||||
audio.srcObject = null;
|
audio.srcObject = null;
|
||||||
audio.remove();
|
audio.remove();
|
||||||
state.participantAudioEls.delete(peerId);
|
state.participantAudioEls.delete(peerId);
|
||||||
|
Glow.calls.debug("removed remote audio element for peer", peerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.syncRemoteAudioElements = () => {
|
Glow.calls.syncRemoteAudioElements = () => {
|
||||||
@ -122,6 +158,7 @@ Glow.calls.syncRemoteAudioElements = () => {
|
|||||||
const audio = Glow.calls.ensureRemoteAudioElement(peerId);
|
const audio = Glow.calls.ensureRemoteAudioElement(peerId);
|
||||||
if (audio && audio.srcObject !== stream) {
|
if (audio && audio.srcObject !== stream) {
|
||||||
audio.srcObject = stream;
|
audio.srcObject = stream;
|
||||||
|
Glow.calls.debug("bound remote stream to audio", peerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,6 +190,7 @@ Glow.calls.upsertPeer = peer => {
|
|||||||
peers.push(normalized);
|
peers.push(normalized);
|
||||||
}
|
}
|
||||||
state.peers = peers;
|
state.peers = peers;
|
||||||
|
Glow.calls.debug("upsertPeer", normalized);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.removePeerConnection = peerId => {
|
Glow.calls.removePeerConnection = peerId => {
|
||||||
@ -168,8 +206,12 @@ Glow.calls.removePeerConnection = peerId => {
|
|||||||
|
|
||||||
state.peerConnections.delete(peerId);
|
state.peerConnections.delete(peerId);
|
||||||
state.remoteStreams.delete(peerId);
|
state.remoteStreams.delete(peerId);
|
||||||
|
state.makingOffer.delete(peerId);
|
||||||
|
state.ignoreOffer.delete(peerId);
|
||||||
|
state.isSettingRemoteAnswerPending.delete(peerId);
|
||||||
Glow.calls.removeRemoteAudioElement(peerId);
|
Glow.calls.removeRemoteAudioElement(peerId);
|
||||||
Glow.calls.syncRemoteAudioElements();
|
Glow.calls.syncRemoteAudioElements();
|
||||||
|
Glow.calls.debug("removed peer connection", peerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.removePeer = peerId => {
|
Glow.calls.removePeer = peerId => {
|
||||||
@ -177,6 +219,7 @@ Glow.calls.removePeer = peerId => {
|
|||||||
const state = Glow.calls.state();
|
const state = Glow.calls.state();
|
||||||
state.peers = (state.peers || []).filter(peer => peer.participantId !== peerId);
|
state.peers = (state.peers || []).filter(peer => peer.participantId !== peerId);
|
||||||
Glow.calls.removePeerConnection(peerId);
|
Glow.calls.removePeerConnection(peerId);
|
||||||
|
Glow.calls.debug("removed peer", peerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.listOtherPeers = () => {
|
Glow.calls.listOtherPeers = () => {
|
||||||
@ -191,14 +234,21 @@ Glow.calls.ensureLocalAudio = async () => {
|
|||||||
if (state.localStream) return state.localStream;
|
if (state.localStream) return state.localStream;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Glow.calls.debug("requesting local microphone");
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: true,
|
audio: true,
|
||||||
video: false
|
video: false
|
||||||
});
|
});
|
||||||
state.localStream = stream;
|
state.localStream = stream;
|
||||||
|
Glow.calls.debug("microphone ready");
|
||||||
return stream;
|
return stream;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
Glow.calls.debug("getUserMedia failed", error);
|
||||||
if (error?.name === "NotAllowedError" || error?.name === "PermissionDeniedError") {
|
if (error?.name === "NotAllowedError" || error?.name === "PermissionDeniedError") {
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
if (/librewolf/i.test(ua)) {
|
||||||
|
throw new Error("Microphone permission was denied. In LibreWolf, click the lock icon in the address bar and allow microphone access for this site.");
|
||||||
|
}
|
||||||
throw new Error("Microphone permission was denied.");
|
throw new Error("Microphone permission was denied.");
|
||||||
}
|
}
|
||||||
if (error?.name === "NotFoundError" || error?.name === "DevicesNotFoundError") {
|
if (error?.name === "NotFoundError" || error?.name === "DevicesNotFoundError") {
|
||||||
@ -222,12 +272,15 @@ Glow.calls.ensurePeerConnection = async peerId => {
|
|||||||
return state.peerConnections.get(peerId);
|
return state.peerConnections.get(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Glow.calls.debug("creating peer connection", peerId);
|
||||||
|
|
||||||
const pc = new RTCPeerConnection({
|
const pc = new RTCPeerConnection({
|
||||||
iceServers: state.session?.iceServers || []
|
iceServers: state.session?.iceServers || []
|
||||||
});
|
});
|
||||||
|
|
||||||
pc.onicecandidate = event => {
|
pc.onicecandidate = event => {
|
||||||
if (!event.candidate) return;
|
if (!event.candidate) return;
|
||||||
|
Glow.calls.debug("ice candidate", peerId, event.candidate);
|
||||||
Glow.calls.sendSignal({
|
Glow.calls.sendSignal({
|
||||||
type: "ice",
|
type: "ice",
|
||||||
to: peerId,
|
to: peerId,
|
||||||
@ -240,11 +293,13 @@ Glow.calls.ensurePeerConnection = async peerId => {
|
|||||||
pc.ontrack = event => {
|
pc.ontrack = event => {
|
||||||
const stream = event.streams?.[0];
|
const stream = event.streams?.[0];
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
|
Glow.calls.debug("received remote track", peerId, stream);
|
||||||
state.remoteStreams.set(peerId, stream);
|
state.remoteStreams.set(peerId, stream);
|
||||||
Glow.calls.syncRemoteAudioElements();
|
Glow.calls.syncRemoteAudioElements();
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
|
Glow.calls.debug("pc connection state", peerId, pc.connectionState);
|
||||||
const bad =
|
const bad =
|
||||||
pc.connectionState === "failed" ||
|
pc.connectionState === "failed" ||
|
||||||
pc.connectionState === "disconnected";
|
pc.connectionState === "disconnected";
|
||||||
@ -253,7 +308,18 @@ Glow.calls.ensurePeerConnection = async peerId => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pc.onsignalingstatechange = () => {
|
||||||
|
Glow.calls.debug("pc signaling state", peerId, pc.signalingState);
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
Glow.calls.debug("pc ice connection state", peerId, pc.iceConnectionState);
|
||||||
|
};
|
||||||
|
|
||||||
state.peerConnections.set(peerId, pc);
|
state.peerConnections.set(peerId, pc);
|
||||||
|
state.makingOffer.set(peerId, false);
|
||||||
|
state.ignoreOffer.set(peerId, false);
|
||||||
|
state.isSettingRemoteAnswerPending.set(peerId, false);
|
||||||
|
|
||||||
const stream = await Glow.calls.ensureLocalAudio();
|
const stream = await Glow.calls.ensureLocalAudio();
|
||||||
const existingTracks = pc.getSenders().map(sender => sender.track).filter(Boolean);
|
const existingTracks = pc.getSenders().map(sender => sender.track).filter(Boolean);
|
||||||
@ -268,14 +334,39 @@ Glow.calls.ensurePeerConnection = async peerId => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.makeOfferTo = async peerId => {
|
Glow.calls.makeOfferTo = async peerId => {
|
||||||
|
Glow.calls.ensureState();
|
||||||
|
const state = Glow.calls.state();
|
||||||
|
|
||||||
|
Glow.calls.debug("making offer to peer", peerId);
|
||||||
const pc = await Glow.calls.ensurePeerConnection(peerId);
|
const pc = await Glow.calls.ensurePeerConnection(peerId);
|
||||||
const offer = await pc.createOffer();
|
|
||||||
await pc.setLocalDescription(offer);
|
if (pc.signalingState !== "stable") {
|
||||||
Glow.calls.sendSignal({
|
Glow.calls.debug("skipping offer because pc not stable", {
|
||||||
type: "offer",
|
peerId,
|
||||||
to: peerId,
|
signalingState: pc.signalingState
|
||||||
sdp: offer.sdp
|
});
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.makingOffer.set(peerId, true);
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
if (pc.signalingState !== "stable") {
|
||||||
|
Glow.calls.debug("skipping localDescription because signaling changed", {
|
||||||
|
peerId,
|
||||||
|
signalingState: pc.signalingState
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
Glow.calls.sendSignal({
|
||||||
|
type: "offer",
|
||||||
|
to: peerId,
|
||||||
|
sdp: pc.localDescription?.sdp || offer.sdp
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
state.makingOffer.set(peerId, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.callAllPeers = async () => {
|
Glow.calls.callAllPeers = async () => {
|
||||||
@ -288,29 +379,42 @@ Glow.calls.callAllPeers = async () => {
|
|||||||
for (const peer of peers) {
|
for (const peer of peers) {
|
||||||
try {
|
try {
|
||||||
await Glow.calls.makeOfferTo(peer.participantId);
|
await Glow.calls.makeOfferTo(peer.participantId);
|
||||||
} catch (_) {}
|
} catch (error) {
|
||||||
|
Glow.calls.debug("makeOfferTo failed", peer.participantId, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.fetchGroupCallSession = async scope => {
|
Glow.calls.fetchGroupCallSession = async scope => {
|
||||||
|
Glow.calls.debug("fetchGroupCallSession", scope);
|
||||||
|
|
||||||
const data = await Glow.apiRequest("/api/group-calls/token", {
|
const data = await Glow.apiRequest("/api/group-calls/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(scope)
|
body: JSON.stringify(scope)
|
||||||
});
|
});
|
||||||
|
|
||||||
const helperResp = await fetch(`${data.calls_base}/api/call/session`, {
|
let helperResp;
|
||||||
method: "POST",
|
try {
|
||||||
headers: { "Content-Type": "application/json" },
|
helperResp = await fetch(`${data.calls_base}/api/call/session`, {
|
||||||
body: JSON.stringify({ token: data.token })
|
method: "POST",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: data.token })
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Glow.calls.debug("helper fetch failed", error);
|
||||||
|
throw new Error("Could not reach the call helper service.");
|
||||||
|
}
|
||||||
|
|
||||||
let helperData = {};
|
let helperData = {};
|
||||||
try {
|
try {
|
||||||
helperData = await helperResp.json();
|
helperData = await helperResp.json();
|
||||||
} catch (_) {}
|
} catch (error) {
|
||||||
|
Glow.calls.debug("helper json parse failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
if (!helperResp.ok || !helperData.ok) {
|
if (!helperResp.ok || !helperData.ok) {
|
||||||
|
Glow.calls.debug("helper returned error", helperResp.status, helperData);
|
||||||
throw new Error(helperData.error || `Call helper failed (${helperResp.status})`);
|
throw new Error(helperData.error || `Call helper failed (${helperResp.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -323,6 +427,7 @@ Glow.calls.fetchGroupCallSession = async scope => {
|
|||||||
can_invite: false
|
can_invite: false
|
||||||
};
|
};
|
||||||
helperData.scope = data.scope || null;
|
helperData.scope = data.scope || null;
|
||||||
|
Glow.calls.debug("helper session ready", helperData);
|
||||||
return helperData;
|
return helperData;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -491,22 +596,37 @@ Glow.calls.connectWebSocket = async () => {
|
|||||||
throw new Error("Missing helper WebSocket URL.");
|
throw new Error("Missing helper WebSocket URL.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wsUrl = Glow.calls.replaceHttpWs(state.session.ws_url);
|
||||||
|
Glow.calls.debug("connecting websocket", wsUrl);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(Glow.calls.replaceHttpWs(state.session.ws_url));
|
const ws = new WebSocket(wsUrl);
|
||||||
state.ws = ws;
|
state.ws = ws;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
Glow.calls.debug("websocket open");
|
||||||
state.reconnecting = false;
|
state.reconnecting = false;
|
||||||
state.joining = false;
|
state.joining = false;
|
||||||
Glow.calls.setStatusText("Connected");
|
Glow.calls.setStatusText("Connected");
|
||||||
|
settled = true;
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = error => {
|
ws.onerror = error => {
|
||||||
reject(error);
|
Glow.calls.debug("websocket error", error);
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(new Error("Could not connect to the call signaling service."));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = event => {
|
||||||
|
Glow.calls.debug("websocket close", event.code, event.reason);
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(new Error("Call signaling connection closed before it was ready."));
|
||||||
|
}
|
||||||
if (state.activeCall) {
|
if (state.activeCall) {
|
||||||
Glow.calls.scheduleReconnect();
|
Glow.calls.scheduleReconnect();
|
||||||
}
|
}
|
||||||
@ -517,9 +637,12 @@ Glow.calls.connectWebSocket = async () => {
|
|||||||
try {
|
try {
|
||||||
signal = JSON.parse(event.data);
|
signal = JSON.parse(event.data);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
Glow.calls.debug("ignoring non-json ws message");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Glow.calls.debug("ws message", signal);
|
||||||
|
|
||||||
if (signal.type === "joined") {
|
if (signal.type === "joined") {
|
||||||
state.selfPeerId = signal.participantId;
|
state.selfPeerId = signal.participantId;
|
||||||
Glow.calls.setStatusText("Call connected");
|
Glow.calls.setStatusText("Call connected");
|
||||||
@ -529,9 +652,11 @@ Glow.calls.connectWebSocket = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (signal.type === "participant-joined") {
|
if (signal.type === "participant-joined") {
|
||||||
|
state.ignoreOffer.set(signal.participantId, false);
|
||||||
Glow.calls.upsertPeer({
|
Glow.calls.upsertPeer({
|
||||||
participantId: signal.participantId,
|
participantId: signal.participantId,
|
||||||
roomId: signal.roomId
|
roomId: signal.roomId,
|
||||||
|
username: signal.username || signal.participantId
|
||||||
});
|
});
|
||||||
Glow.renderCallUi();
|
Glow.renderCallUi();
|
||||||
Glow.calls.renderParticipants();
|
Glow.calls.renderParticipants();
|
||||||
@ -539,7 +664,9 @@ Glow.calls.connectWebSocket = async () => {
|
|||||||
if (signal.participantId && signal.participantId !== state.selfPeerId) {
|
if (signal.participantId && signal.participantId !== state.selfPeerId) {
|
||||||
try {
|
try {
|
||||||
await Glow.calls.makeOfferTo(signal.participantId);
|
await Glow.calls.makeOfferTo(signal.participantId);
|
||||||
} catch (_) {}
|
} catch (error) {
|
||||||
|
Glow.calls.debug("offer on participant-joined failed", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -561,7 +688,9 @@ Glow.calls.connectWebSocket = async () => {
|
|||||||
if (signal.participantId) {
|
if (signal.participantId) {
|
||||||
try {
|
try {
|
||||||
await Glow.calls.makeOfferTo(signal.participantId);
|
await Glow.calls.makeOfferTo(signal.participantId);
|
||||||
} catch (_) {}
|
} catch (error) {
|
||||||
|
Glow.calls.debug("reoffer-needed failed", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -578,37 +707,114 @@ Glow.calls.connectWebSocket = async () => {
|
|||||||
Glow.calls.renderParticipants();
|
Glow.calls.renderParticipants();
|
||||||
|
|
||||||
const pc = await Glow.calls.ensurePeerConnection(from);
|
const pc = await Glow.calls.ensurePeerConnection(from);
|
||||||
await pc.setRemoteDescription({ type: "offer", sdp: signal.sdp });
|
const polite = Glow.calls.isPolitePeer(from);
|
||||||
const answer = await pc.createAnswer();
|
const makingOffer = Boolean(state.makingOffer.get(from));
|
||||||
await pc.setLocalDescription(answer);
|
const isSettingRemoteAnswerPending = Boolean(state.isSettingRemoteAnswerPending.get(from));
|
||||||
|
const offerCollision =
|
||||||
|
makingOffer ||
|
||||||
|
(pc.signalingState !== "stable" && !isSettingRemoteAnswerPending);
|
||||||
|
|
||||||
Glow.calls.sendSignal({
|
state.ignoreOffer.set(from, !polite && offerCollision);
|
||||||
type: "answer",
|
|
||||||
to: from,
|
Glow.calls.debug("received offer", {
|
||||||
sdp: answer.sdp
|
from,
|
||||||
|
polite,
|
||||||
|
makingOffer,
|
||||||
|
signalingState: pc.signalingState,
|
||||||
|
isSettingRemoteAnswerPending,
|
||||||
|
offerCollision,
|
||||||
|
ignoreOffer: state.ignoreOffer.get(from)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (state.ignoreOffer.get(from)) {
|
||||||
|
Glow.calls.debug("ignoring incoming offer due to collision", { from });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (offerCollision) {
|
||||||
|
await Promise.all([
|
||||||
|
pc.setLocalDescription({ type: "rollback" }),
|
||||||
|
pc.setRemoteDescription({ type: "offer", sdp: signal.sdp })
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
await pc.setRemoteDescription({ type: "offer", sdp: signal.sdp });
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
|
||||||
|
Glow.calls.sendSignal({
|
||||||
|
type: "answer",
|
||||||
|
to: from,
|
||||||
|
sdp: pc.localDescription?.sdp || answer.sdp
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Glow.calls.debug("offer handling failed", {
|
||||||
|
from,
|
||||||
|
signalingState: pc.signalingState,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signal.type === "answer") {
|
if (signal.type === "answer") {
|
||||||
const from = signal.from;
|
const from = signal.from;
|
||||||
if (!from) return;
|
if (!from) return;
|
||||||
|
|
||||||
const pc = await Glow.calls.ensurePeerConnection(from);
|
const pc = await Glow.calls.ensurePeerConnection(from);
|
||||||
await pc.setRemoteDescription({ type: "answer", sdp: signal.sdp });
|
|
||||||
|
Glow.calls.debug("received answer", {
|
||||||
|
from,
|
||||||
|
signalingState: pc.signalingState,
|
||||||
|
connectionState: pc.connectionState
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pc.signalingState !== "have-local-offer") {
|
||||||
|
Glow.calls.debug("ignoring unexpected answer", {
|
||||||
|
from,
|
||||||
|
signalingState: pc.signalingState
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.isSettingRemoteAnswerPending.set(from, true);
|
||||||
|
await pc.setRemoteDescription({ type: "answer", sdp: signal.sdp });
|
||||||
|
} catch (error) {
|
||||||
|
Glow.calls.debug("setRemoteDescription(answer) failed", {
|
||||||
|
from,
|
||||||
|
signalingState: pc.signalingState,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
state.isSettingRemoteAnswerPending.set(from, false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signal.type === "ice" || signal.type === "ice-candidate") {
|
if (signal.type === "ice" || signal.type === "ice-candidate") {
|
||||||
const from = signal.from;
|
const from = signal.from;
|
||||||
if (!from) return;
|
if (!from) return;
|
||||||
|
|
||||||
const pc = await Glow.calls.ensurePeerConnection(from);
|
const pc = await Glow.calls.ensurePeerConnection(from);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pc.addIceCandidate({
|
await pc.addIceCandidate({
|
||||||
candidate: signal.candidate,
|
candidate: signal.candidate,
|
||||||
sdpMid: signal.sdpMid,
|
sdpMid: signal.sdpMid,
|
||||||
sdpMLineIndex: signal.sdpMLineIndex
|
sdpMLineIndex: signal.sdpMLineIndex
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (error) {
|
||||||
|
const ignored = Boolean(state.ignoreOffer.get(from));
|
||||||
|
if (!ignored) {
|
||||||
|
Glow.calls.debug("addIceCandidate failed", {
|
||||||
|
from,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -635,6 +841,8 @@ Glow.calls.resetTransportOnly = () => {
|
|||||||
pc.onicecandidate = null;
|
pc.onicecandidate = null;
|
||||||
pc.ontrack = null;
|
pc.ontrack = null;
|
||||||
pc.onconnectionstatechange = null;
|
pc.onconnectionstatechange = null;
|
||||||
|
pc.onsignalingstatechange = null;
|
||||||
|
pc.oniceconnectionstatechange = null;
|
||||||
pc.close();
|
pc.close();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@ -651,6 +859,11 @@ Glow.calls.resetTransportOnly = () => {
|
|||||||
state.selfPeerId = null;
|
state.selfPeerId = null;
|
||||||
state.reconnectTimer = null;
|
state.reconnectTimer = null;
|
||||||
state.reconnecting = false;
|
state.reconnecting = false;
|
||||||
|
state.makingOffer = new Map();
|
||||||
|
state.ignoreOffer = new Map();
|
||||||
|
state.isSettingRemoteAnswerPending = new Map();
|
||||||
|
|
||||||
|
Glow.calls.debug("resetTransportOnly complete");
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.resetAll = () => {
|
Glow.calls.resetAll = () => {
|
||||||
@ -684,6 +897,8 @@ Glow.calls.resetAll = () => {
|
|||||||
can_end: false,
|
can_end: false,
|
||||||
can_invite: false
|
can_invite: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Glow.calls.debug("resetAll complete");
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.endLocalCallUi = async () => {
|
Glow.endLocalCallUi = async () => {
|
||||||
@ -719,6 +934,7 @@ Glow.calls.scheduleReconnect = () => {
|
|||||||
const scope = state.activeCall.scope || state.scope || Glow.calls.getCurrentScope();
|
const scope = state.activeCall.scope || state.scope || Glow.calls.getCurrentScope();
|
||||||
if (!scope) return;
|
if (!scope) return;
|
||||||
|
|
||||||
|
Glow.calls.debug("reconnect attempt", scope);
|
||||||
Glow.calls.resetTransportOnly();
|
Glow.calls.resetTransportOnly();
|
||||||
Glow.calls.setStatusText("Reconnecting…");
|
Glow.calls.setStatusText("Reconnecting…");
|
||||||
state.session = await Glow.calls.fetchGroupCallSession(scope);
|
state.session = await Glow.calls.fetchGroupCallSession(scope);
|
||||||
@ -733,7 +949,8 @@ Glow.calls.scheduleReconnect = () => {
|
|||||||
Glow.showToast("Call reconnected.");
|
Glow.showToast("Call reconnected.");
|
||||||
Glow.renderCallUi();
|
Glow.renderCallUi();
|
||||||
Glow.calls.refreshBackendParticipants();
|
Glow.calls.refreshBackendParticipants();
|
||||||
} catch (_) {
|
} catch (error) {
|
||||||
|
Glow.calls.debug("reconnect failed", error);
|
||||||
Glow.calls.scheduleReconnect();
|
Glow.calls.scheduleReconnect();
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@ -751,7 +968,10 @@ Glow.calls.refreshBackendParticipants = async () => {
|
|||||||
Glow.state.calls.scope = data.scope || Glow.state.calls.scope;
|
Glow.state.calls.scope = data.scope || Glow.state.calls.scope;
|
||||||
Glow.calls.renderParticipants();
|
Glow.calls.renderParticipants();
|
||||||
Glow.renderCallUi();
|
Glow.renderCallUi();
|
||||||
} catch (_) {}
|
Glow.calls.debug("backend participants refreshed", data);
|
||||||
|
} catch (error) {
|
||||||
|
Glow.calls.debug("refreshBackendParticipants failed", error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.startParticipantSync = () => {
|
Glow.calls.startParticipantSync = () => {
|
||||||
@ -780,7 +1000,10 @@ Glow.calls.startHeartbeat = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Glow.apiHeartbeatGroupCall(sessionId);
|
await Glow.apiHeartbeatGroupCall(sessionId);
|
||||||
} catch (_) {}
|
Glow.calls.debug("heartbeat ok", sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
Glow.calls.debug("heartbeat failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
Glow.calls.startHeartbeat();
|
Glow.calls.startHeartbeat();
|
||||||
};
|
};
|
||||||
@ -866,8 +1089,9 @@ Glow.startCall = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) {
|
const supportError = Glow.calls.browserSupportError();
|
||||||
Glow.showToast("Voice calls are not supported in this browser.", "error");
|
if (supportError) {
|
||||||
|
Glow.showToast(supportError, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -891,6 +1115,9 @@ Glow.startCall = async () => {
|
|||||||
Glow.state.calls.remoteStreams = new Map();
|
Glow.state.calls.remoteStreams = new Map();
|
||||||
Glow.state.calls.participantAudioEls = new Map();
|
Glow.state.calls.participantAudioEls = new Map();
|
||||||
Glow.state.calls.backendParticipants = session.participants || [];
|
Glow.state.calls.backendParticipants = session.participants || [];
|
||||||
|
Glow.state.calls.makingOffer = new Map();
|
||||||
|
Glow.state.calls.ignoreOffer = new Map();
|
||||||
|
Glow.state.calls.isSettingRemoteAnswerPending = new Map();
|
||||||
|
|
||||||
Glow.openCallPanel();
|
Glow.openCallPanel();
|
||||||
Glow.calls.setStatusText("Connecting microphone…");
|
Glow.calls.setStatusText("Connecting microphone…");
|
||||||
@ -904,6 +1131,7 @@ Glow.startCall = async () => {
|
|||||||
Glow.renderCallUi();
|
Glow.renderCallUi();
|
||||||
Glow.showToast("Group call ready.");
|
Glow.showToast("Group call ready.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
Glow.calls.debug("startCall failed", error);
|
||||||
Glow.state.calls.joining = false;
|
Glow.state.calls.joining = false;
|
||||||
Glow.showToast(error.message || "Could not start group call.", "error");
|
Glow.showToast(error.message || "Could not start group call.", "error");
|
||||||
await Glow.endLocalCallUi();
|
await Glow.endLocalCallUi();
|
||||||
@ -917,6 +1145,7 @@ Glow.toggleMuteCall = () => {
|
|||||||
for (const track of stream.getAudioTracks()) {
|
for (const track of stream.getAudioTracks()) {
|
||||||
track.enabled = !Glow.state.calls.muted;
|
track.enabled = !Glow.state.calls.muted;
|
||||||
}
|
}
|
||||||
|
Glow.calls.debug("mute toggled", Glow.state.calls.muted);
|
||||||
Glow.renderCallUi();
|
Glow.renderCallUi();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -936,7 +1165,9 @@ Glow.leaveCall = async () => {
|
|||||||
if (activeCall.groupSession?.id) {
|
if (activeCall.groupSession?.id) {
|
||||||
await Glow.apiLeaveGroupCall(activeCall.groupSession.id);
|
await Glow.apiLeaveGroupCall(activeCall.groupSession.id);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (error) {
|
||||||
|
Glow.calls.debug("leave call api failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
Glow.showToast("Left call.");
|
Glow.showToast("Left call.");
|
||||||
Glow.state.calls.leaving = false;
|
Glow.state.calls.leaving = false;
|
||||||
@ -964,6 +1195,7 @@ Glow.endCallForEveryone = async () => {
|
|||||||
Glow.showToast("Call ended.");
|
Glow.showToast("Call ended.");
|
||||||
await Glow.endLocalCallUi();
|
await Glow.endLocalCallUi();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
Glow.calls.debug("endCallForEveryone failed", error);
|
||||||
Glow.showToast(error.message || "Could not end call.", "error");
|
Glow.showToast(error.message || "Could not end call.", "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1011,6 +1243,7 @@ Glow.openAddToCallDialog = async () => {
|
|||||||
const result = await Glow.calls.requestInvite(values.username.trim().toLowerCase());
|
const result = await Glow.calls.requestInvite(values.username.trim().toLowerCase());
|
||||||
Glow.showToast(`Invite sent to ${result.invite.invitee_username}.`);
|
Glow.showToast(`Invite sent to ${result.invite.invitee_username}.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
Glow.calls.debug("requestInvite failed", error);
|
||||||
Glow.showToast(error.message || "Could not send invite.", "error");
|
Glow.showToast(error.message || "Could not send invite.", "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1027,18 +1260,27 @@ Glow.calls.fetchInvites = async () => {
|
|||||||
Glow.calls.acceptInvite = async inviteId => {
|
Glow.calls.acceptInvite = async inviteId => {
|
||||||
const data = await Glow.apiAcceptGroupCallInvite(inviteId);
|
const data = await Glow.apiAcceptGroupCallInvite(inviteId);
|
||||||
|
|
||||||
const helperResp = await fetch(`${data.calls_base}/api/call/session`, {
|
let helperResp;
|
||||||
method: "POST",
|
try {
|
||||||
headers: { "Content-Type": "application/json" },
|
helperResp = await fetch(`${data.calls_base}/api/call/session`, {
|
||||||
body: JSON.stringify({ token: data.token })
|
method: "POST",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: data.token })
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Glow.calls.debug("acceptInvite helper fetch failed", error);
|
||||||
|
throw new Error("Could not reach the call helper service.");
|
||||||
|
}
|
||||||
|
|
||||||
let helperData = {};
|
let helperData = {};
|
||||||
try {
|
try {
|
||||||
helperData = await helperResp.json();
|
helperData = await helperResp.json();
|
||||||
} catch (_) {}
|
} catch (error) {
|
||||||
|
Glow.calls.debug("acceptInvite helper json parse failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
if (!helperResp.ok || !helperData.ok) {
|
if (!helperResp.ok || !helperData.ok) {
|
||||||
|
Glow.calls.debug("acceptInvite helper returned error", helperResp.status, helperData);
|
||||||
throw new Error(helperData.error || `Call helper failed (${helperResp.status})`);
|
throw new Error(helperData.error || `Call helper failed (${helperResp.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1074,6 +1316,11 @@ Glow.calls.openIncomingInviteDialog = async invite => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const supportError = Glow.calls.browserSupportError();
|
||||||
|
if (supportError) {
|
||||||
|
throw new Error(supportError);
|
||||||
|
}
|
||||||
|
|
||||||
Glow.state.calls.joining = true;
|
Glow.state.calls.joining = true;
|
||||||
Glow.calls.setStatusText("Joining call…");
|
Glow.calls.setStatusText("Joining call…");
|
||||||
|
|
||||||
@ -1094,6 +1341,9 @@ Glow.calls.openIncomingInviteDialog = async invite => {
|
|||||||
Glow.state.calls.remoteStreams = new Map();
|
Glow.state.calls.remoteStreams = new Map();
|
||||||
Glow.state.calls.participantAudioEls = new Map();
|
Glow.state.calls.participantAudioEls = new Map();
|
||||||
Glow.state.calls.backendParticipants = session.participants || [];
|
Glow.state.calls.backendParticipants = session.participants || [];
|
||||||
|
Glow.state.calls.makingOffer = new Map();
|
||||||
|
Glow.state.calls.ignoreOffer = new Map();
|
||||||
|
Glow.state.calls.isSettingRemoteAnswerPending = new Map();
|
||||||
|
|
||||||
Glow.openCallPanel();
|
Glow.openCallPanel();
|
||||||
Glow.calls.setStatusText("Connecting microphone…");
|
Glow.calls.setStatusText("Connecting microphone…");
|
||||||
@ -1109,6 +1359,7 @@ Glow.calls.openIncomingInviteDialog = async invite => {
|
|||||||
Glow.renderCallUi();
|
Glow.renderCallUi();
|
||||||
Glow.showToast("Joined group call.");
|
Glow.showToast("Joined group call.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
Glow.calls.debug("openIncomingInviteDialog failed", error);
|
||||||
Glow.state.calls.joining = false;
|
Glow.state.calls.joining = false;
|
||||||
Glow.showToast(error.message || "Could not join group call.", "error");
|
Glow.showToast(error.message || "Could not join group call.", "error");
|
||||||
}
|
}
|
||||||
@ -1124,7 +1375,9 @@ Glow.calls.pollInvites = async () => {
|
|||||||
|
|
||||||
const invite = invites[0];
|
const invite = invites[0];
|
||||||
await Glow.calls.openIncomingInviteDialog(invite);
|
await Glow.calls.openIncomingInviteDialog(invite);
|
||||||
} catch (_) {}
|
} catch (error) {
|
||||||
|
Glow.calls.debug("pollInvites failed", error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.calls.scheduleInvitePolling = () => {
|
Glow.calls.scheduleInvitePolling = () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user