diff --git a/static/js/12-calls.js b/static/js/12-calls.js index a69dc6c..4587193 100644 --- a/static/js/12-calls.js +++ b/static/js/12-calls.js @@ -1,8 +1,14 @@ -// static/js/12-calls.js "use strict"; window.Glow = window.Glow || {}; 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.ensureState = () => { @@ -33,6 +39,9 @@ Glow.calls.ensureState = () => { state.backendParticipants ||= []; state.scope ||= null; state.statusText ||= "Idle"; + state.makingOffer ||= new Map(); + state.ignoreOffer ||= new Map(); + state.isSettingRemoteAnswerPending ||= new Map(); }; Glow.calls.scopeLabel = invite => { @@ -73,7 +82,11 @@ Glow.calls.wsReady = () => { Glow.calls.sendSignal = payload => { 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)); return true; }; @@ -84,9 +97,30 @@ Glow.calls.replaceHttpWs = url => Glow.calls.setStatusText = text => { Glow.calls.ensureState(); Glow.calls.state().statusText = String(text || ""); + Glow.calls.debug("status:", text); 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.ensureState(); const state = Glow.calls.state(); @@ -101,6 +135,7 @@ Glow.calls.ensureRemoteAudioElement = peerId => { audio.dataset.peerId = String(peerId); Glow.els.remoteAudioContainer.append(audio); state.participantAudioEls.set(peerId, audio); + Glow.calls.debug("created remote audio element for peer", peerId); return audio; }; @@ -112,6 +147,7 @@ Glow.calls.removeRemoteAudioElement = peerId => { audio.srcObject = null; audio.remove(); state.participantAudioEls.delete(peerId); + Glow.calls.debug("removed remote audio element for peer", peerId); }; Glow.calls.syncRemoteAudioElements = () => { @@ -122,6 +158,7 @@ Glow.calls.syncRemoteAudioElements = () => { const audio = Glow.calls.ensureRemoteAudioElement(peerId); if (audio && 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); } state.peers = peers; + Glow.calls.debug("upsertPeer", normalized); }; Glow.calls.removePeerConnection = peerId => { @@ -168,8 +206,12 @@ Glow.calls.removePeerConnection = peerId => { state.peerConnections.delete(peerId); state.remoteStreams.delete(peerId); + state.makingOffer.delete(peerId); + state.ignoreOffer.delete(peerId); + state.isSettingRemoteAnswerPending.delete(peerId); Glow.calls.removeRemoteAudioElement(peerId); Glow.calls.syncRemoteAudioElements(); + Glow.calls.debug("removed peer connection", peerId); }; Glow.calls.removePeer = peerId => { @@ -177,6 +219,7 @@ Glow.calls.removePeer = peerId => { const state = Glow.calls.state(); state.peers = (state.peers || []).filter(peer => peer.participantId !== peerId); Glow.calls.removePeerConnection(peerId); + Glow.calls.debug("removed peer", peerId); }; Glow.calls.listOtherPeers = () => { @@ -191,14 +234,21 @@ Glow.calls.ensureLocalAudio = async () => { if (state.localStream) return state.localStream; try { + Glow.calls.debug("requesting local microphone"); const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); state.localStream = stream; + Glow.calls.debug("microphone ready"); return stream; } catch (error) { + Glow.calls.debug("getUserMedia failed", error); 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."); } if (error?.name === "NotFoundError" || error?.name === "DevicesNotFoundError") { @@ -222,12 +272,15 @@ Glow.calls.ensurePeerConnection = async peerId => { return state.peerConnections.get(peerId); } + Glow.calls.debug("creating peer connection", peerId); + const pc = new RTCPeerConnection({ iceServers: state.session?.iceServers || [] }); pc.onicecandidate = event => { if (!event.candidate) return; + Glow.calls.debug("ice candidate", peerId, event.candidate); Glow.calls.sendSignal({ type: "ice", to: peerId, @@ -240,11 +293,13 @@ Glow.calls.ensurePeerConnection = async peerId => { pc.ontrack = event => { const stream = event.streams?.[0]; if (!stream) return; + Glow.calls.debug("received remote track", peerId, stream); state.remoteStreams.set(peerId, stream); Glow.calls.syncRemoteAudioElements(); }; pc.onconnectionstatechange = () => { + Glow.calls.debug("pc connection state", peerId, pc.connectionState); const bad = pc.connectionState === "failed" || 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.makingOffer.set(peerId, false); + state.ignoreOffer.set(peerId, false); + state.isSettingRemoteAnswerPending.set(peerId, false); const stream = await Glow.calls.ensureLocalAudio(); 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.ensureState(); + const state = Glow.calls.state(); + + Glow.calls.debug("making offer to peer", peerId); const pc = await Glow.calls.ensurePeerConnection(peerId); - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - Glow.calls.sendSignal({ - type: "offer", - to: peerId, - sdp: offer.sdp - }); + + if (pc.signalingState !== "stable") { + Glow.calls.debug("skipping offer because pc not stable", { + peerId, + signalingState: pc.signalingState + }); + 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 () => { @@ -288,29 +379,42 @@ Glow.calls.callAllPeers = async () => { for (const peer of peers) { try { await Glow.calls.makeOfferTo(peer.participantId); - } catch (_) {} + } catch (error) { + Glow.calls.debug("makeOfferTo failed", peer.participantId, error); + } } }; Glow.calls.fetchGroupCallSession = async scope => { + Glow.calls.debug("fetchGroupCallSession", scope); + const data = await Glow.apiRequest("/api/group-calls/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(scope) }); - const helperResp = await fetch(`${data.calls_base}/api/call/session`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: data.token }) - }); + let helperResp; + try { + helperResp = await fetch(`${data.calls_base}/api/call/session`, { + 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 = {}; try { helperData = await helperResp.json(); - } catch (_) {} + } catch (error) { + Glow.calls.debug("helper json parse failed", error); + } if (!helperResp.ok || !helperData.ok) { + Glow.calls.debug("helper returned error", helperResp.status, helperData); throw new Error(helperData.error || `Call helper failed (${helperResp.status})`); } @@ -323,6 +427,7 @@ Glow.calls.fetchGroupCallSession = async scope => { can_invite: false }; helperData.scope = data.scope || null; + Glow.calls.debug("helper session ready", helperData); return helperData; }; @@ -491,22 +596,37 @@ Glow.calls.connectWebSocket = async () => { 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) => { - const ws = new WebSocket(Glow.calls.replaceHttpWs(state.session.ws_url)); + const ws = new WebSocket(wsUrl); state.ws = ws; + let settled = false; ws.onopen = () => { + Glow.calls.debug("websocket open"); state.reconnecting = false; state.joining = false; Glow.calls.setStatusText("Connected"); + settled = true; resolve(); }; 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) { Glow.calls.scheduleReconnect(); } @@ -517,9 +637,12 @@ Glow.calls.connectWebSocket = async () => { try { signal = JSON.parse(event.data); } catch (_) { + Glow.calls.debug("ignoring non-json ws message"); return; } + Glow.calls.debug("ws message", signal); + if (signal.type === "joined") { state.selfPeerId = signal.participantId; Glow.calls.setStatusText("Call connected"); @@ -529,9 +652,11 @@ Glow.calls.connectWebSocket = async () => { } if (signal.type === "participant-joined") { + state.ignoreOffer.set(signal.participantId, false); Glow.calls.upsertPeer({ participantId: signal.participantId, - roomId: signal.roomId + roomId: signal.roomId, + username: signal.username || signal.participantId }); Glow.renderCallUi(); Glow.calls.renderParticipants(); @@ -539,7 +664,9 @@ Glow.calls.connectWebSocket = async () => { if (signal.participantId && signal.participantId !== state.selfPeerId) { try { await Glow.calls.makeOfferTo(signal.participantId); - } catch (_) {} + } catch (error) { + Glow.calls.debug("offer on participant-joined failed", error); + } } return; } @@ -561,7 +688,9 @@ Glow.calls.connectWebSocket = async () => { if (signal.participantId) { try { await Glow.calls.makeOfferTo(signal.participantId); - } catch (_) {} + } catch (error) { + Glow.calls.debug("reoffer-needed failed", error); + } } return; } @@ -578,37 +707,114 @@ Glow.calls.connectWebSocket = async () => { Glow.calls.renderParticipants(); const pc = await Glow.calls.ensurePeerConnection(from); - await pc.setRemoteDescription({ type: "offer", sdp: signal.sdp }); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); + const polite = Glow.calls.isPolitePeer(from); + const makingOffer = Boolean(state.makingOffer.get(from)); + const isSettingRemoteAnswerPending = Boolean(state.isSettingRemoteAnswerPending.get(from)); + const offerCollision = + makingOffer || + (pc.signalingState !== "stable" && !isSettingRemoteAnswerPending); - Glow.calls.sendSignal({ - type: "answer", - to: from, - sdp: answer.sdp + state.ignoreOffer.set(from, !polite && offerCollision); + + Glow.calls.debug("received offer", { + 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; } if (signal.type === "answer") { const from = signal.from; if (!from) return; + 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; } if (signal.type === "ice" || signal.type === "ice-candidate") { const from = signal.from; if (!from) return; + const pc = await Glow.calls.ensurePeerConnection(from); + try { await pc.addIceCandidate({ candidate: signal.candidate, sdpMid: signal.sdpMid, 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.ontrack = null; pc.onconnectionstatechange = null; + pc.onsignalingstatechange = null; + pc.oniceconnectionstatechange = null; pc.close(); } catch (_) {} } @@ -651,6 +859,11 @@ Glow.calls.resetTransportOnly = () => { state.selfPeerId = null; state.reconnectTimer = null; state.reconnecting = false; + state.makingOffer = new Map(); + state.ignoreOffer = new Map(); + state.isSettingRemoteAnswerPending = new Map(); + + Glow.calls.debug("resetTransportOnly complete"); }; Glow.calls.resetAll = () => { @@ -684,6 +897,8 @@ Glow.calls.resetAll = () => { can_end: false, can_invite: false }; + + Glow.calls.debug("resetAll complete"); }; Glow.endLocalCallUi = async () => { @@ -719,6 +934,7 @@ Glow.calls.scheduleReconnect = () => { const scope = state.activeCall.scope || state.scope || Glow.calls.getCurrentScope(); if (!scope) return; + Glow.calls.debug("reconnect attempt", scope); Glow.calls.resetTransportOnly(); Glow.calls.setStatusText("Reconnecting…"); state.session = await Glow.calls.fetchGroupCallSession(scope); @@ -733,7 +949,8 @@ Glow.calls.scheduleReconnect = () => { Glow.showToast("Call reconnected."); Glow.renderCallUi(); Glow.calls.refreshBackendParticipants(); - } catch (_) { + } catch (error) { + Glow.calls.debug("reconnect failed", error); Glow.calls.scheduleReconnect(); } }, 2000); @@ -751,7 +968,10 @@ Glow.calls.refreshBackendParticipants = async () => { Glow.state.calls.scope = data.scope || Glow.state.calls.scope; Glow.calls.renderParticipants(); Glow.renderCallUi(); - } catch (_) {} + Glow.calls.debug("backend participants refreshed", data); + } catch (error) { + Glow.calls.debug("refreshBackendParticipants failed", error); + } }; Glow.calls.startParticipantSync = () => { @@ -780,7 +1000,10 @@ Glow.calls.startHeartbeat = () => { try { await Glow.apiHeartbeatGroupCall(sessionId); - } catch (_) {} + Glow.calls.debug("heartbeat ok", sessionId); + } catch (error) { + Glow.calls.debug("heartbeat failed", error); + } Glow.calls.startHeartbeat(); }; @@ -866,8 +1089,9 @@ Glow.startCall = async () => { return; } - if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) { - Glow.showToast("Voice calls are not supported in this browser.", "error"); + const supportError = Glow.calls.browserSupportError(); + if (supportError) { + Glow.showToast(supportError, "error"); return; } @@ -891,6 +1115,9 @@ Glow.startCall = async () => { Glow.state.calls.remoteStreams = new Map(); Glow.state.calls.participantAudioEls = new Map(); 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.calls.setStatusText("Connecting microphone…"); @@ -904,6 +1131,7 @@ Glow.startCall = async () => { Glow.renderCallUi(); Glow.showToast("Group call ready."); } catch (error) { + Glow.calls.debug("startCall failed", error); Glow.state.calls.joining = false; Glow.showToast(error.message || "Could not start group call.", "error"); await Glow.endLocalCallUi(); @@ -917,6 +1145,7 @@ Glow.toggleMuteCall = () => { for (const track of stream.getAudioTracks()) { track.enabled = !Glow.state.calls.muted; } + Glow.calls.debug("mute toggled", Glow.state.calls.muted); Glow.renderCallUi(); }; @@ -936,7 +1165,9 @@ Glow.leaveCall = async () => { if (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.state.calls.leaving = false; @@ -964,6 +1195,7 @@ Glow.endCallForEveryone = async () => { Glow.showToast("Call ended."); await Glow.endLocalCallUi(); } catch (error) { + Glow.calls.debug("endCallForEveryone failed", 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()); Glow.showToast(`Invite sent to ${result.invite.invitee_username}.`); } catch (error) { + Glow.calls.debug("requestInvite failed", error); Glow.showToast(error.message || "Could not send invite.", "error"); } }; @@ -1027,18 +1260,27 @@ Glow.calls.fetchInvites = async () => { Glow.calls.acceptInvite = async inviteId => { const data = await Glow.apiAcceptGroupCallInvite(inviteId); - const helperResp = await fetch(`${data.calls_base}/api/call/session`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: data.token }) - }); + let helperResp; + try { + helperResp = await fetch(`${data.calls_base}/api/call/session`, { + 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 = {}; try { helperData = await helperResp.json(); - } catch (_) {} + } catch (error) { + Glow.calls.debug("acceptInvite helper json parse failed", error); + } 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})`); } @@ -1074,6 +1316,11 @@ Glow.calls.openIncomingInviteDialog = async invite => { } try { + const supportError = Glow.calls.browserSupportError(); + if (supportError) { + throw new Error(supportError); + } + Glow.state.calls.joining = true; Glow.calls.setStatusText("Joining call…"); @@ -1094,6 +1341,9 @@ Glow.calls.openIncomingInviteDialog = async invite => { Glow.state.calls.remoteStreams = new Map(); Glow.state.calls.participantAudioEls = new Map(); 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.calls.setStatusText("Connecting microphone…"); @@ -1109,6 +1359,7 @@ Glow.calls.openIncomingInviteDialog = async invite => { Glow.renderCallUi(); Glow.showToast("Joined group call."); } catch (error) { + Glow.calls.debug("openIncomingInviteDialog failed", error); Glow.state.calls.joining = false; Glow.showToast(error.message || "Could not join group call.", "error"); } @@ -1124,7 +1375,9 @@ Glow.calls.pollInvites = async () => { const invite = invites[0]; await Glow.calls.openIncomingInviteDialog(invite); - } catch (_) {} + } catch (error) { + Glow.calls.debug("pollInvites failed", error); + } }; Glow.calls.scheduleInvitePolling = () => {