Add voice announcement modal
This commit is contained in:
+37
-16
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user