glo/static/js/12-calls.js
2026-10-30 00:57:03 +00:00

1168 lines
32 KiB
JavaScript

// static/js/12-calls.js
"use strict";
window.Glow = window.Glow || {};
Glow.calls = Glow.calls || {};
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";
};
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) return false;
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.renderCallUi();
};
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);
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.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;
}
}
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
};
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.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);
Glow.calls.removeRemoteAudioElement(peerId);
Glow.calls.syncRemoteAudioElements();
};
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.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 {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
});
state.localStream = stream;
return stream;
} catch (error) {
if (error?.name === "NotAllowedError" || error?.name === "PermissionDeniedError") {
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);
}
const pc = new RTCPeerConnection({
iceServers: state.session?.iceServers || []
});
pc.onicecandidate = event => {
if (!event.candidate) return;
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;
state.remoteStreams.set(peerId, stream);
Glow.calls.syncRemoteAudioElements();
};
pc.onconnectionstatechange = () => {
const bad =
pc.connectionState === "failed" ||
pc.connectionState === "disconnected";
if (bad) {
Glow.showToast(`Connection issue with ${peerId}.`, "error");
}
};
state.peerConnections.set(peerId, pc);
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 => {
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
});
};
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 (_) {}
}
};
Glow.calls.fetchGroupCallSession = async 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 helperData = {};
try {
helperData = await helperResp.json();
} catch (_) {}
if (!helperResp.ok || !helperData.ok) {
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.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.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("div");
left.className = "call-participant-name";
left.textContent =
participant.display_name || participant.username || `user ${participant.user_id}`;
if (participant) {
Glow.applyUserColor(left, participant);
}
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()
);
});
right.textContent = matchingPeer ? "connected" : "joined";
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("div");
left.className = "call-participant-name";
left.textContent = peer.username || peer.participantId;
const right = document.createElement("div");
right.className = "call-participant-status";
right.textContent = "connecting";
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.renderHeaderCallChip = () => {
if (!Glow.els.callStatusChip) return;
const active = Boolean(Glow.state.calls.activeCall);
Glow.els.callStatusChip.classList.toggle("hidden", !active);
Glow.els.callStatusChip.textContent = active ? "Call active" : "Call";
};
Glow.calls.connectWebSocket = async () => {
Glow.calls.ensureState();
const state = Glow.calls.state();
if (!state.session?.ws_url) {
throw new Error("Missing helper WebSocket URL.");
}
return new Promise((resolve, reject) => {
const ws = new WebSocket(Glow.calls.replaceHttpWs(state.session.ws_url));
state.ws = ws;
ws.onopen = () => {
state.reconnecting = false;
state.joining = false;
Glow.calls.setStatusText("Connected");
resolve();
};
ws.onerror = error => {
reject(error);
};
ws.onclose = () => {
if (state.activeCall) {
Glow.calls.scheduleReconnect();
}
};
ws.onmessage = async event => {
let signal;
try {
signal = JSON.parse(event.data);
} catch (_) {
return;
}
if (signal.type === "joined") {
state.selfPeerId = signal.participantId;
Glow.calls.setStatusText("Call connected");
Glow.renderCallUi();
Glow.calls.renderParticipants();
return;
}
if (signal.type === "participant-joined") {
Glow.calls.upsertPeer({
participantId: signal.participantId,
roomId: signal.roomId
});
Glow.renderCallUi();
Glow.calls.renderParticipants();
if (signal.participantId && signal.participantId !== state.selfPeerId) {
try {
await Glow.calls.makeOfferTo(signal.participantId);
} catch (_) {}
}
return;
}
if (signal.type === "participant-left") {
Glow.calls.removePeer(signal.participantId);
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 (_) {}
}
return;
}
if (signal.type === "offer") {
const from = signal.from;
if (!from) return;
Glow.calls.upsertPeer({
participantId: from,
roomId: signal.roomId,
username: signal.username || from
});
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);
Glow.calls.sendSignal({
type: "answer",
to: from,
sdp: answer.sdp
});
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 });
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 (_) {}
}
};
});
};
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.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;
};
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
};
};
Glow.endLocalCallUi = async () => {
Glow.calls.resetAll();
if (Glow.els.callParticipants) {
Glow.els.callParticipants.replaceChildren();
}
if (Glow.els.callPendingInvites) {
Glow.els.callPendingInvites.replaceChildren();
Glow.els.callPendingInvites.classList.add("hidden");
}
if (Glow.els.remoteAudioContainer) {
Glow.els.remoteAudioContainer.replaceChildren();
}
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.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 (_) {
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.renderCallUi();
} catch (_) {}
};
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);
} catch (_) {}
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 scope = state.activeCall?.scope || state.scope || Glow.calls.getCurrentScope();
const peerCount = Glow.calls.listOtherPeers().length;
if (!Glow.els.callStatus) return;
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 (!active) {
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
);
Glow.els.retryCallButton?.classList.toggle(
"hidden",
!active || !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
);
Glow.els.muteCallButton.textContent =
state.muted ? "Unmute" : "Mute";
Glow.calls.renderParticipants();
Glow.calls.renderPendingInvites();
Glow.calls.renderHeaderCallChip();
};
Glow.openCallPanel = async () => {
Glow.els.callOverlay?.classList.remove("hidden");
if (Glow.state.calls.activeCall?.groupSession?.id) {
await Glow.calls.refreshBackendParticipants();
}
Glow.renderCallUi();
};
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;
}
if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) {
Glow.showToast("Voice calls are not supported in this browser.", "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.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.renderCallUi();
Glow.showToast("Group call ready.");
} catch (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.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 (_) {}
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.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.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);
const helperResp = await fetch(`${data.calls_base}/api/call/session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: data.token })
});
let helperData = {};
try {
helperData = await helperResp.json();
} catch (_) {}
if (!helperResp.ok || !helperData.ok) {
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 {
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.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.renderCallUi();
Glow.showToast("Joined group call.");
} catch (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 (_) {}
};
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?.();
});