1668 lines
47 KiB
JavaScript
1668 lines
47 KiB
JavaScript
// 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); |