From 0cc5ae213d8e765d66adec1fa3fcfe4a50a6d714 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 10 Jun 2026 20:16:51 +0200 Subject: [PATCH] Add voice announcement modal --- kkkmeet-server.el | 53 ++++++++++++++++++++++++----------- public/app.js | 70 ++++++++++++++++++++++++++++++++++++++++++++--- public/index.html | 17 ++++++++++++ public/styles.css | 22 ++++++++++++++- server.js | 27 ++++++++++++++++-- 5 files changed, 166 insertions(+), 23 deletions(-) diff --git a/kkkmeet-server.el b/kkkmeet-server.el index 3e5f839..32f406a 100644 --- a/kkkmeet-server.el +++ b/kkkmeet-server.el @@ -81,6 +81,11 @@ "Guest" (substring normalized 0 (min 48 (length normalized)))))) +(defun kkkmeet-server--normalize-announcement (text) + "Return a display-safe announcement TEXT." + (let ((normalized (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " (or text ""))))) + (substring normalized 0 (min 240 (length normalized))))) + (defun kkkmeet-server--nonnegative-number (value) "Return VALUE as a nonnegative number, or zero." (max 0 (if (numberp value) value 0))) @@ -361,23 +366,39 @@ When KEEP-OPEN is nil, close PROCESS after writing." (safe-room-id (if (string-empty-p room-id) kkkmeet-server--default-room-id room-id))) - (if (and from (equal type "presentation-state")) - (let ((event `((type . "presentation-state") - (from . ,from) - (peerId . ,from) - (name . ,(kkkmeet-server--normalize-name - (alist-get 'name payload))) - (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) - (when (equal (alist-get 'peerId (gethash safe-room-id kkkmeet-server--presentation-states)) - from) - (remhash safe-room-id kkkmeet-server--presentation-states))) - (kkkmeet-server--broadcast safe-room-id from event) - (kkkmeet-server--send-json process 202 '((ok . t)))) + (cond + ((and from (equal type "presentation-state")) + (let ((event `((type . "presentation-state") + (from . ,from) + (peerId . ,from) + (name . ,(kkkmeet-server--normalize-name + (alist-get 'name payload))) + (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) + (when (equal (alist-get 'peerId (gethash safe-room-id kkkmeet-server--presentation-states)) + from) + (remhash safe-room-id kkkmeet-server--presentation-states))) + (kkkmeet-server--broadcast safe-room-id from event) + (kkkmeet-server--send-json process 202 '((ok . t))))) + ((and from (equal type "voice-announcement")) + (let ((text (kkkmeet-server--normalize-announcement + (alist-get 'text payload)))) + (if (string-empty-p text) + (kkkmeet-server--send-json + process 400 '((error . "Voice announcements require text."))) + (let ((event `((type . "voice-announcement") + (from . ,from) + (name . ,(kkkmeet-server--normalize-name + (alist-get 'name payload))) + (text . ,text) + (updatedAt . ,(floor (* 1000 (float-time))))))) + (kkkmeet-server--broadcast safe-room-id from event) + (kkkmeet-server--send-json process 202 '((ok . t))))))) + (t (kkkmeet-server--send-json - process 400 '((error . "Room events require from and type=presentation-state."))))) + process 400 '((error . "Room events require from and a supported type.")))))) (error (kkkmeet-server--send-json process 400 `((error . ,(error-message-string error))))))) diff --git a/public/app.js b/public/app.js index 79807c5..29c45ef 100644 --- a/public/app.js +++ b/public/app.js @@ -23,6 +23,11 @@ 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 announceButton = document.querySelector("#announce-button"); +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 roomId = "main"; const nameStorageKey = "kkkmeet.displayName"; @@ -93,22 +98,42 @@ function normalizeName(name) { return name.trim().replace(/\s+/g, " ").slice(0, 48); } -function announceParticipantJoined(name) { - const participantName = normalizeName(name || "A participant") || "A participant"; +function normalizeAnnouncement(text) { + return String(text || "").trim().replace(/\s+/g, " ").slice(0, 240); +} + +function speakText(text) { if (typeof window.speak !== "function") return; try { - window.speak(`${participantName} has joined.`, { + window.speak(text, { amplitude: 100, pitch: 32, speed: 135, wordgap: 4 }); } catch (error) { - console.warn("Could not play join announcement.", error); + console.warn("Could not play voice announcement.", error); } } +function announceParticipantJoined(name) { + const participantName = normalizeName(name || "A participant") || "A participant"; + speakText(`${participantName} has joined.`); +} + +function speakAnnouncement(message) { + const text = normalizeAnnouncement(message?.text || ""); + if (!text) return; + speakText(text); +} + +function openAnnouncementDialog() { + announcementInput.value = ""; + announcementDialog.showModal(); + announcementInput.focus(); +} + function setDisplayName(nextName) { displayName = normalizeName(nextName); localStorage.setItem(nameStorageKey, displayName); @@ -825,6 +850,12 @@ async function handleSignal(message) { return; } + if (message.type === "voice-announcement") { + speakAnnouncement(message); + setStatus(`${message.name || "Someone"} sent an announcement.`); + return; + } + const connection = peers.get(message.from)?.connection; if (!connection) return; @@ -954,6 +985,15 @@ async function clearYoutubePlaylist() { await sendWatchState({ videoId: "", position: 0, playing: false, action: "pause" }); } +async function sendVoiceAnnouncement(text) { + const announcementText = normalizeAnnouncement(text); + if (!announcementText) return; + + await sendRoomEvent({ type: "voice-announcement", text: announcementText }); + speakAnnouncement({ text: announcementText }); + setStatus("Announcement sent."); +} + async function startScreenShare() { if (!localStream || screenStream) return; @@ -1011,6 +1051,28 @@ renameButton.addEventListener("click", () => { openNameDialog("rename"); }); +announceButton.addEventListener("click", () => { + openAnnouncementDialog(); +}); + +announcementCancelButton.addEventListener("click", () => { + announcementDialog.close(); +}); + +announcementDialog.addEventListener("cancel", () => { + announcementDialog.close(); +}); + +announcementForm.addEventListener("submit", (event) => { + event.preventDefault(); + const text = announcementInput.value; + announcementDialog.close(); + sendVoiceAnnouncement(text).catch((error) => { + console.error(error); + setStatus("Could not send announcement."); + }); +}); + cameraButton.addEventListener("click", () => { const enabled = cameraButton.getAttribute("aria-pressed") !== "true"; updateToggleButton(cameraButton, enabled, "Turn camera off", "Turn camera on"); diff --git a/public/index.html b/public/index.html index e3ff330..06c6cfc 100644 --- a/public/index.html +++ b/public/index.html @@ -102,6 +102,7 @@
+
@@ -132,6 +133,22 @@ + +
+

