Compare commits

...
10 Commits
Author SHA1 Message Date
codex 71cdd06ef5 Allow screen share takeover 2026-06-12 21:54:02 +02:00
codex d391e0fb3b Fix repeated screen sharing 2026-06-12 21:51:04 +02:00
codex cb736a051a Add announcement voice controls 2026-06-12 21:47:49 +02:00
codex f303711d45 Use relative browser URLs 2026-06-12 20:28:43 +02:00
codex 0ea5428240 Rename captcha environment variable 2026-06-11 21:07:17 +02:00
codex c5048560e5 Fix Elisp captcha compatibility 2026-06-11 21:06:19 +02:00
codex f80111dba4 Refine join captcha layout 2026-06-11 21:02:25 +02:00
codex bb3753e5ee Stop exposing captcha passphrase 2026-06-11 21:00:03 +02:00
codex ceb8f7edf5 Add join captcha gate 2026-06-11 20:50:14 +02:00
codex 8dc0299b75 Split activity status from session summary 2026-06-11 01:45:37 +02:00
6 changed files with 754 additions and 112 deletions
+177 -37
View File
@@ -2,6 +2,7 @@
;; Run from a shell:
;; emacs -Q --batch -l kkkmeet-server.el -f kkkmeet-server-run-batch
;; CAPTCHA=secret emacs -Q --batch -l kkkmeet-server.el -f kkkmeet-server-run-batch
;;
;; Or from Emacs:
;; M-x kkkmeet-server-start
@@ -42,6 +43,46 @@
("json" . "application/json; charset=utf-8")
("svg" . "image/svg+xml")))
(defconst kkkmeet-server--captcha-glyphs
'(("a" . ("01110" "10001" "10001" "11111" "10001" "10001" "10001"))
("b" . ("11110" "10001" "10001" "11110" "10001" "10001" "11110"))
("c" . ("01111" "10000" "10000" "10000" "10000" "10000" "01111"))
("d" . ("11110" "10001" "10001" "10001" "10001" "10001" "11110"))
("e" . ("11111" "10000" "10000" "11110" "10000" "10000" "11111"))
("f" . ("11111" "10000" "10000" "11110" "10000" "10000" "10000"))
("g" . ("01111" "10000" "10000" "10111" "10001" "10001" "01111"))
("h" . ("10001" "10001" "10001" "11111" "10001" "10001" "10001"))
("i" . ("11111" "00100" "00100" "00100" "00100" "00100" "11111"))
("j" . ("00111" "00010" "00010" "00010" "10010" "10010" "01100"))
("k" . ("10001" "10010" "10100" "11000" "10100" "10010" "10001"))
("l" . ("10000" "10000" "10000" "10000" "10000" "10000" "11111"))
("m" . ("10001" "11011" "10101" "10101" "10001" "10001" "10001"))
("n" . ("10001" "11001" "10101" "10011" "10001" "10001" "10001"))
("o" . ("01110" "10001" "10001" "10001" "10001" "10001" "01110"))
("p" . ("11110" "10001" "10001" "11110" "10000" "10000" "10000"))
("q" . ("01110" "10001" "10001" "10001" "10101" "10010" "01101"))
("r" . ("11110" "10001" "10001" "11110" "10100" "10010" "10001"))
("s" . ("01111" "10000" "10000" "01110" "00001" "00001" "11110"))
("t" . ("11111" "00100" "00100" "00100" "00100" "00100" "00100"))
("u" . ("10001" "10001" "10001" "10001" "10001" "10001" "01110"))
("v" . ("10001" "10001" "10001" "10001" "10001" "01010" "00100"))
("w" . ("10001" "10001" "10001" "10101" "10101" "10101" "01010"))
("x" . ("10001" "10001" "01010" "00100" "01010" "10001" "10001"))
("y" . ("10001" "10001" "01010" "00100" "00100" "00100" "00100"))
("z" . ("11111" "00001" "00010" "00100" "01000" "10000" "11111"))
("0" . ("01110" "10001" "10011" "10101" "11001" "10001" "01110"))
("1" . ("00100" "01100" "00100" "00100" "00100" "00100" "01110"))
("2" . ("01110" "10001" "00001" "00010" "00100" "01000" "11111"))
("3" . ("11110" "00001" "00001" "01110" "00001" "00001" "11110"))
("4" . ("10010" "10010" "10010" "11111" "00010" "00010" "00010"))
("5" . ("11111" "10000" "10000" "11110" "00001" "00001" "11110"))
("6" . ("01111" "10000" "10000" "11110" "10001" "10001" "01110"))
("7" . ("11111" "00001" "00010" "00100" "01000" "01000" "01000"))
("8" . ("01110" "10001" "10001" "01110" "10001" "10001" "01110"))
("9" . ("01110" "10001" "10001" "01111" "00001" "00001" "11110"))
("-" . ("00000" "00000" "00000" "11111" "00000" "00000" "00000"))
("_" . ("00000" "00000" "00000" "00000" "00000" "00000" "11111"))))
(defun kkkmeet-server--uuid ()
"Return a UUID-like random peer id."
(let ((hex (md5 (format "%s-%s-%s" (current-time) (random) (emacs-pid)))))
@@ -81,6 +122,19 @@
(let ((normalized (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " (or text "")))))
(substring normalized 0 (min 240 (length normalized)))))
(defun kkkmeet-server--clamp-number (value fallback min max)
"Return VALUE clamped between MIN and MAX, or FALLBACK."
(let ((number (if (numberp value) value fallback)))
(min max (max min number))))
(defun kkkmeet-server--normalize-voice-settings (settings)
"Return sanitized voice SETTINGS."
(let ((safe-settings (if (listp settings) settings nil)))
`((amplitude . ,(kkkmeet-server--clamp-number (alist-get 'amplitude safe-settings) 100 0 200))
(pitch . ,(kkkmeet-server--clamp-number (alist-get 'pitch safe-settings) 32 0 99))
(speed . ,(kkkmeet-server--clamp-number (alist-get 'speed safe-settings) 135 80 240))
(wordgap . ,(kkkmeet-server--clamp-number (alist-get 'wordgap safe-settings) 4 0 20)))))
(defun kkkmeet-server--normalize-client-id (value)
"Return a safe persistent client id from VALUE."
(let ((normalized (string-trim (or value ""))))
@@ -88,6 +142,52 @@
normalized
"")))
(defun kkkmeet-server--captcha-passphrase ()
"Return the configured join captcha passphrase."
(or (getenv "CAPTCHA") "kkkmeet"))
(defun kkkmeet-server--captcha-accepted-p (value)
"Return non-nil when VALUE matches the configured captcha passphrase."
(equal (downcase (or value "")) (downcase (kkkmeet-server--captcha-passphrase))))
(defun kkkmeet-server--captcha-svg ()
"Return an SVG captcha image for the configured passphrase."
(let* ((cell 4)
(gap 4)
(padding 10)
(passphrase (downcase (kkkmeet-server--captcha-passphrase)))
(chars (append passphrase nil))
(width (max 160 (- (+ (* 2 padding)
(* (length chars) (+ (* 5 cell) gap)))
gap)))
(height 42)
rects)
(cl-loop for char in chars
for index from 0
for key = (char-to-string char)
for glyph = (or (cdr (assoc key kkkmeet-server--captcha-glyphs))
(cdr (assoc "_" kkkmeet-server--captcha-glyphs)))
do (cl-loop for row in glyph
for y from 0
do (cl-loop for pixel across row
for x from 0
when (eq pixel ?1)
do (push
(format
"<rect x=\"%s\" y=\"%s\" width=\"%s\" height=\"%s\" rx=\"1\"/>"
(+ padding (* index (+ (* 5 cell) gap)) (* x cell))
(+ 7 (* y cell))
(1- cell)
(1- cell))
rects))))
(format "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%s\" height=\"%s\" viewBox=\"0 0 %s %s\">
<rect width=\"100%%\" height=\"100%%\" fill=\"#eef2f6\"/>
<path d=\"M0 34 C35 2 78 44 %s 12\" stroke=\"rgba(24,32,42,0.22)\" stroke-width=\"1.5\" fill=\"none\"/>
<g fill=\"#18202a\" transform=\"skewX(-4)\">%s</g>
</svg>"
width height width height width (string-join (nreverse rects) ""))))
(defun kkkmeet-server--nonnegative-number (value)
"Return VALUE as a nonnegative number, or zero."
(max 0 (if (numberp value) value 0)))
@@ -180,19 +280,21 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(defun kkkmeet-server--remove-peer (process)
"Remove PROCESS from its room and notify remaining peers."
(let ((room-id (process-get process :kkkmeet-room-id))
(peer-id (process-get process :kkkmeet-peer-id)))
(peer-id (process-get process :kkkmeet-peer-id))
(name (or (process-get process :kkkmeet-name) "A participant")))
(when (and room-id peer-id)
(let ((room (gethash room-id kkkmeet-server--rooms)))
(when room
(remhash peer-id room)
(kkkmeet-server--broadcast
room-id peer-id `((type . "peer-left") (peerId . ,peer-id)))
room-id peer-id `((type . "peer-left") (peerId . ,peer-id) (name . ,name)))
(when (equal (alist-get 'peerId (gethash room-id kkkmeet-server--presentation-states))
peer-id)
(remhash room-id kkkmeet-server--presentation-states)
(kkkmeet-server--broadcast
room-id peer-id `((type . "presentation-state")
(from . ,peer-id)
(name . ,name)
(sharing . :false))))
(when (= (hash-table-count room) 0)
(remhash room-id kkkmeet-server--rooms)
@@ -265,40 +367,56 @@ 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 client-id)
(defun kkkmeet-server--handle-captcha (process method)
"Send the captcha image to PROCESS."
(kkkmeet-server--send-response
process 200
'(("Content-Type" . "image/svg+xml; charset=utf-8")
("Cache-Control" . "no-store"))
(unless (equal method "HEAD")
(kkkmeet-server--captcha-svg))))
(defun kkkmeet-server--handle-captcha-verify (process answer)
"Send captcha verification result for ANSWER to PROCESS."
(kkkmeet-server--send-json
process 200 `((ok . ,(if (kkkmeet-server--captcha-accepted-p answer) t :false)))))
(defun kkkmeet-server--handle-events (process room-id display-name client-id captcha)
"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-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
'(("Content-Type" . "text/event-stream; charset=utf-8")
("Cache-Control" . "no-store, no-transform")
("X-Accel-Buffering" . "no"))
nil t)
(kkkmeet-server--send-sse
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))
(presentationState . ,(or (gethash safe-room-id kkkmeet-server--presentation-states)
:null))))
(kkkmeet-server--broadcast
safe-room-id peer-id `((type . "peer-joined")
(peerId . ,peer-id)
(name . ,safe-name)
(clientId . ,safe-client-id)))))
(if (not (kkkmeet-server--captcha-accepted-p captcha))
(kkkmeet-server--send-json process 403 '((error . "Captcha passphrase is required.")))
(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-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
'(("Content-Type" . "text/event-stream; charset=utf-8")
("Cache-Control" . "no-store, no-transform")
("X-Accel-Buffering" . "no"))
nil t)
(kkkmeet-server--send-sse
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)
nil))
(presentationState . ,(or (gethash safe-room-id kkkmeet-server--presentation-states)
nil))))
(kkkmeet-server--broadcast
safe-room-id peer-id `((type . "peer-joined")
(peerId . ,peer-id)
(name . ,safe-name)
(clientId . ,safe-client-id))))))
(defun kkkmeet-server--handle-signal (process room-id body)
"Handle signaling BODY from PROCESS for ROOM-ID."
@@ -382,7 +500,21 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(sharing . ,(if (alist-get 'sharing payload) t :false))
(updatedAt . ,(floor (* 1000 (float-time)))))))
(if (alist-get 'sharing payload)
(puthash safe-room-id event kkkmeet-server--presentation-states)
(let ((previous (gethash safe-room-id kkkmeet-server--presentation-states)))
(when (and previous
(alist-get 'peerId previous)
(not (equal (alist-get 'peerId previous) from)))
(kkkmeet-server--broadcast
safe-room-id from `((type . "presentation-state")
(from . ,(alist-get 'peerId previous))
(peerId . ,(alist-get 'peerId previous))
(name . ,(alist-get 'name previous))
(sharing . :false)
(reason . "takeover")
(replacedBy . ,from)
(replacedByName . ,(alist-get 'name event))
(updatedAt . ,(floor (* 1000 (float-time)))))))
(puthash safe-room-id event kkkmeet-server--presentation-states))
(when (equal (alist-get 'peerId (gethash safe-room-id kkkmeet-server--presentation-states))
from)
(remhash safe-room-id kkkmeet-server--presentation-states)))
@@ -399,6 +531,8 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(name . ,(kkkmeet-server--normalize-name
(alist-get 'name payload)))
(text . ,text)
(voice . ,(kkkmeet-server--normalize-voice-settings
(alist-get 'voice payload)))
(updatedAt . ,(floor (* 1000 (float-time)))))))
(kkkmeet-server--broadcast safe-room-id from event)
(kkkmeet-server--send-json process 202 '((ok . t)))))))
@@ -415,12 +549,18 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(path (alist-get 'path request))
(body (alist-get 'body request)))
(cond
((and (member method '("GET" "HEAD")) (equal path "/captcha.svg"))
(kkkmeet-server--handle-captcha process method))
((and (equal method "GET") (equal path "/captcha/verify"))
(kkkmeet-server--handle-captcha-verify
process (kkkmeet-server--query-param target "answer")))
((and (equal method "GET") (string-prefix-p "/events/" path))
(kkkmeet-server--handle-events
process
(url-unhex-string (string-remove-prefix "/events/" path))
(kkkmeet-server--query-param target "name")
(kkkmeet-server--query-param target "clientId")))
(kkkmeet-server--query-param target "clientId")
(kkkmeet-server--query-param target "captcha")))
((and (equal method "POST") (string-prefix-p "/signal/" path))
(kkkmeet-server--handle-signal
process (url-unhex-string (string-remove-prefix "/signal/" path)) body))
+331 -68
View File
@@ -9,7 +9,11 @@ const nameDialogTitle = document.querySelector("#name-dialog-title");
const nameDialogSubmit = document.querySelector("#name-dialog-submit");
const initialNameForm = document.querySelector("#initial-name-form");
const initialNameInput = document.querySelector("#initial-name-input");
const captchaControl = document.querySelector("#captcha-control");
const captchaCanvas = document.querySelector("#captcha-canvas");
const captchaInput = document.querySelector("#captcha-input");
const statusText = document.querySelector("#status");
const activityStatusText = document.querySelector("#activity-status");
const youtubeForm = document.querySelector("#youtube-form");
const youtubeInput = document.querySelector("#youtube-input");
const youtubeShell = document.querySelector("#youtube-shell");
@@ -32,14 +36,20 @@ const announcementDialog = document.querySelector("#announcement-dialog");
const announcementForm = document.querySelector("#announcement-form");
const announcementInput = document.querySelector("#announcement-input");
const announcementCancelButton = document.querySelector("#announcement-cancel-button");
const announcementAmplitudeInput = document.querySelector("#announcement-amplitude");
const announcementPitchInput = document.querySelector("#announcement-pitch");
const announcementSpeedInput = document.querySelector("#announcement-speed");
const announcementWordgapInput = document.querySelector("#announcement-wordgap");
const roomId = "main";
const nameStorageKey = "kkkmeet.displayName";
const clientIdStorageKey = "kkkmeet.clientId";
const captchaAnswerStorageKey = "kkkmeet.captchaAnswer";
const cameraStorageKey = "kkkmeet.cameraDeviceId";
const micStorageKey = "kkkmeet.micDeviceId";
const micVolumeStorageKey = "kkkmeet.selfMicVolume";
const noiseSuppressionStorageKey = "kkkmeet.noiseSuppression";
const announcementVoiceStorageKey = "kkkmeet.announcementVoice";
const watchHeightStorageKey = "kkkmeet.watchPanelHeight";
const watchCollapsedStorageKey = "kkkmeet.watchPanelCollapsed";
const rtcConfig = {
@@ -57,6 +67,7 @@ let localMicDestination;
let micMeterAnimationFrame = 0;
let micMeterLevel = 0;
let screenStream;
let stoppingScreenShare = false;
let localPresentationTile;
let events;
let audioContext;
@@ -73,6 +84,7 @@ let youtubeReadyResolvers = [];
let watchApplySequence = 0;
let youtubePlaybackRequestId = 0;
let watchPlaylist = [];
let remoteParticipantCount = 0;
const peers = new Map();
function persistentClientId() {
@@ -133,6 +145,26 @@ function setStatus(message) {
statusText.textContent = message;
}
function participantDisplayName(name, fallback = "Someone") {
return normalizeName(String(name || "")) || fallback;
}
function setSessionSummary() {
if (!peerId) {
setStatus(displayName ? `Ready as ${displayName}.` : "Enter your name to join the call.");
return;
}
const suffix = remoteParticipantCount === 1 ? "participant" : "participants";
setStatus(`You are ${displayName}. ${remoteParticipantCount} other ${suffix}.`);
}
function setActivity(name, action) {
const actor = participantDisplayName(name);
const message = `${actor} ${action}`;
activityStatusText.textContent = message;
console.info(`[kkkmeet activity] ${message}`);
}
function normalizeName(name) {
return name.trim().replace(/\s+/g, " ").slice(0, 48);
}
@@ -141,16 +173,64 @@ function normalizeAnnouncement(text) {
return String(text || "").trim().replace(/\s+/g, " ").slice(0, 240);
}
function speakText(text) {
function voiceSetting(value, fallback, min, max) {
const parsed = Number(value);
return Number.isFinite(parsed) ? clamp(parsed, min, max) : fallback;
}
function defaultVoiceSettings() {
return {
amplitude: 100,
pitch: 32,
speed: 135,
wordgap: 4
};
}
function normalizeVoiceSettings(settings = {}) {
const defaults = defaultVoiceSettings();
return {
amplitude: voiceSetting(settings.amplitude, defaults.amplitude, 0, 200),
pitch: voiceSetting(settings.pitch, defaults.pitch, 0, 99),
speed: voiceSetting(settings.speed, defaults.speed, 80, 240),
wordgap: voiceSetting(settings.wordgap, defaults.wordgap, 0, 20)
};
}
function currentAnnouncementVoiceSettings() {
return normalizeVoiceSettings({
amplitude: announcementAmplitudeInput.value,
pitch: announcementPitchInput.value,
speed: announcementSpeedInput.value,
wordgap: announcementWordgapInput.value
});
}
function saveAnnouncementVoiceSettings() {
localStorage.setItem(announcementVoiceStorageKey, JSON.stringify(currentAnnouncementVoiceSettings()));
}
function applyAnnouncementVoiceSettings(settings) {
const voice = normalizeVoiceSettings(settings);
announcementAmplitudeInput.value = String(voice.amplitude);
announcementPitchInput.value = String(voice.pitch);
announcementSpeedInput.value = String(voice.speed);
announcementWordgapInput.value = String(voice.wordgap);
}
function savedAnnouncementVoiceSettings() {
try {
return normalizeVoiceSettings(JSON.parse(localStorage.getItem(announcementVoiceStorageKey) || "null") || {});
} catch {
return defaultVoiceSettings();
}
}
function speakText(text, voiceSettings = {}) {
if (typeof window.speak !== "function") return;
try {
window.speak(text, {
amplitude: 100,
pitch: 32,
speed: 135,
wordgap: 4
});
window.speak(text, normalizeVoiceSettings(voiceSettings));
} catch (error) {
console.warn("Could not play voice announcement.", error);
}
@@ -158,13 +238,13 @@ function speakText(text) {
function announceParticipantJoined(name) {
const participantName = normalizeName(name || "A participant") || "A participant";
speakText(`${participantName} has joined.`);
speakText(`${participantName} has joined.`, defaultVoiceSettings());
}
function speakAnnouncement(message) {
const text = normalizeAnnouncement(message?.text || "");
if (!text) return;
speakText(text);
speakText(text, message?.voice);
}
function openAnnouncementDialog() {
@@ -177,19 +257,62 @@ function setDisplayName(nextName) {
displayName = normalizeName(nextName);
localStorage.setItem(nameStorageKey, displayName);
localNameLabel.textContent = displayName ? `${displayName} (you)` : "You";
setSessionSummary();
}
function openNameDialog(mode) {
const renaming = mode === "rename";
nameDialogTitle.textContent = renaming ? "Rename yourself" : "Choose your name";
nameDialogSubmit.textContent = renaming ? "Save name" : "Join call";
initialNameInput.value = renaming ? displayName : "";
initialNameInput.value = renaming ? displayName : displayName || "";
captchaControl.hidden = renaming;
captchaInput.required = !renaming;
if (!renaming) {
captchaInput.value = localStorage.getItem(captchaAnswerStorageKey) || "";
drawCaptchaPassphrase().catch((error) => {
console.error(error);
setStatus("Could not load captcha image.");
});
}
nameDialog.dataset.mode = mode;
nameDialog.showModal();
initialNameInput.focus();
initialNameInput.select();
}
async function drawCaptchaPassphrase() {
const context = captchaCanvas.getContext("2d");
if (!context) return;
const width = captchaCanvas.width;
const height = captchaCanvas.height;
context.clearRect(0, 0, width, height);
context.fillStyle = "#eef2f6";
context.fillRect(0, 0, width, height);
const image = new Image();
image.decoding = "async";
image.src = `captcha.svg?${Date.now()}`;
await image.decode();
context.clearRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
}
function captchaAnswer() {
return String(captchaInput.value || localStorage.getItem(captchaAnswerStorageKey) || "").trim();
}
async function captchaIsCorrect() {
const answer = captchaAnswer();
if (!answer) return false;
const params = new URLSearchParams({ answer });
const response = await fetch(`captcha/verify?${params}`, { cache: "no-store" });
if (!response.ok) return false;
const result = await response.json();
return Boolean(result.ok);
}
function labelForPeer(peer) {
return peer?.name || `Participant ${peer?.id?.slice(0, 4) || ""}`.trim();
}
@@ -513,7 +636,7 @@ async function sendWatchState({
const watchAction = action === "play" ? "play" : "pause";
const playlist = playlistWithActivePosition(position);
await fetch(`/watch/${encodeURIComponent(roomId)}`, {
await fetch(`watch/${encodeURIComponent(roomId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
@@ -528,6 +651,7 @@ async function sendWatchState({
updatedAt: Date.now()
})
});
setActivity(displayName, "synced YouTube playback.");
}
function currentWatchState(playing) {
@@ -793,7 +917,9 @@ async function refreshDeviceLists() {
}
function savedSelfMicVolume() {
const saved = Number(localStorage.getItem(micVolumeStorageKey));
const stored = localStorage.getItem(micVolumeStorageKey);
if (stored === null) return 100;
const saved = Number(stored);
return Number.isFinite(saved) ? clamp(saved, 0, 200) : 100;
}
@@ -1048,7 +1174,7 @@ function removeRemoteTile(id) {
}
async function sendSignal(to, payload) {
await fetch(`/signal/${encodeURIComponent(roomId)}`, {
await fetch(`signal/${encodeURIComponent(roomId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...payload, from: peerId, to, name: displayName, clientId })
@@ -1058,7 +1184,7 @@ async function sendSignal(to, payload) {
async function sendRoomEvent(payload) {
if (!peerId) return;
await fetch(`/room-event/${encodeURIComponent(roomId)}`, {
await fetch(`room-event/${encodeURIComponent(roomId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...payload, from: peerId, name: displayName, clientId })
@@ -1150,7 +1276,9 @@ async function answerPeer(message) {
async function handleSignal(message) {
if (message.type === "welcome") {
peerId = message.peerId;
setStatus(`Joined the call as ${displayName}. ${message.peers.length} other participant(s).`);
remoteParticipantCount = message.peers.length;
setSessionSummary();
setActivity(displayName, "joined.");
if (message.watchState) {
const watchShouldPlay = message.watchState.action === "play" ||
(!message.watchState.action && Boolean(message.watchState.playing));
@@ -1175,13 +1303,20 @@ async function handleSignal(message) {
if (message.type === "peer-joined") {
updateRemoteLabel(message.peerId, message.name);
announceParticipantJoined(message.name);
setStatus(`${message.name || "A participant"} joined.`);
remoteParticipantCount += 1;
setSessionSummary();
setActivity(message.name, "joined.");
return;
}
if (message.type === "peer-left") {
const peerName = message.name ||
peers.get(message.peerId)?.name ||
`Participant ${message.peerId?.slice(0, 4) || ""}`.trim();
closePeer(message.peerId);
setStatus("A participant left.");
remoteParticipantCount = Math.max(0, remoteParticipantCount - 1);
setSessionSummary();
setActivity(peerName, "left.");
return;
}
@@ -1197,21 +1332,32 @@ async function handleSignal(message) {
allowJoinedYoutubeStartPrompt = false;
}
await applyWatchState(message, { allowStartPrompt });
setStatus(`${message.name || "Someone"} synced YouTube playback.`);
setActivity(message.name, "synced YouTube playback.");
return;
}
if (message.type === "presentation-state") {
if (
message.reason === "takeover" &&
message.from === peerId &&
!message.sharing &&
screenStream
) {
await stopScreenShare({ announce: false });
}
setRemotePresentation(message.from, message.sharing, message.name);
setStatus(message.sharing
? `${message.name || "Someone"} is sharing their screen.`
: `${message.name || "Someone"} stopped sharing their screen.`);
setActivity(
message.name,
message.reason === "takeover"
? `was replaced by ${participantDisplayName(message.replacedByName)} as presenter.`
: message.sharing ? "started sharing their screen." : "stopped sharing their screen."
);
return;
}
if (message.type === "voice-announcement") {
speakAnnouncement(message);
setStatus(`${message.name || "Someone"} sent an announcement.`);
setActivity(message.name, "sent an announcement.");
return;
}
@@ -1245,14 +1391,20 @@ async function joinCall() {
openNameDialog("join");
return;
}
if (!(await captchaIsCorrect())) {
setStatus("Enter the captcha passphrase to join the call.");
openNameDialog("join");
captchaInput.focus();
return;
}
leaveCall();
await ensureLocalStream();
const params = new URLSearchParams({ name: displayName, clientId });
events = new EventSource(`/events/${encodeURIComponent(roomId)}?${params}`);
const params = new URLSearchParams({ name: displayName, clientId, captcha: captchaAnswer() });
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.");
events.onerror = () => setStatus("Connection to signaling server was interrupted or captcha was denied.");
setStatus(`Joining as ${displayName}...`);
}
@@ -1260,8 +1412,9 @@ function leaveCall() {
events?.close();
events = undefined;
peerId = "";
remoteParticipantCount = 0;
for (const remotePeerId of peers.keys()) closePeer(remotePeerId);
setStatus(displayName ? `Ready as ${displayName}.` : "Enter your name to join the call.");
setSessionSummary();
}
function setTrackEnabled(kind, enabled) {
@@ -1354,46 +1507,129 @@ async function sendVoiceAnnouncement(text) {
const announcementText = normalizeAnnouncement(text);
if (!announcementText) return;
await sendRoomEvent({ type: "voice-announcement", text: announcementText });
speakAnnouncement({ text: announcementText });
setStatus("Announcement sent.");
const voice = currentAnnouncementVoiceSettings();
saveAnnouncementVoiceSettings();
await sendRoomEvent({ type: "voice-announcement", text: announcementText, voice });
speakAnnouncement({ text: announcementText, voice });
setActivity(displayName, "sent an announcement.");
}
async function constrainScreenTrack(track) {
if (!track?.applyConstraints) return;
try {
await track.applyConstraints({
width: { max: 1920 },
height: { max: 1080 },
frameRate: { max: 30 }
});
} catch (error) {
console.warn("Could not constrain screen share resolution.", error);
}
}
async function getScreenShareStream() {
const constraints = {
video: {
width: { max: 1920 },
height: { max: 1080 },
frameRate: { max: 30 }
},
audio: false
};
try {
return await navigator.mediaDevices.getDisplayMedia(constraints);
} catch (error) {
if (!["OverconstrainedError", "TypeError", "ConstraintNotSatisfiedError"].includes(error.name)) {
throw error;
}
console.warn("Screen share constraints were rejected, falling back to default capture.", error);
return navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
}
}
async function startScreenShare() {
if (!localStream || screenStream) return;
if (!localStream || screenStream || stoppingScreenShare) return;
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const screenTrack = screenStream.getVideoTracks()[0];
createLocalPresentationTile(screenStream);
await replaceOutboundVideoTrack(screenTrack);
shareButton.textContent = "Stop Sharing";
await sendRoomEvent({ type: "presentation-state", sharing: true });
const nextScreenStream = await getScreenShareStream();
screenStream = nextScreenStream;
const screenTrack = nextScreenStream.getVideoTracks()[0];
screenTrack.onended = () => {
stopScreenShare().catch((error) => {
console.error(error);
setStatus("Could not stop screen sharing cleanly.");
try {
await constrainScreenTrack(screenTrack);
screenTrack.onended = () => {
if (screenStream?.getVideoTracks()[0] !== screenTrack) return;
stopScreenShare().catch((error) => {
console.error(error);
setStatus("Could not stop screen sharing cleanly.");
});
};
createLocalPresentationTile(nextScreenStream);
await replaceOutboundVideoTrack(screenTrack);
shareButton.textContent = "Stop Sharing";
await sendRoomEvent({ type: "presentation-state", sharing: true });
setActivity(displayName, "started sharing their screen.");
} catch (error) {
if (screenStream === nextScreenStream) {
screenStream = undefined;
}
nextScreenStream.getTracks().forEach((track) => {
track.onended = null;
if (track.readyState !== "ended") track.stop();
});
};
}
async function stopScreenShare() {
if (!screenStream) return;
const cameraTrack = localStream?.getVideoTracks()[0];
screenStream.getTracks().forEach((track) => track.stop());
screenStream = undefined;
removeLocalPresentationTile();
if (cameraTrack) {
await replaceOutboundVideoTrack(cameraTrack);
removeLocalPresentationTile();
shareButton.textContent = "Share Screen";
throw error;
}
shareButton.textContent = "Share Screen";
await sendRoomEvent({ type: "presentation-state", sharing: false });
}
initialNameForm.addEventListener("submit", (event) => {
async function stopScreenShare({ announce = true } = {}) {
if (!screenStream || stoppingScreenShare) return;
stoppingScreenShare = true;
const streamToStop = screenStream;
const cameraTrack = localStream?.getVideoTracks()[0];
screenStream = undefined;
try {
removeLocalPresentationTile();
if (cameraTrack) {
await replaceOutboundVideoTrack(cameraTrack);
}
shareButton.textContent = "Share Screen";
if (announce) {
await sendRoomEvent({ type: "presentation-state", sharing: false });
setActivity(displayName, "stopped sharing their screen.");
}
} finally {
streamToStop.getTracks().forEach((track) => {
track.onended = null;
if (track.readyState !== "ended") track.stop();
});
stoppingScreenShare = false;
}
}
initialNameForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (nameDialog.dataset.mode !== "rename" && !(await captchaIsCorrect())) {
localStorage.removeItem(captchaAnswerStorageKey);
captchaInput.value = "";
drawCaptchaPassphrase().catch((error) => {
console.error(error);
setStatus("Could not load captcha image.");
});
setStatus("Captcha passphrase did not match.");
captchaInput.focus();
return;
}
setDisplayName(initialNameInput.value);
if (nameDialog.dataset.mode !== "rename") {
localStorage.setItem(captchaAnswerStorageKey, captchaAnswer());
}
nameDialog.close();
if (events) {
joinCall().catch((error) => {
@@ -1479,6 +1715,15 @@ noiseSuppressionInput.addEventListener("change", () => {
});
});
[
announcementAmplitudeInput,
announcementPitchInput,
announcementSpeedInput,
announcementWordgapInput
].forEach((input) => {
input.addEventListener("input", saveAnnouncementVoiceSettings);
});
cameraSelect.addEventListener("change", () => {
replaceLocalTrack("video", cameraSelect.value).catch((error) => {
console.error(error);
@@ -1530,30 +1775,48 @@ clearPlaylistButton.addEventListener("click", () => {
shareButton.addEventListener("click", async () => {
if (!localStream) return;
if (screenStream) {
await stopScreenShare();
} else {
await startScreenShare();
try {
if (screenStream) {
await stopScreenShare();
} else {
await startScreenShare();
}
} catch (error) {
console.error(error);
setStatus("Could not update screen sharing.");
}
});
selfMicVolumeInput.value = String(savedSelfMicVolume());
selfMicMeter.value = 0;
noiseSuppressionInput.checked = savedNoiseSuppression();
applyAnnouncementVoiceSettings(savedAnnouncementVoiceSettings());
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 {
setStatus("Choose your name to join the call.");
async function initializeApp() {
const savedName = localStorage.getItem(nameStorageKey);
if (savedName) {
setDisplayName(savedName);
if (await captchaIsCorrect()) {
joinCall().catch((error) => {
console.error(error);
setStatus("Could not start camera or join the call.");
});
return;
}
}
setStatus(savedName
? "Enter the captcha passphrase to join the call."
: "Choose your name to join the call.");
openNameDialog("join");
refreshDeviceLists().catch(console.error);
}
initializeApp().catch((error) => {
console.error(error);
setStatus("Could not initialize the application.");
});
navigator.mediaDevices?.addEventListener?.("devicechange", () => {
refreshDeviceLists().catch(console.error);
});
+34 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>kkkmeet</title>
<link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="app-shell">
@@ -12,6 +12,7 @@
<div>
<h1>kkkmeet</h1>
<p id="status">Enter your name to join the call.</p>
<p id="activity-status" aria-live="polite"></p>
</div>
<button id="rename-button" type="button">Rename</button>
</header>
@@ -156,6 +157,18 @@
placeholder="Your name"
required
/>
<div id="captcha-control" class="captcha-control">
<span class="captcha-label">Prove that you are not a bot</span>
<div class="captcha-row">
<canvas id="captcha-canvas" width="160" height="42" aria-label="Captcha passphrase"></canvas>
<input
id="captcha-input"
autocomplete="off"
placeholder="Type captcha text here"
required
/>
</div>
</div>
<button id="name-dialog-submit" type="submit">Join call</button>
</form>
</dialog>
@@ -169,6 +182,24 @@
placeholder="Announcement text"
required
></textarea>
<div class="voice-controls" aria-label="Announcement voice settings">
<label>
<span>Volume</span>
<input id="announcement-amplitude" type="range" min="0" max="200" step="1" value="100" />
</label>
<label>
<span>Pitch</span>
<input id="announcement-pitch" type="range" min="0" max="99" step="1" value="32" />
</label>
<label>
<span>Speed</span>
<input id="announcement-speed" type="range" min="80" max="240" step="1" value="135" />
</label>
<label>
<span>Word gap</span>
<input id="announcement-wordgap" type="range" min="0" max="20" step="1" value="4" />
</label>
</div>
<div class="dialog-actions">
<button id="announcement-cancel-button" type="button">Cancel</button>
<button type="submit">Send</button>
@@ -177,7 +208,7 @@
</dialog>
<div id="audio" hidden></div>
<script src="/vendor/speakjs/speakClient.js"></script>
<script type="module" src="/app.js"></script>
<script src="vendor/speakjs/speakClient.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>
+79 -1
View File
@@ -62,12 +62,19 @@ h1 {
font-size: 14px;
}
#activity-status {
min-height: 18px;
margin-top: 3px;
color: #d5dbe3;
font-size: 13px;
}
#rename-button {
min-width: 92px;
}
.name-dialog {
width: min(420px, calc(100vw - 32px));
width: min(560px, calc(100vw - 32px));
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
background: #171b20;
@@ -97,6 +104,39 @@ h1 {
width: 100%;
}
.captcha-control {
display: grid;
gap: 7px;
}
.captcha-control[hidden] {
display: none;
}
.captcha-label {
color: #d5dbe3;
font-size: 13px;
font-weight: 650;
}
.captcha-row {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 8px;
align-items: stretch;
}
.captcha-control canvas {
width: 160px;
height: 42px;
border-radius: 8px;
background: #f2f4f7;
}
.captcha-control input {
width: 100%;
}
.initial-name-form textarea {
min-height: 110px;
resize: vertical;
@@ -108,6 +148,40 @@ h1 {
gap: 10px;
}
.voice-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.voice-controls label {
display: grid;
gap: 5px;
color: #aeb7c3;
font-size: 12px;
font-weight: 650;
}
.voice-controls input {
width: 100%;
height: 18px;
padding: 0;
}
@media (max-width: 620px) {
.captcha-row {
grid-template-columns: 1fr;
}
.captcha-control canvas {
width: 100%;
}
.voice-controls {
grid-template-columns: 1fr;
}
}
input,
select,
textarea {
@@ -446,6 +520,10 @@ button[aria-pressed="false"] {
background: #171b20;
}
.presentation-tile video {
object-fit: contain;
}
.tile-name {
position: absolute;
left: 12px;
+5 -1
View File
@@ -1,6 +1,10 @@
var speakWorker;
try {
speakWorker = new Worker('/vendor/speakjs/speakWorker.js');
var speakClientScript = document.currentScript && document.currentScript.src;
var speakWorkerUrl = speakClientScript
? new URL('speakWorker.js', speakClientScript).toString()
: 'vendor/speakjs/speakWorker.js';
speakWorker = new Worker(speakWorkerUrl);
} catch(e) {
console.log('speak.js warning: no worker support');
}
+128 -2
View File
@@ -5,11 +5,52 @@ import { randomUUID } from "node:crypto";
const port = Number(process.env.PORT || 3000);
const host = process.env.HOST || "127.0.0.1";
const captchaPassphrase = process.env.CAPTCHA || "kkkmeet";
const publicDir = join(process.cwd(), "public");
const rooms = new Map();
const watchStates = new Map();
const presentationStates = new Map();
const defaultRoomId = "main";
const captchaGlyphs = {
a: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
b: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
c: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
d: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
e: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
f: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
g: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"],
h: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
i: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
j: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"],
k: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
l: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
m: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
n: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
o: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
p: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
r: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
s: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
t: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
u: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
v: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
w: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
x: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
0: ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
1: ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
2: ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
3: ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
4: ["10010", "10010", "10010", "11111", "00010", "00010", "00010"],
5: ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
6: ["01111", "10000", "10000", "11110", "10001", "10001", "01110"],
7: ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
8: ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
9: ["01110", "10001", "10001", "01111", "00001", "00001", "11110"],
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
_: ["00000", "00000", "00000", "00000", "00000", "00000", "11111"]
};
const mimeTypes = {
".html": "text/html; charset=utf-8",
@@ -43,11 +84,60 @@ function normalizeAnnouncement(text) {
return String(text || "").trim().replace(/\s+/g, " ").slice(0, 240);
}
function clampNumber(value, fallback, min, max) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
}
function normalizeVoiceSettings(settings = {}) {
const source = settings && typeof settings === "object" ? settings : {};
return {
amplitude: clampNumber(source.amplitude, 100, 0, 200),
pitch: clampNumber(source.pitch, 32, 0, 99),
speed: clampNumber(source.speed, 135, 80, 240),
wordgap: clampNumber(source.wordgap, 4, 0, 20)
};
}
function normalizeClientId(value) {
const normalized = String(value || "").trim();
return /^[a-z0-9_-]{16,80}$/i.test(normalized) ? normalized : "";
}
function captchaAccepted(value) {
return String(value || "").toLowerCase() === captchaPassphrase.toLowerCase();
}
function captchaSvg() {
const cell = 4;
const gap = 4;
const padding = 10;
const chars = [...captchaPassphrase.toLowerCase()];
const width = Math.max(160, padding * 2 + chars.length * (5 * cell + gap) - gap);
const height = 42;
const rects = [];
chars.forEach((char, index) => {
const glyph = captchaGlyphs[char] || captchaGlyphs._;
const xOffset = padding + index * (5 * cell + gap);
glyph.forEach((row, y) => {
[...row].forEach((pixel, x) => {
if (pixel === "1") {
rects.push(`<rect x="${xOffset + x * cell}" y="${7 + y * cell}" width="${cell - 1}" height="${cell - 1}" rx="1"/>`);
}
});
});
});
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="#eef2f6"/>
<path d="M0 34 C35 2 78 44 ${width} 12" stroke="rgba(24,32,42,0.22)" stroke-width="1.5" fill="none"/>
<g fill="#18202a" transform="skewX(-4)">${rects.join("")}</g>
</svg>`;
}
function normalizePlaylist(playlist) {
if (!Array.isArray(playlist)) return [];
@@ -84,11 +174,13 @@ function removePeer(roomId, peerId) {
const room = rooms.get(roomId);
if (!room) return;
const peer = room.get(peerId);
const name = peer?.name || "A participant";
room.delete(peerId);
broadcast(roomId, peerId, { type: "peer-left", peerId });
broadcast(roomId, peerId, { type: "peer-left", peerId, name });
if (presentationStates.get(roomId)?.peerId === peerId) {
presentationStates.delete(roomId);
broadcast(roomId, peerId, { type: "presentation-state", from: peerId, sharing: false });
broadcast(roomId, peerId, { type: "presentation-state", from: peerId, name, sharing: false });
}
if (room.size === 0) {
@@ -135,6 +227,11 @@ const server = http.createServer(async (req, res) => {
try {
if (req.method === "GET" && url.pathname.startsWith("/events/")) {
if (!captchaAccepted(url.searchParams.get("captcha"))) {
sendJson(res, 403, { error: "Captcha passphrase is required." });
return;
}
const roomId = url.pathname.split("/").at(-1) || defaultRoomId;
const displayName = normalizeName(url.searchParams.get("name"));
const clientId = normalizeClientId(url.searchParams.get("clientId"));
@@ -178,6 +275,20 @@ const server = http.createServer(async (req, res) => {
return;
}
if ((req.method === "GET" || req.method === "HEAD") && url.pathname === "/captcha.svg") {
res.writeHead(200, {
"content-type": "image/svg+xml; charset=utf-8",
"cache-control": "no-store"
});
res.end(req.method === "HEAD" ? undefined : captchaSvg());
return;
}
if (req.method === "GET" && url.pathname === "/captcha/verify") {
sendJson(res, 200, { ok: captchaAccepted(url.searchParams.get("answer")) });
return;
}
if (req.method === "POST" && url.pathname.startsWith("/room-event/")) {
const roomId = url.pathname.split("/").at(-1) || defaultRoomId;
const payload = await parseJson(req);
@@ -199,6 +310,7 @@ const server = http.createServer(async (req, res) => {
from: payload.from,
name: normalizeName(payload.name),
text,
voice: normalizeVoiceSettings(payload.voice),
updatedAt: Date.now()
};
broadcast(roomId, payload.from, event);
@@ -215,6 +327,20 @@ const server = http.createServer(async (req, res) => {
};
if (event.sharing) {
const previous = presentationStates.get(roomId);
if (previous?.peerId && previous.peerId !== payload.from) {
broadcast(roomId, payload.from, {
type: "presentation-state",
from: previous.peerId,
peerId: previous.peerId,
name: previous.name,
sharing: false,
reason: "takeover",
replacedBy: payload.from,
replacedByName: event.name,
updatedAt: Date.now()
});
}
presentationStates.set(roomId, { ...event, peerId: payload.from });
} else if (presentationStates.get(roomId)?.peerId === payload.from) {
presentationStates.delete(roomId);