Add voice announcement modal

This commit is contained in:
codex
2026-06-10 20:16:51 +02:00
parent 5491c149d4
commit 0cc5ae213d
5 changed files with 166 additions and 23 deletions
+37 -16
View File
@@ -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)))))))
+66 -4
View File
@@ -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");
+17
View File
@@ -102,6 +102,7 @@
</div>
<div class="control-actions" aria-label="Call controls">
<button id="share-button" type="button">Share Screen</button>
<button id="announce-button" type="button">Announce</button>
</div>
</div>
<article class="self-preview">
@@ -132,6 +133,22 @@
</form>
</dialog>
<dialog id="announcement-dialog" class="name-dialog">
<form id="announcement-form" class="initial-name-form">
<h2>Announce</h2>
<textarea
id="announcement-input"
maxlength="240"
placeholder="Announcement text"
required
></textarea>
<div class="dialog-actions">
<button id="announcement-cancel-button" type="button">Cancel</button>
<button type="submit">Send</button>
</div>
</form>
</dialog>
<div id="audio" hidden></div>
<script src="/vendor/speakjs/speakClient.js"></script>
<script type="module" src="/app.js"></script>
+21 -1
View File
@@ -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 {
+25 -2
View File
@@ -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;
}