Announce

+ +
+ + +
+
+
+ diff --git a/public/styles.css b/public/styles.css index 78ab300..841bc82 100644 --- a/public/styles.css +++ b/public/styles.css @@ -92,12 +92,25 @@ h1 { } .initial-name-form input, +.initial-name-form textarea, .initial-name-form button { width: 100%; } +.initial-name-form textarea { + min-height: 110px; + resize: vertical; +} + +.dialog-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + input, -select { +select, +textarea { width: min(42vw, 260px); height: 42px; border: 1px solid rgba(255, 255, 255, 0.16); @@ -105,6 +118,13 @@ select { background: rgba(255, 255, 255, 0.08); color: #ffffff; padding: 0 12px; + font: inherit; +} + +textarea { + height: auto; + line-height: 1.4; + padding: 10px 12px; } select { diff --git a/server.js b/server.js index c10558c..5fa0319 100644 --- a/server.js +++ b/server.js @@ -39,6 +39,10 @@ function normalizeName(name) { return normalized.slice(0, 48) || "Guest"; } +function normalizeAnnouncement(text) { + return String(text || "").trim().replace(/\s+/g, " ").slice(0, 240); +} + function normalizePlaylist(playlist) { if (!Array.isArray(playlist)) return []; @@ -167,8 +171,27 @@ const server = http.createServer(async (req, res) => { const roomId = url.pathname.split("/").at(-1) || defaultRoomId; const payload = await parseJson(req); - if (!payload.from || payload.type !== "presentation-state") { - sendJson(res, 400, { error: "Room events require from and type=presentation-state." }); + if (!payload.from || !["presentation-state", "voice-announcement"].includes(payload.type)) { + sendJson(res, 400, { error: "Room events require from and a supported type." }); + return; + } + + if (payload.type === "voice-announcement") { + const text = normalizeAnnouncement(payload.text); + if (!text) { + sendJson(res, 400, { error: "Voice announcements require text." }); + return; + } + + const event = { + type: "voice-announcement", + from: payload.from, + name: normalizeName(payload.name), + text, + updatedAt: Date.now() + }; + broadcast(roomId, payload.from, event); + sendJson(res, 202, { ok: true }); return; }