Add local per-participant volume
This commit is contained in:
+18
-5
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user