glo/static/js/12-calls.js
2026-09-24 22:33:36 +00:00

390 lines
9.6 KiB
JavaScript

// static/js/12-calls.js
"use strict";
window.Glow = window.Glow || {};
Glow.calls = Glow.calls || {};
Glow.calls.iceServers = [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" }
];
Glow.getActiveDmPeer = () => {
if (!Glow.state.activeDm || !Glow.state.currentUser) return null;
return (Glow.state.activeDm.members || []).find(
member => Number(member.id) !== Number(Glow.state.currentUser.id)
) || null;
};
Glow.renderCallUi = () => {
const call = Glow.state.calls.activeCall;
const hasCall = Boolean(call);
const incoming = Glow.state.calls.incomingCall;
const peer = Glow.getActiveDmPeer();
if (!Glow.els.callStatus) return;
Glow.els.callPeerLabel.textContent =
peer?.display_name || peer?.username || "No active call";
Glow.els.callStatus.textContent = incoming
? "Incoming call"
: hasCall
? call.active
? "In call"
: "Ended"
: "Idle";
Glow.els.startCallButton?.classList.toggle(
"hidden",
!Glow.state.activeDm || hasCall || Boolean(incoming)
);
Glow.els.answerCallButton?.classList.toggle("hidden", !incoming);
Glow.els.muteCallButton?.classList.toggle("hidden", !hasCall);
Glow.els.hangupCallButton?.classList.toggle("hidden", !hasCall && !incoming);
Glow.els.muteCallButton.textContent =
Glow.state.calls.muted ? "Unmute" : "Mute";
};
Glow.openCallPanel = () => {
Glow.els.callOverlay?.classList.remove("hidden");
Glow.renderCallUi();
};
Glow.endLocalCallUi = async () => {
try {
if (Glow.state.calls.peerConnection) {
Glow.state.calls.peerConnection.onicecandidate = null;
Glow.state.calls.peerConnection.ontrack = null;
Glow.state.calls.peerConnection.close();
}
} catch (_) {}
try {
if (Glow.state.calls.localStream) {
for (const track of Glow.state.calls.localStream.getTracks()) {
track.stop();
}
}
} catch (_) {}
Glow.state.calls.peerConnection = null;
Glow.state.calls.localStream = null;
Glow.state.calls.activeCall = null;
Glow.state.calls.incomingCall = null;
Glow.state.calls.pendingOffer = null;
Glow.state.calls.signalLastSeenId = 0;
Glow.state.calls.muted = false;
clearTimeout(Glow.state.calls.pollTimer);
if (Glow.els.remoteAudio) {
Glow.els.remoteAudio.srcObject = null;
}
Glow.renderCallUi();
};
Glow.ensurePeerConnection = async () => {
if (Glow.state.calls.peerConnection) {
return Glow.state.calls.peerConnection;
}
const pc = new RTCPeerConnection({
iceServers: Glow.calls.iceServers
});
pc.onicecandidate = async event => {
if (!event.candidate || !Glow.state.calls.activeCall) return;
try {
await Glow.apiPostCallSignal(Glow.state.calls.activeCall.id, {
signal_type: "ice",
payload: { candidate: event.candidate }
});
} catch (_) {}
};
pc.ontrack = event => {
const stream = event.streams?.[0];
if (stream && Glow.els.remoteAudio) {
Glow.els.remoteAudio.srcObject = stream;
}
};
Glow.state.calls.peerConnection = pc;
return pc;
};
Glow.ensureLocalAudio = async () => {
if (Glow.state.calls.localStream) {
return Glow.state.calls.localStream;
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
});
Glow.state.calls.localStream = stream;
return stream;
};
Glow.attachLocalTracks = async () => {
const pc = await Glow.ensurePeerConnection();
const stream = await Glow.ensureLocalAudio();
const existingSenders = pc.getSenders().map(sender => sender.track).filter(Boolean);
for (const track of stream.getTracks()) {
if (!existingSenders.includes(track)) {
pc.addTrack(track, stream);
}
}
};
Glow.startCall = async () => {
if (!Glow.state.activeDm) {
Glow.showToast("Open a DM to start a call.", "error");
return;
}
if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) {
Glow.showToast("Voice calls are not supported in this browser.", "error");
return;
}
try {
const data = await Glow.apiCreateCall(Glow.state.activeDm.id);
Glow.state.calls.activeCall = data.call;
Glow.state.calls.incomingCall = null;
Glow.state.calls.pendingOffer = null;
Glow.state.calls.signalLastSeenId = 0;
Glow.openCallPanel();
await Glow.attachLocalTracks();
const pc = await Glow.ensurePeerConnection();
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await Glow.apiPostCallSignal(data.call.id, {
signal_type: "offer",
payload: { sdp: offer }
});
await Glow.apiPostCallSignal(data.call.id, {
signal_type: "ring",
payload: {}
});
Glow.renderCallUi();
Glow.scheduleCallPolling();
} catch (error) {
Glow.showToast(error.message || "Could not start call.", "error");
await Glow.endLocalCallUi();
}
};
Glow.answerIncomingCall = async () => {
const incoming = Glow.state.calls.incomingCall;
const pendingOffer = Glow.state.calls.pendingOffer;
if (!incoming || !pendingOffer) return;
try {
Glow.state.calls.activeCall = incoming;
Glow.state.calls.incomingCall = null;
Glow.openCallPanel();
await Glow.attachLocalTracks();
const pc = await Glow.ensurePeerConnection();
await pc.setRemoteDescription(new RTCSessionDescription(pendingOffer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await Glow.apiPostCallSignal(incoming.id, {
signal_type: "answer",
payload: { sdp: answer }
});
Glow.state.calls.pendingOffer = null;
Glow.renderCallUi();
Glow.scheduleCallPolling();
} catch (error) {
Glow.showToast(error.message || "Could not answer call.", "error");
await Glow.endLocalCallUi();
}
};
Glow.handleCallSignal = async signal => {
const activeCall = Glow.state.calls.activeCall;
const incomingCall = Glow.state.calls.incomingCall;
if (signal.signal_type === "ring") {
if (!activeCall && !incomingCall) {
try {
const data = await Glow.apiGetCall(signal.call_session_id);
Glow.state.calls.incomingCall = data.call;
Glow.openCallPanel();
Glow.renderCallUi();
} catch (_) {}
}
return;
}
if (signal.signal_type === "offer") {
try {
const data = await Glow.apiGetCall(signal.call_session_id);
Glow.state.calls.incomingCall = data.call;
Glow.state.calls.pendingOffer = signal.payload?.sdp || signal.payload;
Glow.openCallPanel();
Glow.renderCallUi();
} catch (_) {}
return;
}
if (!Glow.state.calls.activeCall || Number(Glow.state.calls.activeCall.id) !== Number(signal.call_session_id)) {
return;
}
const pc = await Glow.ensurePeerConnection();
if (signal.signal_type === "answer") {
const sdp = signal.payload?.sdp || signal.payload;
if (sdp) {
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
}
return;
}
if (signal.signal_type === "ice") {
const candidate = signal.payload?.candidate || signal.payload;
if (candidate) {
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (_) {}
}
return;
}
if (signal.signal_type === "hangup") {
Glow.showToast("Call ended.");
await Glow.endLocalCallUi();
return;
}
};
Glow.pollCallSignals = async () => {
const activeCall = Glow.state.calls.activeCall;
const incomingCall = Glow.state.calls.incomingCall;
const call = activeCall || incomingCall;
if (!call || document.hidden) return;
try {
const data = await Glow.apiGetCallSignals(
call.id,
Glow.state.calls.signalLastSeenId || 0
);
const signals = data.signals || [];
for (const signal of signals) {
Glow.state.calls.signalLastSeenId = Math.max(
Glow.state.calls.signalLastSeenId || 0,
Number(signal.id) || 0
);
if (
Glow.state.currentUser &&
Number(signal.user_id) === Number(Glow.state.currentUser.id)
) {
continue;
}
await Glow.handleCallSignal(signal);
}
} catch (_) {}
};
Glow.scheduleCallPolling = () => {
clearTimeout(Glow.state.calls.pollTimer);
if (!Glow.state.calls.activeCall && !Glow.state.calls.incomingCall) {
return;
}
Glow.state.calls.pollTimer = setTimeout(async () => {
await Glow.pollCallSignals();
Glow.scheduleCallPolling();
}, document.hidden ? 5000 : 2000);
};
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.hangupCall = async () => {
const call = Glow.state.calls.activeCall || Glow.state.calls.incomingCall;
if (!call) return;
try {
await Glow.apiEndCall(call.id);
} catch (_) {
// ignore
}
Glow.showToast("Call ended.");
await Glow.endLocalCallUi();
};
Glow.syncActiveCallForDm = async () => {
if (!Glow.state.activeDm) return;
try {
const createResult = await Glow.apiCreateCall(Glow.state.activeDm.id);
const call = createResult.call;
if (call?.active) {
Glow.state.calls.activeCall = call;
Glow.renderCallUi();
Glow.scheduleCallPolling();
}
} catch (_) {
// ignore if no active call semantics vary
}
};
Glow.els.startCallButton?.addEventListener("click", () => {
Glow.startCall?.();
});
Glow.els.answerCallButton?.addEventListener("click", () => {
Glow.answerIncomingCall?.();
});
Glow.els.muteCallButton?.addEventListener("click", () => {
Glow.toggleMuteCall?.();
});
Glow.els.hangupCallButton?.addEventListener("click", () => {
Glow.hangupCall?.();
});