Compare commits
10
Commits
819cf0f121
...
71cdd06ef5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71cdd06ef5 | ||
|
|
d391e0fb3b | ||
|
|
cb736a051a | ||
|
|
f303711d45 | ||
|
|
0ea5428240 | ||
|
|
c5048560e5 | ||
|
|
f80111dba4 | ||
|
|
bb3753e5ee | ||
|
|
ceb8f7edf5 | ||
|
|
8dc0299b75 |
+177
-37
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Vendored
+5
-1
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user