// 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 = () => { const state = Glow.calls.state(); state.activeCall ||= null; state.ws ||= null; state.session ||= null; state.localStream ||= null; state.muted ||= false; state.selfPeerId ||= null; state.peerConnections ||= new Map(); state.remoteStreams ||= new Map(); state.participantAudioEls ||= new Map(); state.peers ||= []; state.reconnectTimer ||= null; state.invitePollTimer ||= null; state.participantPollTimer ||= null; state.heartbeatTimer ||= null; state.invites ||= []; state.reconnecting ||= false; state.joining ||= false; state.leaving ||= false; state.permissions ||= { can_manage: false, can_end: false, can_invite: false }; state.backendParticipants ||= []; state.scope ||= null; state.statusText ||= "idle"; state.makingOffer ||= new Map(); state.ignoreOffer ||= new Map(); state.isSettingRemoteAnswerPending ||= new Map(); state.panelOpen ??= false; state.drag ||= { active: false, x: null, y: null, offsetX: 0, offsetY: 0 }; }; Glow.calls.scopeLabel = invite => { if (!invite) return "call"; if (invite.scope_kind === "channel") { return `channel ${invite.scope_slug}`; } return `room ${invite.scope_slug}`; }; Glow.calls.getCurrentScope = () => { if (Glow.state.activeTeam && Glow.state.activeChannel) { return { scope_kind: "channel", team_slug: Glow.state.activeTeam.slug, channel_slug: Glow.state.activeChannel.slug, label: `# ${Glow.state.activeChannel.name}` }; } if (Glow.state.activeRoom) { return { scope_kind: "room", room_slug: Glow.state.activeRoom.slug, label: Glow.state.activeRoom.name }; } return null; }; Glow.calls.isGroupCallContext = () => { return Boolean(Glow.calls.getCurrentScope()); }; Glow.calls.wsReady = () => { const ws = Glow.calls.state().ws; return Boolean(ws && ws.readyState === WebSocket.OPEN); }; Glow.calls.sendSignal = payload => { const ws = Glow.calls.state().ws; 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; }; Glow.calls.replaceHttpWs = url => url.replace(/^https:/, "wss:").replace(/^http:/, "ws:"); 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(); if (!Glow.els.remoteAudioContainer) return null; let audio = state.participantAudioEls.get(peerId); if (audio) return audio; audio = document.createElement("audio"); audio.autoplay = true; audio.playsInline = true; 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; }; Glow.calls.removeRemoteAudioElement = peerId => { Glow.calls.ensureState(); const state = Glow.calls.state(); const audio = state.participantAudioEls.get(peerId); if (!audio) return; audio.srcObject = null; audio.remove(); state.participantAudioEls.delete(peerId); Glow.calls.debug("removed remote audio element for peer", peerId); }; Glow.calls.syncRemoteAudioElements = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); for (const [peerId, stream] of state.remoteStreams.entries()) { const audio = Glow.calls.ensureRemoteAudioElement(peerId); if (audio && audio.srcObject !== stream) { audio.srcObject = stream; Glow.calls.debug("bound remote stream to audio", peerId); } } for (const peerId of [...state.participantAudioEls.keys()]) { if (!state.remoteStreams.has(peerId)) { Glow.calls.removeRemoteAudioElement(peerId); } } }; Glow.calls.upsertPeer = peer => { Glow.calls.ensureState(); const state = Glow.calls.state(); const peers = state.peers || []; const participantId = peer.participantId || peer.peer_id || peer.peerId || peer.id; if (!participantId) return; const normalized = { participantId, roomId: peer.roomId || peer.room_id || state.session?.call_id || "", username: peer.username || participantId, connectionState: peer.connectionState || "connecting" }; const index = peers.findIndex(item => item.participantId === participantId); if (index >= 0) { peers[index] = { ...peers[index], ...normalized }; } else { peers.push(normalized); } state.peers = peers; Glow.calls.debug("upsertPeer", normalized); }; Glow.calls.removePeerConnection = peerId => { Glow.calls.ensureState(); const state = Glow.calls.state(); const pc = state.peerConnections.get(peerId); if (pc) { try { pc.close(); } catch (_) {} } 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 => { Glow.calls.ensureState(); 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 = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); return (state.peers || []).filter(peer => peer.participantId !== state.selfPeerId); }; Glow.calls.ensureLocalAudio = async () => { Glow.calls.ensureState(); const state = Glow.calls.state(); 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") { throw new Error("No microphone was found on this device."); } if (error?.name === "NotReadableError" || error?.name === "TrackStartError") { throw new Error("The microphone is already in use by another application."); } if (error?.name === "SecurityError") { throw new Error("Microphone access is blocked by browser security settings."); } throw new Error("Could not access your microphone."); } }; Glow.calls.ensurePeerConnection = async peerId => { Glow.calls.ensureState(); const state = Glow.calls.state(); if (state.peerConnections.has(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, candidate: event.candidate.candidate, sdpMid: event.candidate.sdpMid, sdpMLineIndex: event.candidate.sdpMLineIndex }); }; 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 = () => { const nextState = pc.connectionState || "connecting"; Glow.calls.debug("pc connection state", peerId, nextState); const peer = state.peers.find(item => item.participantId === peerId); if (peer) { peer.connectionState = nextState; } const bad = nextState === "failed" || nextState === "disconnected"; if (bad) { Glow.showToast(`Connection issue with ${peerId}.`, "error"); } Glow.calls.renderParticipants(); Glow.calls.updateSummaryMetrics(); }; 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); for (const track of stream.getTracks()) { if (!existingTracks.includes(track)) { pc.addTrack(track, stream); } } return pc; }; 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); 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 () => { const peers = Glow.calls.listOtherPeers(); if (!peers.length) { Glow.showToast("No peers available yet.", "error"); return; } for (const peer of peers) { try { await Glow.calls.makeOfferTo(peer.participantId); } 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) }); 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 (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})`); } helperData.calls_base = data.calls_base; helperData.group_session = data.session; helperData.participants = data.participants || []; helperData.permissions = data.permissions || { can_manage: false, can_end: false, can_invite: false }; helperData.scope = data.scope || null; Glow.calls.debug("helper session ready", helperData); return helperData; }; Glow.calls.renderPendingInvites = () => { const wrap = Glow.els.callPendingInvites; if (!wrap) return; wrap.replaceChildren(); const activeCallId = Glow.state.calls.activeCall?.groupSession?.id; const relevant = (Glow.state.calls.invites || []).filter( invite => Number(invite.group_call_session_id) === Number(activeCallId) ); if (!relevant.length) { wrap.classList.add("hidden"); return; } const title = document.createElement("div"); title.className = "call-pending-title"; title.textContent = "Pending invites"; wrap.append(title); for (const invite of relevant) { const row = document.createElement("div"); row.className = "call-pending-row"; const left = document.createElement("div"); left.className = "call-pending-name"; left.textContent = invite.inviter_username ? `from ${invite.inviter_username}` : "pending invite"; const right = document.createElement("div"); right.className = "call-pending-status"; right.textContent = "pending"; row.append(left, right); wrap.append(row); } wrap.classList.remove("hidden"); }; Glow.calls.getParticipantStateLabel = peer => { const state = String(peer?.connectionState || "").toLowerCase(); if (state === "connected") return "active"; if (state === "connecting" || state === "new") return "connecting"; if (state === "disconnected") return "disconnected"; if (state === "failed") return "reconnecting"; return "joined"; }; Glow.calls.renderParticipants = () => { if (!Glow.els.callParticipants) return; Glow.calls.ensureState(); const state = Glow.calls.state(); const box = Glow.els.callParticipants; box.replaceChildren(); const backendParticipants = Array.isArray(state.backendParticipants) ? state.backendParticipants : []; const peerMap = new Map( (state.peers || []).map(peer => [String(peer.participantId), peer]) ); const selfRow = document.createElement("div"); selfRow.className = "call-participant self"; const selfLeft = document.createElement("div"); selfLeft.className = "call-participant-name"; selfLeft.textContent = Glow.state.currentUser?.username ? `${Glow.state.currentUser.username} (you)` : "you"; const selfRight = document.createElement("div"); selfRight.className = "call-participant-status"; selfRight.textContent = state.activeCall ? state.muted ? "connected · muted" : "connected" : "idle"; selfRow.append(selfLeft, selfRight); box.append(selfRow); const renderedUserIds = new Set(); if (Glow.state.currentUser?.id) { renderedUserIds.add(String(Glow.state.currentUser.id)); } for (const participant of backendParticipants) { const userId = String(participant.user_id); if (renderedUserIds.has(userId)) continue; renderedUserIds.add(userId); const row = document.createElement("div"); row.className = "call-participant"; const left = document.createElement("button"); left.className = "call-participant-name profile-link"; left.type = "button"; left.textContent = participant.display_name || participant.username || `user ${participant.user_id}`; Glow.applyUserColor(left, participant); if (participant.username) { left.addEventListener("click", () => { Glow.openProfile?.(participant.username); }); } const right = document.createElement("div"); right.className = "call-participant-status"; const matchingPeer = [...peerMap.values()].find(peer => { return ( String(peer.username || "").toLowerCase() === String(participant.username || "").toLowerCase() ); }); const label = Glow.calls.getParticipantStateLabel(matchingPeer); right.textContent = label; right.dataset.state = label; row.append(left, right); box.append(row); } const extraPeers = Glow.calls.listOtherPeers().filter(peer => { return !backendParticipants.some( participant => String(participant.username || "").toLowerCase() === String(peer.username || "").toLowerCase() ); }); for (const peer of extraPeers) { const row = document.createElement("div"); row.className = "call-participant"; const left = document.createElement("button"); left.className = "call-participant-name profile-link"; left.type = "button"; left.textContent = peer.username || peer.participantId; if (peer.username) { left.addEventListener("click", () => { Glow.openProfile?.(peer.username); }); } const right = document.createElement("div"); right.className = "call-participant-status"; const label = Glow.calls.getParticipantStateLabel(peer); right.textContent = label; right.dataset.state = label; row.append(left, right); box.append(row); } if (box.children.length === 1 && state.activeCall) { const empty = document.createElement("div"); empty.className = "call-participant"; const label = document.createElement("div"); label.className = "call-participant-name"; label.textContent = "waiting for others to join"; empty.append(label); box.append(empty); } }; Glow.calls.updateSummaryMetrics = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); const createdAt = state.activeCall?.groupSession?.created_at || state.session?.group_session?.created_at || null; let elapsedSeconds = 0; if (createdAt) { const ms = new Date(createdAt).getTime(); if (!Number.isNaN(ms)) { elapsedSeconds = Math.max(0, Math.floor((Date.now() - ms) / 1000)); } } state.metrics.elapsedSeconds = elapsedSeconds; state.metrics.userCount = 1 + Glow.calls.listOtherPeers().length; if (Glow.els.callUserCountStat) { Glow.els.callUserCountStat.textContent = String(state.metrics.userCount); } if (Glow.els.callElapsedStat) { const minutes = Math.floor(elapsedSeconds / 60); const seconds = elapsedSeconds % 60; Glow.els.callElapsedStat.textContent = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; } if (Glow.els.callLatencyStat) { Glow.els.callLatencyStat.textContent = state.metrics.latency == null ? "—" : `${state.metrics.latency} ms`; } if (Glow.els.callPacketLossStat) { Glow.els.callPacketLossStat.textContent = state.metrics.packetLoss == null ? "—" : `${state.metrics.packetLoss}%`; } if (Glow.els.callUptimeStat) { Glow.els.callUptimeStat.textContent = state.metrics.uptime == null ? "—" : `${state.metrics.uptime}%`; } }; Glow.calls.connectWebSocket = async () => { Glow.calls.ensureState(); const state = Glow.calls.state(); if (!state.session?.ws_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) => { 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"); Glow.calls.updateSummaryMetrics(); settled = true; resolve(); }; ws.onerror = error => { Glow.calls.debug("websocket error", error); if (!settled) { settled = true; reject(new Error("Could not connect to the call signaling service.")); } }; 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(); } }; ws.onmessage = async event => { let signal; 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"); Glow.calls.updateSummaryMetrics(); Glow.renderCallUi(); Glow.calls.renderParticipants(); return; } if (signal.type === "participant-joined") { state.ignoreOffer.set(signal.participantId, false); Glow.calls.upsertPeer({ participantId: signal.participantId, roomId: signal.roomId, username: signal.username || signal.participantId, connectionState: "connecting" }); Glow.calls.updateSummaryMetrics(); Glow.renderCallUi(); Glow.calls.renderParticipants(); if (signal.participantId && signal.participantId !== state.selfPeerId) { try { await Glow.calls.makeOfferTo(signal.participantId); } catch (error) { Glow.calls.debug("offer on participant-joined failed", error); } } return; } if (signal.type === "participant-left") { Glow.calls.removePeer(signal.participantId); Glow.calls.updateSummaryMetrics(); Glow.renderCallUi(); Glow.calls.renderParticipants(); Glow.showToast("Peer left call."); return; } if (signal.type === "peer-reconnected") { Glow.showToast("Peer reconnected."); return; } if (signal.type === "reoffer-needed") { if (signal.participantId) { try { await Glow.calls.makeOfferTo(signal.participantId); } catch (error) { Glow.calls.debug("reoffer-needed failed", error); } } return; } if (signal.type === "offer") { const from = signal.from; if (!from) return; Glow.calls.upsertPeer({ participantId: from, roomId: signal.roomId, username: signal.username || from, connectionState: "connecting" }); Glow.calls.renderParticipants(); const pc = await Glow.calls.ensurePeerConnection(from); 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); 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); 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 (error) { const ignored = Boolean(state.ignoreOffer.get(from)); if (!ignored) { Glow.calls.debug("addIceCandidate failed", { from, error: error?.message || String(error) }); } } } }; }); }; Glow.calls.resetTransportOnly = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); clearTimeout(state.reconnectTimer); try { if (state.ws) { state.ws.onopen = null; state.ws.onclose = null; state.ws.onerror = null; state.ws.onmessage = null; state.ws.close(); } } catch (_) {} for (const [, pc] of state.peerConnections) { try { pc.onicecandidate = null; pc.ontrack = null; pc.onconnectionstatechange = null; pc.onsignalingstatechange = null; pc.oniceconnectionstatechange = null; pc.close(); } catch (_) {} } for (const peerId of [...state.participantAudioEls.keys()]) { Glow.calls.removeRemoteAudioElement(peerId); } state.ws = null; state.peerConnections = new Map(); state.remoteStreams = new Map(); state.participantAudioEls = new Map(); state.peers = []; 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 = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); clearTimeout(state.participantPollTimer); clearTimeout(state.heartbeatTimer); Glow.calls.resetTransportOnly(); try { if (state.localStream) { for (const track of state.localStream.getTracks()) { track.stop(); } } } catch (_) {} state.localStream = null; state.activeCall = null; state.session = null; state.muted = false; state.joining = false; state.leaving = false; state.backendParticipants = []; state.scope = null; state.statusText = "Idle"; state.permissions = { can_manage: false, can_end: false, can_invite: false }; state.panelOpen = false; state.metrics = { packetLoss: null, uptime: null, latency: null, elapsedSeconds: 0, userCount: 0 }; Glow.calls.debug("resetAll complete"); }; Glow.endLocalCallUi = async () => { Glow.calls.resetAll(); Glow.state.calls.panelOpen = false; if (Glow.els.callParticipants) { Glow.els.callParticipants.replaceChildren(); Glow.els.callParticipants.classList.add("hidden"); } if (Glow.els.callPendingInvites) { Glow.els.callPendingInvites.replaceChildren(); Glow.els.callPendingInvites.classList.add("hidden"); } if (Glow.els.remoteAudioContainer) { Glow.els.remoteAudioContainer.replaceChildren(); } Glow.els.callOverlay?.classList.add("hidden"); Glow.renderCallUi(); Glow.calls.renderHeaderCallChip(); }; Glow.calls.scheduleReconnect = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); clearTimeout(state.reconnectTimer); state.reconnecting = true; Glow.calls.setStatusText("Reconnecting..."); Glow.renderCallUi(); state.reconnectTimer = setTimeout(async () => { try { if (!state.activeCall) return; 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); state.permissions = state.session.permissions || state.permissions; state.scope = state.session.scope || state.scope; if (state.session.group_session?.id) { await Glow.apiJoinGroupCall(state.session.group_session.id); } await Glow.calls.connectWebSocket(); Glow.calls.startParticipantSync(); Glow.calls.startHeartbeat(); Glow.showToast("Call reconnected."); Glow.renderCallUi(); Glow.calls.refreshBackendParticipants(); } catch (error) { Glow.calls.debug("reconnect failed", error); Glow.calls.scheduleReconnect(); } }, 2000); }; Glow.calls.refreshBackendParticipants = async () => { Glow.calls.ensureState(); const sessionId = Glow.state.calls.activeCall?.groupSession?.id; if (!sessionId) return; try { const data = await Glow.apiGetGroupCallParticipants(sessionId); Glow.state.calls.backendParticipants = data.participants || []; Glow.state.calls.permissions = data.permissions || Glow.state.calls.permissions; Glow.state.calls.scope = data.scope || Glow.state.calls.scope; Glow.calls.renderParticipants(); Glow.calls.updateSummaryMetrics(); Glow.renderCallUi(); Glow.calls.debug("backend participants refreshed", data); } catch (error) { Glow.calls.debug("refreshBackendParticipants failed", error); } }; Glow.calls.startParticipantSync = () => { Glow.calls.ensureState(); clearTimeout(Glow.state.calls.participantPollTimer); const run = async () => { if (!Glow.state.calls.activeCall || document.hidden) { Glow.calls.startParticipantSync(); return; } await Glow.calls.refreshBackendParticipants(); Glow.calls.startParticipantSync(); }; Glow.state.calls.participantPollTimer = setTimeout(run, 5000); }; Glow.calls.startHeartbeat = () => { Glow.calls.ensureState(); clearTimeout(Glow.state.calls.heartbeatTimer); const run = async () => { const sessionId = Glow.state.calls.activeCall?.groupSession?.id; if (!sessionId) return; try { await Glow.apiHeartbeatGroupCall(sessionId); Glow.calls.debug("heartbeat ok", sessionId); } catch (error) { Glow.calls.debug("heartbeat failed", error); } Glow.calls.startHeartbeat(); }; Glow.state.calls.heartbeatTimer = setTimeout(run, 12000); }; Glow.renderCallUi = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); const active = Boolean(state.activeCall); const busy = Boolean(state.activeCall || state.joining || state.reconnecting); const overlayVisible = Boolean(state.panelOpen || busy); const scope = state.activeCall?.scope || state.scope || Glow.calls.getCurrentScope(); const peerCount = Glow.calls.listOtherPeers().length; if (!Glow.els.callStatus) return; Glow.els.callOverlay?.classList.toggle("hidden", !overlayVisible); Glow.els.callPeerLabel.textContent = scope?.label || state.scope?.scope_label || Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "No active call"; let statusText = state.statusText || "Idle"; if (!busy) { statusText = "Idle"; } else if (state.joining) { statusText = "Joining call..."; } else if (state.reconnecting) { statusText = "Reconnecting..."; } else if (!Glow.calls.wsReady()) { statusText = "Connecting..."; } else if (peerCount > 0) { statusText = `Group call active · ${peerCount} peer(s)`; } else { statusText = "Call open · waiting for others"; } Glow.els.callStatus.textContent = statusText; Glow.els.startCallButton?.classList.toggle( "hidden", !Glow.calls.isGroupCallContext() || active || state.joining ); Glow.els.retryCallButton?.classList.toggle( "hidden", !busy || !state.reconnecting ); Glow.els.muteCallButton?.classList.toggle("hidden", !active); Glow.els.hangupCallButton?.classList.toggle("hidden", !active); Glow.els.addToCallButton?.classList.toggle( "hidden", !active || !state.permissions?.can_invite ); Glow.els.endCallButton?.classList.toggle( "hidden", !active || !state.permissions?.can_end ); if (Glow.els.muteCallButton) { Glow.els.muteCallButton.textContent = state.muted ? "Unmute" : "Mute"; } if (Glow.els.callParticipants) { Glow.els.callParticipants.classList.toggle("hidden", !overlayVisible); } Glow.calls.updateSummaryMetrics(); Glow.calls.renderParticipants(); Glow.calls.renderPendingInvites(); Glow.calls.renderHeaderCallChip(); }; Glow.calls.applyPanelPosition = () => { Glow.calls.ensureState(); const overlay = Glow.els.callOverlay; const drag = Glow.state.calls?.drag; if (!overlay || !drag) return; if (!Number.isFinite(drag.x) || !Number.isFinite(drag.y)) return; overlay.style.left = `${drag.x}px`; overlay.style.top = `${drag.y}px`; overlay.style.right = "auto"; overlay.style.bottom = "auto"; overlay.style.alignItems = "flex-start"; overlay.style.justifyContent = "flex-start"; }; Glow.calls.loadPanelPosition = () => { Glow.calls.ensureState(); const drag = Glow.state.calls?.drag; if (!drag) return; const x = Number(localStorage.getItem("glow_call_panel_x")); const y = Number(localStorage.getItem("glow_call_panel_y")); if (!Number.isFinite(x) || !Number.isFinite(y)) return; drag.x = x; drag.y = y; Glow.calls.applyPanelPosition(); }; Glow.calls.savePanelPosition = () => { Glow.calls.ensureState(); const drag = Glow.state.calls?.drag; if (!drag) return; if (!Number.isFinite(drag.x) || !Number.isFinite(drag.y)) return; localStorage.setItem("glow_call_panel_x", String(drag.x)); localStorage.setItem("glow_call_panel_y", String(drag.y)); }; Glow.calls.beginDrag = event => { Glow.calls.ensureState(); const overlay = Glow.els.callOverlay; const drag = Glow.state.calls?.drag; if (!overlay || !drag) return; const rect = overlay.getBoundingClientRect(); drag.active = true; drag.x = rect.left; drag.y = rect.top; drag.offsetX = event.clientX - rect.left; drag.offsetY = event.clientY - rect.top; }; Glow.calls.onDrag = event => { const drag = Glow.state.calls?.drag; const overlay = Glow.els.callOverlay; if (!drag?.active || !overlay) return; const maxX = Math.max(0, window.innerWidth - overlay.offsetWidth); const maxY = Math.max(0, window.innerHeight - overlay.offsetHeight); drag.x = Math.min(maxX, Math.max(0, event.clientX - drag.offsetX)); drag.y = Math.min(maxY, Math.max(0, event.clientY - drag.offsetY)); Glow.calls.applyPanelPosition(); }; Glow.calls.endDrag = () => { const drag = Glow.state.calls?.drag; if (!drag) return; if (!drag.active) return; drag.active = false; Glow.calls.savePanelPosition(); }; Glow.startCall = async () => { Glow.calls.ensureState(); const scope = Glow.calls.getCurrentScope(); if (!scope) { Glow.showToast("Open a room or channel to start a group call.", "error"); return; } const supportError = Glow.calls.browserSupportError(); if (supportError) { Glow.showToast(supportError, "error"); return; } try { Glow.state.calls.joining = true; Glow.calls.setStatusText("Preparing call…"); const session = await Glow.calls.fetchGroupCallSession(scope); Glow.state.calls.session = session; Glow.state.calls.activeCall = { id: session.call_id, active: true, scope, groupSession: session.group_session }; Glow.state.calls.scope = session.scope || scope; Glow.state.calls.permissions = session.permissions || Glow.state.calls.permissions; Glow.state.calls.selfPeerId = session.peer_id || null; Glow.state.calls.peers = []; Glow.state.calls.peerConnections = new Map(); 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.state.calls.panelOpen = true; Glow.openCallPanel(); Glow.calls.setStatusText("Connecting microphone…"); await Glow.calls.ensureLocalAudio(); Glow.calls.setStatusText("Joining call…"); await Glow.apiJoinGroupCall(session.group_session.id); await Glow.calls.connectWebSocket(); Glow.calls.startParticipantSync(); Glow.calls.startHeartbeat(); Glow.state.calls.joining = false; Glow.calls.updateSummaryMetrics(); 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(); } }; Glow.toggleMuteCall = () => { const stream = Glow.state.calls.localStream; if (!stream) return; Glow.state.calls.muted = !Glow.state.calls.muted; for (const track of stream.getAudioTracks()) { track.enabled = !Glow.state.calls.muted; } Glow.calls.debug("mute toggled", Glow.state.calls.muted); Glow.renderCallUi(); }; Glow.leaveCall = async () => { Glow.calls.ensureState(); const activeCall = Glow.state.calls.activeCall; if (!activeCall) return; Glow.state.calls.leaving = true; Glow.calls.setStatusText("Leaving call…"); try { Glow.calls.sendSignal({ type: "hangup" }); } catch (_) {} try { if (activeCall.groupSession?.id) { await Glow.apiLeaveGroupCall(activeCall.groupSession.id); } } catch (error) { Glow.calls.debug("leave call api failed", error); } Glow.showToast("Left call."); Glow.state.calls.leaving = false; await Glow.endLocalCallUi(); }; Glow.endCallForEveryone = async () => { Glow.calls.ensureState(); const activeCall = Glow.state.calls.activeCall; if (!activeCall?.groupSession?.id) return; const values = await Glow.openDialog({ title: "End call for everyone?", body: "This will end the active call session for all participants.", confirmText: "End call", cancelText: "Cancel", danger: true }); if (!values) return; try { Glow.calls.setStatusText("Ending call…"); await Glow.apiEndGroupCall(activeCall.groupSession.id); Glow.showToast("Call ended."); await Glow.endLocalCallUi(); } catch (error) { Glow.calls.debug("endCallForEveryone failed", error); Glow.showToast(error.message || "Could not end call.", "error"); } }; Glow.hangupCall = async () => { await Glow.leaveCall(); }; Glow.calls.requestInvite = async username => { Glow.calls.ensureState(); const activeCall = Glow.state.calls.activeCall; if (!activeCall?.groupSession?.id) { throw new Error("No active group call session."); } return Glow.apiInviteToGroupCall({ group_call_session_id: activeCall.groupSession.id, username }); }; Glow.openAddToCallDialog = async () => { if (!Glow.state.calls.activeCall?.groupSession?.id) { Glow.showToast("Start a call first.", "error"); return; } const values = await Glow.openDialog({ title: "Add user to call", body: "Send a stored invite to another user for this room/channel call.", confirmText: "Send invite", fields: [ { name: "username", label: "Username", placeholder: "username", maxLength: 30 } ] }); if (!values?.username) return; try { 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"); } }; Glow.calls.fetchInvites = async () => { Glow.calls.ensureState(); if (!Glow.state.currentUser) return []; const data = await Glow.apiListGroupCallInvites(); Glow.state.calls.invites = data.invites || []; Glow.calls.renderPendingInvites(); return Glow.state.calls.invites; }; Glow.calls.acceptInvite = async inviteId => { const data = await Glow.apiAcceptGroupCallInvite(inviteId); 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 (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})`); } helperData.calls_base = data.calls_base; helperData.group_session = data.session; helperData.participants = data.participants || []; helperData.permissions = data.permissions || { can_manage: false, can_end: false, can_invite: false }; helperData.scope = data.scope || null; return helperData; }; Glow.calls.dismissInvite = async inviteId => { await Glow.apiDismissGroupCallInvite(inviteId); }; Glow.calls.openIncomingInviteDialog = async invite => { const values = await Glow.openDialog({ title: "Incoming group call invite", body: `${invite.inviter_username} invited you to join ${Glow.calls.scopeLabel(invite)}.`, confirmText: "Join call", cancelText: "Dismiss" }); if (!values) { try { await Glow.calls.dismissInvite(invite.id); } catch (_) {} return; } try { const supportError = Glow.calls.browserSupportError(); if (supportError) { throw new Error(supportError); } Glow.state.calls.joining = true; Glow.calls.setStatusText("Joining call…"); const session = await Glow.calls.acceptInvite(invite.id); Glow.state.calls.session = session; Glow.state.calls.activeCall = { id: session.call_id, active: true, scope: null, groupSession: session.group_session }; Glow.state.calls.scope = session.scope || null; Glow.state.calls.permissions = session.permissions || Glow.state.calls.permissions; Glow.state.calls.selfPeerId = session.peer_id || null; Glow.state.calls.peers = []; Glow.state.calls.peerConnections = new Map(); 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.state.calls.panelOpen = true; Glow.openCallPanel(); Glow.calls.setStatusText("Connecting microphone…"); await Glow.calls.ensureLocalAudio(); if (session.group_session?.id) { Glow.calls.setStatusText("Joining call…"); await Glow.apiJoinGroupCall(session.group_session.id); } await Glow.calls.connectWebSocket(); Glow.calls.startParticipantSync(); Glow.calls.startHeartbeat(); Glow.state.calls.joining = false; Glow.calls.updateSummaryMetrics(); 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"); } }; Glow.calls.pollInvites = async () => { Glow.calls.ensureState(); if (!Glow.state.currentUser || document.hidden) return; try { const invites = await Glow.calls.fetchInvites(); if (!invites.length) return; const invite = invites[0]; await Glow.calls.openIncomingInviteDialog(invite); } catch (error) { Glow.calls.debug("pollInvites failed", error); } }; Glow.calls.scheduleInvitePolling = () => { Glow.calls.ensureState(); clearTimeout(Glow.state.calls.invitePollTimer); if (!Glow.state.currentUser) return; Glow.state.calls.invitePollTimer = setTimeout(async () => { await Glow.calls.pollInvites(); Glow.calls.scheduleInvitePolling(); }, document.hidden ? 12000 : 6000); }; Glow.els.startCallButton?.addEventListener("click", async () => { if (!Glow.state.calls.activeCall) { await Glow.startCall(); return; } await Glow.calls.callAllPeers(); }); Glow.els.retryCallButton?.addEventListener("click", async () => { clearTimeout(Glow.state.calls.reconnectTimer); Glow.calls.scheduleReconnect(); }); Glow.els.muteCallButton?.addEventListener("click", () => { Glow.toggleMuteCall?.(); }); Glow.els.hangupCallButton?.addEventListener("click", () => { Glow.hangupCall?.(); }); Glow.els.endCallButton?.addEventListener("click", () => { Glow.endCallForEveryone?.(); }); Glow.els.addToCallButton?.addEventListener("click", () => { Glow.openAddToCallDialog?.(); }); Glow.els.callDragHandle?.addEventListener("mousedown", event => { if (event.button !== 0) return; event.preventDefault(); Glow.calls.beginDrag(event); }); document.addEventListener("mousemove", event => { Glow.calls.onDrag?.(event); }); document.addEventListener("mouseup", () => { Glow.calls.endDrag?.(); }); window.addEventListener("resize", () => { const drag = Glow.state.calls?.drag; const overlay = Glow.els.callOverlay; if (!overlay || !drag) return; if (!Number.isFinite(drag.x) || !Number.isFinite(drag.y)) return; const maxX = Math.max(0, window.innerWidth - overlay.offsetWidth); const maxY = Math.max(0, window.innerHeight - overlay.offsetHeight); drag.x = Math.min(maxX, Math.max(0, drag.x)); drag.y = Math.min(maxY, Math.max(0, drag.y)); Glow.calls.applyPanelPosition(); Glow.calls.savePanelPosition(); }); //window.addEventListener("resize", () => { // if (Glow.state.calls?.panelOpen) { // Glow.calls.applyPanelPosition?.(); // } //}); // // Glow.calls.loadPanelPosition?.(); Glow.calls.ensureState(); Glow.calls.loadPanelPosition(); setInterval(() => { if (Glow.state.calls?.activeCall) { Glow.calls.updateSummaryMetrics?.(); } }, 1000);