From 35a04db978caf566ae1a46f1fa64e1b6e3ff3bf1 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 10 Jun 2026 22:33:09 +0200 Subject: [PATCH] Add local per-participant volume --- kkkmeet-server.el | 23 +++++++-- public/app.js | 124 ++++++++++++++++++++++++++++++++++++++++++---- public/index.html | 8 ++- public/styles.css | 33 ++++++++++-- server.js | 17 +++++-- 5 files changed, 183 insertions(+), 22 deletions(-) diff --git a/kkkmeet-server.el b/kkkmeet-server.el index 32f406a..405ddae 100644 --- a/kkkmeet-server.el +++ b/kkkmeet-server.el @@ -69,7 +69,8 @@ (maphash (lambda (peer-id process) (push `((id . ,peer-id) - (name . ,(or (process-get process :kkkmeet-name) "Guest"))) + (name . ,(or (process-get process :kkkmeet-name) "Guest")) + (clientId . ,(or (process-get process :kkkmeet-client-id) ""))) peers)) table) (vconcat (nreverse peers)))) @@ -86,6 +87,13 @@ (let ((normalized (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " (or text ""))))) (substring normalized 0 (min 240 (length normalized))))) +(defun kkkmeet-server--normalize-client-id (value) + "Return a safe persistent client id from VALUE." + (let ((normalized (string-trim (or value "")))) + (if (string-match-p "\\`[A-Za-z0-9_-]\\{16,80\\}\\'" normalized) + normalized + ""))) + (defun kkkmeet-server--nonnegative-number (value) "Return VALUE as a nonnegative number, or zero." (max 0 (if (numberp value) value 0))) @@ -263,16 +271,18 @@ When KEEP-OPEN is nil, close PROCESS after writing." (when-let ((query (cadr (split-string target "?" t)))) (cadr (assoc name (url-parse-query-string query))))) -(defun kkkmeet-server--handle-events (process room-id display-name) +(defun kkkmeet-server--handle-events (process room-id display-name client-id) "Attach PROCESS to ROOM-ID as an SSE client." (let* ((safe-room-id (if (string-empty-p room-id) kkkmeet-server--default-room-id room-id)) (room (kkkmeet-server--room safe-room-id)) (peer-id (kkkmeet-server--uuid)) (peers (kkkmeet-server--room-peers room)) - (safe-name (kkkmeet-server--normalize-name display-name))) + (safe-name (kkkmeet-server--normalize-name display-name)) + (safe-client-id (kkkmeet-server--normalize-client-id client-id))) (process-put process :kkkmeet-room-id safe-room-id) (process-put process :kkkmeet-peer-id peer-id) (process-put process :kkkmeet-name safe-name) + (process-put process :kkkmeet-client-id safe-client-id) (puthash peer-id process room) (kkkmeet-server--send-response process 200 @@ -284,6 +294,7 @@ When KEEP-OPEN is nil, close PROCESS after writing." process `((type . "welcome") (peerId . ,peer-id) (name . ,safe-name) + (clientId . ,safe-client-id) (peers . ,peers) (watchState . ,(or (gethash safe-room-id kkkmeet-server--watch-states) :null)) @@ -292,7 +303,8 @@ When KEEP-OPEN is nil, close PROCESS after writing." (kkkmeet-server--broadcast safe-room-id peer-id `((type . "peer-joined") (peerId . ,peer-id) - (name . ,safe-name))))) + (name . ,safe-name) + (clientId . ,safe-client-id))))) (defun kkkmeet-server--handle-signal (process room-id body) "Handle signaling BODY from PROCESS for ROOM-ID." @@ -413,7 +425,8 @@ When KEEP-OPEN is nil, close PROCESS after writing." (kkkmeet-server--handle-events process (url-unhex-string (string-remove-prefix "/events/" path)) - (kkkmeet-server--query-param target "name"))) + (kkkmeet-server--query-param target "name") + (kkkmeet-server--query-param target "clientId"))) ((and (equal method "POST") (string-prefix-p "/signal/" path)) (kkkmeet-server--handle-signal process (url-unhex-string (string-remove-prefix "/signal/" path)) body)) diff --git a/public/app.js b/public/app.js index 9757c09..f93a58b 100644 --- a/public/app.js +++ b/public/app.js @@ -32,6 +32,7 @@ const announcementCancelButton = document.querySelector("#announcement-cancel-bu const roomId = "main"; const nameStorageKey = "kkkmeet.displayName"; +const clientIdStorageKey = "kkkmeet.clientId"; const cameraStorageKey = "kkkmeet.cameraDeviceId"; const micStorageKey = "kkkmeet.micDeviceId"; const watchHeightStorageKey = "kkkmeet.watchPanelHeight"; @@ -46,6 +47,7 @@ let localStream; let screenStream; let localPresentationTile; let events; +let audioContext; let youtubePlayer; let youtubeReady = false; let currentYoutubeVideoId = ""; @@ -61,6 +63,18 @@ let youtubePlaybackRequestId = 0; let watchPlaylist = []; const peers = new Map(); +function persistentClientId() { + let storedClientId = localStorage.getItem(clientIdStorageKey); + if (!storedClientId) { + storedClientId = crypto.randomUUID?.() || + `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + localStorage.setItem(clientIdStorageKey, storedClientId); + } + return storedClientId; +} + +const clientId = persistentClientId(); + function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } @@ -756,8 +770,65 @@ async function replaceLocalTrack(kind, deviceId) { await refreshDeviceLists(); } +function volumeStorageKey(remoteClientId) { + return `kkkmeet.remoteVolume.${remoteClientId}`; +} + +function savedRemoteVolume(remoteClientId) { + if (!remoteClientId) return 100; + const saved = Number(localStorage.getItem(volumeStorageKey(remoteClientId))); + return Number.isFinite(saved) ? clamp(saved, 0, 200) : 100; +} + +function ensureAudioContext() { + if (!audioContext) { + audioContext = new AudioContext(); + } + if (audioContext.state === "suspended") { + audioContext.resume().catch(console.error); + } + return audioContext; +} + +function applyRemoteVolume(remotePeerId, volume) { + const peer = peers.get(remotePeerId); + if (!peer) return; + + const normalizedVolume = clamp(Number(volume) || 0, 0, 200); + if (peer.gainNode) { + peer.gainNode.gain.value = normalizedVolume / 100; + } + if (peer.clientId) { + localStorage.setItem(volumeStorageKey(peer.clientId), String(normalizedVolume)); + } +} + +function rebuildRemoteAudio(peerId) { + const peer = peers.get(peerId); + if (!peer) return; + + peer.sourceNode?.disconnect(); + peer.gainNode?.disconnect(); + peer.sourceNode = undefined; + peer.gainNode = undefined; + peer.audioDestination = undefined; + + const audioTracks = peer.remoteStream.getAudioTracks(); + if (audioTracks.length === 0) return; + + const context = ensureAudioContext(); + const audioStream = new MediaStream(audioTracks); + peer.sourceNode = context.createMediaStreamSource(audioStream); + peer.gainNode = context.createGain(); + peer.audioDestination = context.createMediaStreamDestination(); + peer.sourceNode.connect(peer.gainNode).connect(peer.audioDestination); + peer.audio.srcObject = peer.audioDestination.stream; + peer.audio.play().catch(() => {}); + applyRemoteVolume(peerId, peer.volumeInput.value); +} + function updateRemoteLabel(id, name) { - const label = document.querySelector(`[data-peer-id="${id}"] span`); + const label = document.querySelector(`[data-peer-id="${id}"] .tile-name`); if (label) label.textContent = name || `Participant ${id.slice(0, 4)}`; } @@ -766,11 +837,25 @@ function createRemoteTile(peer) { const fragment = remoteTemplate.content.cloneNode(true); const tile = fragment.querySelector(".tile"); const video = fragment.querySelector("video"); - const label = fragment.querySelector("span"); + const audio = document.createElement("audio"); + const label = fragment.querySelector(".tile-name"); + const volumeInput = fragment.querySelector(".participant-volume"); + const remoteClientId = typeof peer === "string" ? "" : peer.clientId || ""; tile.dataset.peerId = id; + tile.dataset.clientId = remoteClientId; + video.muted = true; + audio.autoplay = true; + audio.playsInline = true; + audio.hidden = true; + volumeInput.value = savedRemoteVolume(remoteClientId); + volumeInput.disabled = !remoteClientId; + volumeInput.addEventListener("input", () => { + applyRemoteVolume(id, volumeInput.value); + }); label.textContent = labelForPeer(typeof peer === "string" ? { id } : peer); + tile.append(audio); remoteGrid.append(tile); - return { tile, video }; + return { tile, video, audio, volumeInput }; } function updatePresentationLayout() { @@ -782,7 +867,8 @@ function createLocalPresentationTile(stream) { const fragment = remoteTemplate.content.cloneNode(true); const tile = fragment.querySelector(".tile"); const video = fragment.querySelector("video"); - const label = fragment.querySelector("span"); + const label = fragment.querySelector(".tile-name"); + fragment.querySelector(".participant-audio-controls")?.remove(); tile.classList.add("presentation-tile", "local-presentation-tile"); tile.dataset.localPresentation = "true"; label.textContent = "Your screen"; @@ -816,7 +902,7 @@ 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 }) + body: JSON.stringify({ ...payload, from: peerId, to, name: displayName, clientId }) }); } @@ -826,7 +912,7 @@ async function sendRoomEvent(payload) { await fetch(`/room-event/${encodeURIComponent(roomId)}`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ ...payload, from: peerId, name: displayName }) + body: JSON.stringify({ ...payload, from: peerId, name: displayName, clientId }) }); } @@ -846,13 +932,20 @@ async function replaceOutboundVideoTrack(track) { function createPeerConnection(remotePeer) { const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id; if (peers.has(remotePeerId)) { + const peer = peers.get(remotePeerId); + if (remotePeer.clientId && peer.clientId !== remotePeer.clientId) { + peer.clientId = remotePeer.clientId; + peer.volumeInput.disabled = false; + peer.volumeInput.value = savedRemoteVolume(peer.clientId); + applyRemoteVolume(remotePeerId, peer.volumeInput.value); + } updateRemoteLabel(remotePeerId, remotePeer.name); - return peers.get(remotePeerId).connection; + return peer.connection; } const connection = new RTCPeerConnection(rtcConfig); const remoteStream = new MediaStream(); - const { video } = createRemoteTile(remotePeer); + const { video, audio, volumeInput } = createRemoteTile(remotePeer); video.srcObject = remoteStream; outboundTracks().forEach((track) => { @@ -863,6 +956,7 @@ function createPeerConnection(remotePeer) { event.streams[0].getTracks().forEach((track) => { remoteStream.addTrack(track); }); + rebuildRemoteAudio(remotePeerId); }; connection.onicecandidate = (event) => { @@ -877,7 +971,14 @@ function createPeerConnection(remotePeer) { } }; - peers.set(remotePeerId, { connection, remoteStream, name: remotePeer.name }); + peers.set(remotePeerId, { + connection, + remoteStream, + name: remotePeer.name, + clientId: remotePeer.clientId || "", + audio, + volumeInput + }); return connection; } @@ -981,6 +1082,9 @@ function closePeer(remotePeerId) { const peer = peers.get(remotePeerId); if (!peer) return; + peer.sourceNode?.disconnect(); + peer.gainNode?.disconnect(); + peer.audio.srcObject = null; peer.connection.close(); peers.delete(remotePeerId); removeRemoteTile(remotePeerId); @@ -996,7 +1100,7 @@ async function joinCall() { leaveCall(); await ensureLocalStream(); - const params = new URLSearchParams({ name: displayName }); + const params = new URLSearchParams({ name: displayName, clientId }); 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."); diff --git a/public/index.html b/public/index.html index c7683ea..195ab7c 100644 --- a/public/index.html +++ b/public/index.html @@ -123,7 +123,13 @@ diff --git a/public/styles.css b/public/styles.css index cf3d81a..df9f6bd 100644 --- a/public/styles.css +++ b/public/styles.css @@ -435,7 +435,7 @@ button[aria-pressed="false"] { min-height: 130px; } -.remote-grid.has-presentation .tile:not(.presentation-tile) span { +.remote-grid.has-presentation .tile:not(.presentation-tile) .tile-name { font-size: 12px; } @@ -446,11 +446,11 @@ button[aria-pressed="false"] { background: #171b20; } -.tile span { +.tile-name { position: absolute; left: 12px; bottom: 12px; - max-width: calc(100% - 24px); + max-width: max(90px, calc(100% - 190px)); padding: 6px 9px; border-radius: 6px; background: rgba(0, 0, 0, 0.56); @@ -461,6 +461,33 @@ button[aria-pressed="false"] { white-space: nowrap; } +.participant-audio-controls { + position: absolute; + right: 10px; + bottom: 10px; + display: grid; + gap: 4px; + width: min(150px, calc(100% - 24px)); + padding: 7px 8px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.58); +} + +.participant-audio-controls label { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + align-items: center; + gap: 7px; + color: #fff; + font-size: 11px; +} + +.participant-audio-controls input { + width: 100%; + height: 18px; + padding: 0; +} + .empty-stage { display: grid; place-items: center; diff --git a/server.js b/server.js index 5fa0319..5a72c48 100644 --- a/server.js +++ b/server.js @@ -43,6 +43,11 @@ function normalizeAnnouncement(text) { return String(text || "").trim().replace(/\s+/g, " ").slice(0, 240); } +function normalizeClientId(value) { + const normalized = String(value || "").trim(); + return /^[a-z0-9_-]{16,80}$/i.test(normalized) ? normalized : ""; +} + function normalizePlaylist(playlist) { if (!Array.isArray(playlist)) return []; @@ -132,9 +137,14 @@ const server = http.createServer(async (req, res) => { if (req.method === "GET" && url.pathname.startsWith("/events/")) { const roomId = url.pathname.split("/").at(-1) || defaultRoomId; const displayName = normalizeName(url.searchParams.get("name")); + const clientId = normalizeClientId(url.searchParams.get("clientId")); const peerId = randomUUID(); const room = getRoom(roomId); - const existingPeers = [...room].map(([id, peer]) => ({ id, name: peer.name })); + const existingPeers = [...room].map(([id, peer]) => ({ + id, + name: peer.name, + clientId: peer.clientId + })); res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", @@ -143,18 +153,19 @@ const server = http.createServer(async (req, res) => { "x-accel-buffering": "no" }); - room.set(peerId, { res, name: displayName }); + room.set(peerId, { res, name: displayName, clientId }); res.write( `data: ${JSON.stringify({ type: "welcome", peerId, name: displayName, + clientId, peers: existingPeers, watchState: watchStates.get(roomId) || null, presentationState: presentationStates.get(roomId) || null })}\n\n` ); - broadcast(roomId, peerId, { type: "peer-joined", peerId, name: displayName }); + broadcast(roomId, peerId, { type: "peer-joined", peerId, name: displayName, clientId }); const heartbeat = setInterval(() => { res.write(": heartbeat\n\n");