Add local per-participant volume

This commit is contained in:
codex
2026-06-10 22:33:09 +02:00
parent b069258405
commit 35a04db978
5 changed files with 183 additions and 22 deletions
+18 -5
View File
@@ -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))
+114 -10
View File
@@ -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.");
+7 -1
View File
@@ -123,7 +123,13 @@
<template id="remote-template">
<article class="tile">
<video autoplay playsinline></video>
<span>Participant</span>
<span class="tile-name">Participant</span>
<div class="participant-audio-controls">
<label>
<span>Volume</span>
<input class="participant-volume" type="range" min="0" max="200" step="1" />
</label>
</div>
</article>
</template>
+30 -3
View File
@@ -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;
+14 -3
View File
@@ -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");