390 lines
9.6 KiB
JavaScript
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?.();
|
|
});
|