Files
kkkmeet/public/app.js
T
2026-06-09 21:22:54 +02:00

413 lines
13 KiB
JavaScript

const localVideo = document.querySelector("#local-video");
const localNameLabel = document.querySelector("#local-name");
const remoteGrid = document.querySelector("#remote-grid");
const remoteTemplate = document.querySelector("#remote-template");
const nameForm = document.querySelector("#name-form");
const nameInput = document.querySelector("#name-input");
const statusText = document.querySelector("#status");
const cameraSelect = document.querySelector("#camera-select");
const micSelect = document.querySelector("#mic-select");
const cameraButton = document.querySelector("#camera-button");
const micButton = document.querySelector("#mic-button");
const shareButton = document.querySelector("#share-button");
const roomId = "main";
const nameStorageKey = "kkkmeet.displayName";
const cameraStorageKey = "kkkmeet.cameraDeviceId";
const micStorageKey = "kkkmeet.micDeviceId";
const rtcConfig = {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
};
let peerId = "";
let displayName = "";
let localStream;
let screenStream;
let events;
const peers = new Map();
function setStatus(message) {
statusText.textContent = message;
}
function normalizeName(name) {
return name.trim().replace(/\s+/g, " ").slice(0, 48);
}
function setDisplayName(nextName) {
displayName = normalizeName(nextName);
localStorage.setItem(nameStorageKey, displayName);
nameInput.value = displayName;
localNameLabel.textContent = displayName ? `${displayName} (you)` : "You";
}
function labelForPeer(peer) {
return peer?.name || `Participant ${peer?.id?.slice(0, 4) || ""}`.trim();
}
function mediaConstraints() {
const cameraDeviceId = localStorage.getItem(cameraStorageKey);
const micDeviceId = localStorage.getItem(micStorageKey);
return {
audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true,
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
...(cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : {})
}
};
}
async function ensureLocalStream() {
if (localStream) return localStream;
try {
localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints());
} catch (error) {
localStorage.removeItem(cameraStorageKey);
localStorage.removeItem(micStorageKey);
localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints());
}
localVideo.srcObject = localStream;
await refreshDeviceLists();
return localStream;
}
function trackDeviceId(kind) {
return localStream?.getTracks().find((track) => track.kind === kind)?.getSettings().deviceId || "";
}
function fillDeviceSelect(select, devices, selectedDeviceId, fallbackLabel) {
select.replaceChildren();
if (devices.length === 0) {
const option = new Option(`No ${fallbackLabel.toLowerCase()} found`, "");
select.add(option);
select.disabled = true;
return;
}
select.disabled = false;
devices.forEach((device, index) => {
const label = device.label || `${fallbackLabel} ${index + 1}`;
select.add(new Option(label, device.deviceId));
});
select.value = selectedDeviceId && devices.some((device) => device.deviceId === selectedDeviceId)
? selectedDeviceId
: devices[0].deviceId;
}
async function refreshDeviceLists() {
if (!navigator.mediaDevices?.enumerateDevices) return;
const devices = await navigator.mediaDevices.enumerateDevices();
fillDeviceSelect(
cameraSelect,
devices.filter((device) => device.kind === "videoinput"),
trackDeviceId("video") || localStorage.getItem(cameraStorageKey),
"Camera"
);
fillDeviceSelect(
micSelect,
devices.filter((device) => device.kind === "audioinput"),
trackDeviceId("audio") || localStorage.getItem(micStorageKey),
"Microphone"
);
}
async function replaceLocalTrack(kind, deviceId) {
if (!deviceId) return;
if (!localStream) {
localStorage.setItem(kind === "video" ? cameraStorageKey : micStorageKey, deviceId);
return;
}
const oldTrack = localStream?.getTracks().find((track) => track.kind === kind);
const wasEnabled = oldTrack?.enabled ?? true;
const constraints = kind === "video"
? { video: { deviceId: { exact: deviceId }, width: { ideal: 1280 }, height: { ideal: 720 } } }
: { audio: { deviceId: { exact: deviceId } } };
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const newTrack = stream.getTracks()[0];
newTrack.enabled = wasEnabled;
if (!localStream) {
localStream = new MediaStream([newTrack]);
} else {
if (oldTrack) {
localStream.removeTrack(oldTrack);
oldTrack.stop();
}
localStream.addTrack(newTrack);
}
if (kind === "audio" || !screenStream) {
for (const { connection } of peers.values()) {
const sender = connection.getSenders().find((item) => item.track?.kind === kind);
await sender?.replaceTrack(newTrack);
}
}
if (kind === "video" && !screenStream) {
localVideo.srcObject = localStream;
}
localStorage.setItem(kind === "video" ? cameraStorageKey : micStorageKey, deviceId);
await refreshDeviceLists();
}
function updateRemoteLabel(id, name) {
const label = document.querySelector(`[data-peer-id="${id}"] span`);
if (label) label.textContent = name || `Participant ${id.slice(0, 4)}`;
}
function createRemoteTile(peer) {
const id = typeof peer === "string" ? peer : peer.id;
const fragment = remoteTemplate.content.cloneNode(true);
const tile = fragment.querySelector(".tile");
const video = fragment.querySelector("video");
const label = fragment.querySelector("span");
tile.dataset.peerId = id;
label.textContent = labelForPeer(typeof peer === "string" ? { id } : peer);
remoteGrid.append(tile);
return { tile, video };
}
function removeRemoteTile(id) {
document.querySelector(`[data-peer-id="${id}"]`)?.remove();
}
async function sendSignal(to, payload) {
await fetch(`/signal/${encodeURIComponent(roomId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...payload, from: peerId, to, name: displayName })
});
}
function createPeerConnection(remotePeer) {
const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id;
if (peers.has(remotePeerId)) {
updateRemoteLabel(remotePeerId, remotePeer.name);
return peers.get(remotePeerId).connection;
}
const connection = new RTCPeerConnection(rtcConfig);
const remoteStream = new MediaStream();
const { video } = createRemoteTile(remotePeer);
video.srcObject = remoteStream;
localStream.getTracks().forEach((track) => {
connection.addTrack(track, localStream);
});
connection.ontrack = (event) => {
event.streams[0].getTracks().forEach((track) => {
remoteStream.addTrack(track);
});
};
connection.onicecandidate = (event) => {
if (event.candidate) {
sendSignal(remotePeerId, { type: "ice-candidate", candidate: event.candidate });
}
};
connection.onconnectionstatechange = () => {
if (["closed", "disconnected", "failed"].includes(connection.connectionState)) {
closePeer(remotePeerId);
}
};
peers.set(remotePeerId, { connection, remoteStream, name: remotePeer.name });
return connection;
}
async function callPeer(remotePeer) {
const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id;
const connection = createPeerConnection(remotePeer);
const offer = await connection.createOffer();
await connection.setLocalDescription(offer);
await sendSignal(remotePeerId, { type: "offer", description: connection.localDescription });
}
async function answerPeer(message) {
const connection = createPeerConnection({ id: message.from, name: message.name });
await connection.setRemoteDescription(message.description);
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
await sendSignal(message.from, { type: "answer", description: connection.localDescription });
}
async function handleSignal(message) {
if (message.type === "welcome") {
peerId = message.peerId;
setStatus(`Joined the call as ${displayName}. ${message.peers.length} other participant(s).`);
for (const existingPeer of message.peers) {
await callPeer(existingPeer);
}
return;
}
if (message.type === "peer-joined") {
updateRemoteLabel(message.peerId, message.name);
setStatus(`${message.name || "A participant"} joined.`);
return;
}
if (message.type === "peer-left") {
closePeer(message.peerId);
setStatus("A participant left.");
return;
}
if (message.type === "offer") {
await answerPeer(message);
return;
}
const connection = peers.get(message.from)?.connection;
if (!connection) return;
if (message.type === "answer") {
await connection.setRemoteDescription(message.description);
}
if (message.type === "ice-candidate") {
await connection.addIceCandidate(message.candidate);
}
}
function closePeer(remotePeerId) {
const peer = peers.get(remotePeerId);
if (!peer) return;
peer.connection.close();
peers.delete(remotePeerId);
removeRemoteTile(remotePeerId);
}
async function joinCall() {
if (!displayName) {
setStatus("Enter your name to join the call.");
nameInput.focus();
return;
}
leaveCall();
await ensureLocalStream();
const params = new URLSearchParams({ name: displayName });
events = new EventSource(`/events/${encodeURIComponent(roomId)}?${params}`);
events.onmessage = (event) => handleSignal(JSON.parse(event.data)).catch(console.error);
events.onerror = () => setStatus("Connection to signaling server was interrupted.");
setStatus(`Joining as ${displayName}...`);
}
function leaveCall() {
events?.close();
events = undefined;
peerId = "";
for (const remotePeerId of peers.keys()) closePeer(remotePeerId);
setStatus(displayName ? `Ready as ${displayName}.` : "Enter your name to join the call.");
}
function setTrackEnabled(kind, enabled) {
localStream?.getTracks().forEach((track) => {
if (track.kind === kind) track.enabled = enabled;
});
}
function updateToggleButton(button, enabled, enabledLabel, disabledLabel) {
button.setAttribute("aria-pressed", String(enabled));
button.setAttribute("aria-label", enabled ? enabledLabel : disabledLabel);
button.title = enabled ? enabledLabel : disabledLabel;
button.textContent = enabled ? "●" : "○";
}
nameForm.addEventListener("submit", (event) => {
event.preventDefault();
setDisplayName(nameInput.value);
joinCall().catch((error) => {
console.error(error);
setStatus("Could not start camera or join the call.");
});
});
nameInput.addEventListener("change", () => {
setDisplayName(nameInput.value);
if (events) {
joinCall().catch((error) => {
console.error(error);
setStatus("Could not rejoin with the updated name.");
});
}
});
cameraButton.addEventListener("click", () => {
const enabled = cameraButton.getAttribute("aria-pressed") !== "true";
updateToggleButton(cameraButton, enabled, "Turn camera off", "Turn camera on");
setTrackEnabled("video", enabled);
});
micButton.addEventListener("click", () => {
const enabled = micButton.getAttribute("aria-pressed") !== "true";
updateToggleButton(micButton, enabled, "Mute microphone", "Unmute microphone");
setTrackEnabled("audio", enabled);
});
cameraSelect.addEventListener("change", () => {
replaceLocalTrack("video", cameraSelect.value).catch((error) => {
console.error(error);
setStatus("Could not switch camera.");
});
});
micSelect.addEventListener("change", () => {
replaceLocalTrack("audio", micSelect.value).catch((error) => {
console.error(error);
setStatus("Could not switch microphone.");
});
});
shareButton.addEventListener("click", async () => {
if (!localStream) return;
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const screenTrack = screenStream.getVideoTracks()[0];
for (const { connection } of peers.values()) {
const sender = connection.getSenders().find((item) => item.track?.kind === "video");
await sender?.replaceTrack(screenTrack);
}
localVideo.srcObject = screenStream;
screenTrack.onended = async () => {
const cameraTrack = localStream.getVideoTracks()[0];
for (const { connection } of peers.values()) {
const sender = connection.getSenders().find((item) => item.track?.kind === "video");
await sender?.replaceTrack(cameraTrack);
}
localVideo.srcObject = localStream;
screenStream = undefined;
};
});
const savedName = localStorage.getItem(nameStorageKey);
if (savedName) {
setDisplayName(savedName);
joinCall().catch((error) => {
console.error(error);
setStatus("Could not start camera or join the call.");
});
} else {
nameInput.focus();
refreshDeviceLists().catch(console.error);
}
navigator.mediaDevices?.addEventListener?.("devicechange", () => {
refreshDeviceLists().catch(console.error);
});